Chapter 7: Handling Errors in Rust and Rocket

In the previous chapter, we learned about creating endpoints and SQL queries to handle the management of the User entity. In this chapter, we are going to learn more about error handling in Rust and Rocket. After learning the concepts in this chapter, you will be able to implement error handling in a Rocket application.

We are also going to discuss more common ways to handle errors in Rust and Rocket, including signaling unrecoverable errors using the panic! macro and catching the panic! macro, using Option, using Result, creating a custom Error type, and logging the generated error.

In this chapter, we're going to cover the following main topics:

  • Using panic!
  • Using Option
  • Returning Result
  • Creating a custom error type
  • Logging errors

Technical requirements

For this chapter, we have the same technical requirements as the previous chapter. We need a Rust compiler, a text editor, an HTTP client, and a PostgreSQL database server.

You can find the source code for this chapter at https://github.com/PacktPublishing/Rust-Web-Development-with-Rocket/tree/main/Chapter07.

Using panic!

To understand error handling in Rust, we need to begin with the panic! macro. We can use the panic! macro when the application encounters an unrecoverable error and there's no purpose in continuing the application. If the application encounters panic!, the application will emit the backtrace and terminate.

Let's try using panic! on the program that we created in the previous chapter. Suppose we want the application to read a secret file before we initialize Rocket. If the application cannot find this secret file, it will not continue.

Let's get started:

  1. Add the following line in src/main.rs:

    use std::env;

  2. In the same file in the rocket() function, prepend the following lines:

    let secret_file_path = env::current_dir().unwrap().join("secret_file");

    if !secret_file_path.exists() {

        panic!("secret does not exists");

    }

  3. Afterward, try executing cargo run without creating an empty file named secret_file inside the working directory. You should see the output as follows:

    thread 'main' panicked at 'secret does not exists', src/main.rs:15:9

    note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace

  4. Now, try running the application again with RUST_BACKTRACE=1 cargo run. You should see the backtrace output in the terminal similar to the following:

    RUST_BACKTRACE=1 cargo run

        Finished dev [unoptimized + debuginfo] target(s)

        in 0.18s

         Running `target/debug/our_application`

    thread 'main' panicked at 'secret does not exists', src/main.rs:15:9

    stack backtrace:

    ...

      14: our_application::main

                 at ./src/main.rs:12:36

      15: core::ops::function::FnOnce::call_once

                 at /rustc/59eed8a2aac0230a8b5

                 3e89d4e99d55912ba6b35/library/core/

                 src/ops/function.rs:227:5

    note: Some details are omitted, run with `RUST_BACKTRACE=full` for a verbose backtrace.

  5. Sometimes, we don't want to deallocate after panicking using the panic! macro because we want the application to exit as soon as possible. We can skip deallocating by setting panic = "abort" in Cargo.toml under the profile we are using. Setting that configuration will make our binary smaller and exit faster, and the operating system will need to clean it later. Let's try doing that. Set the following lines in Cargo.toml and run the application again:

    [profile.dev]

    panic = "abort"

Now that we know how to use panic!, let's see how we can catch it in the next section.

Catching panic!

As well as using panic!, we can also use the todo! and unimplemented! macros in Rust code. Those macros are very useful for prototyping because they will call panic! while also allowing the code to type-check at compile time.

But, why does Rocket not shut down when we are calling a route with todo!? If we check the Rocket source code, there's a catch_unwind function in src::panic that can be used to capture a panicking function. Let's see that code in the Rocket source code, core/lib/src/server.rs:

let fut = std::panic::catch_unwind(move || run())
         .map_err(|e| panic_info!(name, e))
         .ok()?;

Here, run() is a route handling function. Each time we call a route that is panicking, the preceding routine will convert the panic into the result's Err variant. Try removing the secret_file_path routine we added before and running the application. Now, create a user and try going into user posts. For example, create a user with the 95a54c16-e830-45c9-ba1d-5242c0e4c18f UUID. Try opening http://127.0.0.1/users/95a54c16-e830-45c9-ba1d-5242c0e4c18f/posts. Since we only put todo!("will implement later") in the function body, the application will panic, but the preceding catch_unwind function will catch the panic and convert it into an error. Please note that catch_unwind will not work if we set panic = "abort" in Cargo.toml.

In a regular workflow, we don't usually want to use panic!, because panicking interrupts everything, and the program will not be able to continue. If the Rocket framework does not catch panic! and one of the route handling functions is panicking, then that single error will close the application and there will be nothing to handle the other requests. But, what if we want to terminate the Rocket application when we encounter an unrecoverable error? Let's see how we can do it in the next section.

Using shutdown

To shut down smoothly if the application encounters an unrecoverable error in the route handling function, we can use the rocket::Shutdown request guard. Remember, the request guard is a parameter we are supplying to the route handling functions.

To see the Shutdown request guard in action, let's try implementing it in our application. Using the previous application, add a new route in src/routes/mod.rs called /shutdown:

use rocket::Shutdown;
...
#[get("/shutdown")]
pub async fn shutdown(shutdown: Shutdown) -> &'static str {
    // suppose this variable is from function which 
    // produces irrecoverable error
    let result: Result<&str, &str> = Err("err");
    if result.is_err() {
        shutdown.notify();
        return "Shutting down the application.";
    }
    return "Not doing anything.";
}

Try adding the shutdown() function in src/main.rs. After that, rerun the application and send an HTTP request to /shutdown while monitoring the output of the application on the terminal. The application should shut down smoothly.

In the next two sections, let's see how we can use Option and Result as an alternative way to handle errors.

Using Option

In programming, a routine might produce a correct result or encounter a problem. One classical example is division by zero. Dividing something by zero is mathematically undefined. If the application has a routine to divide something, and the routine encounters zero as input, the application cannot return any number. We want the application to return another type instead of a number. We need a type that can hold multiple variants of data.

In Rust, we can define an enum type, a type that can be different variants of data. An enum type might be as follows:

enum Shapes {
    None,
    Point(i8),
    Line(i8, i8),
    Rectangle {
        top: (i8, i8),
        length: u8,
        height: u8,
    },
}

Point and Line are said to have unnamed fields, while Rectangle is said to have named fields. Rectangle can also be called a struct-like enum variant.

If all members of enum have no data, we can add a discriminant on the member. Here is an example:

enum Color {
    Red,         // 0
    Green = 127, // 127
    Blue,        // 128
}

We can assign enum to a variable, and use the variable in a function as in the following:

fn do_something(color: Color) -> Shapes {
    let rectangle = Shapes::Rectangle {
        top: (0, 2),
        length: 10,
        height: 8,
    };
    match color {
        Color::Red => Shapes::None,
        Color::Green => Shapes::Point(10),
        _ => rectangle,
    }
}

Going back to error handling, we can use enum to communicate that there's something wrong in our code. Going back to division by zero, here is an example:

enum Maybe {
    WeCannotDoIt,
    WeCanDoIt(i8),
}
fn check_divisible(input: i8) -> Maybe {
    if input == 0 {
        return Maybe::WeCannotDoIt;
    }
    Maybe::WeCanDoIt(input)
}

The preceding pattern returning something or not returning something is very common, so Rust has its own enum to show whether we have something or not in the standard library, called std::option::Option:

pub enum Option<T> {
    None,
    Some(T),
}

Some(T) is used to communicate that we have T, and None is obviously used to communicate that we don't have T. We used Option in some of the previous code. For example, we used it in the User struct:

struct User {
    ...
    description: Option<String>,
    ...
}

We also used Option as a function parameter or return type:

find_all(..., pagination: Option<Pagination>) -> (..., Option<Pagination>), ... {}

There are many useful things we can use with Option. Suppose we have two variables, we_have_it and we_do_not_have_it:

let we_have_it: Option<usize> = Some(1);
let we_do_not_have_it: Option<usize> = None;
  • One thing we can do is pattern matching and use the content:

    match we_have_it {

        Some(t) => println!("The value = {}", t),

        None => println!("We don't have it"),

    };

  • We can process it in a more convenient way if we care about the content of we_have_it:

    if let Some(t) = we_have_it {

        println!("The value = {}", t);

    }

  • Option can be compared if the inner type implements std::cmp::Eq and std::cmp::Ord, that is, the inner type can be compared using ==, !=, >, and other comparison operators. Notice that we use assert!, a macro used for testing:

    assert!(we_have_it != we_do_not_have_it);

  • We can check whether a variable is Some or None:

    assert!(we_have_it.is_some());

    assert!(we_do_not_have_it.is_none());

  • We can also get the content by unwrapping Option. But, there's a caveat; unwrapping None will cause panic, so be careful when unwrapping Option. Notice we use assert_eq!, which is a macro used for testing to ensure equality:

    assert_eq!(we_have_it.unwrap(), 1);

    // assert_eq!(we_do_not_have_it.unwrap(), 1);

    // will panic

  • We can also use the expect() method. This method will work the same with unwrap() but we can use a custom message:

    assert_eq!(we_have_it.expect("Oh no!"), 1);

    // assert_eq!(we_do_not_have_it.expect("Oh no!"), 1); // will panic

  • We can unwrap and set the default value so it will not panic if we unwrap None:

    assert_eq!(we_have_it.unwrap_or(42), 1);

    assert_eq!(we_do_not_have_it.unwrap_or(42), 42);

  • We can unwrap and set the default value with a closure:

    let x = 42;

    assert_eq!(we_have_it.unwrap_or_else(|| x), 1);

    assert_eq!(we_do_not_have_it.unwrap_or_else(|| x), 42);

  • We can convert the value contained to something else using map(), map_or(), or map_or_else():

    assert_eq!(we_have_it.map(|v| format!("The value = {}", v)), Some("The value = 1".to_string()));

    assert_eq!(we_do_not_have_it.map(|v| format!("The value = {}", v)), None);

    assert_eq!(we_have_it.map_or("Oh no!".to_string(), |v| format!("The value = {}", v)), "The value = 1".to_string());

    assert_eq!(we_do_not_have_it.map_or("Oh no!".to_string(), |v| format!("The value = {}", v)), "Oh no!".to_string());

    assert_eq!(we_have_it.map_or_else(|| "Oh no!".to_string(), |v| format!("The value = {}", v)), "The value = 1".to_string());

    assert_eq!(we_do_not_have_it.map_or_else(|| "Oh no!".to_string(), |v| format!("The value = {}", v)), "Oh no!".to_string());

There are other important methods, which you can check in the documentation for std::option::Option. Even though we can use Option to handle a situation where there's something or nothing, it does not convey a message of something went wrong. We can use another type similar to Option in the next part to achieve this.

Returning Result

In Rust, we have the std::result::Result enum that works like Option, but instead of saying we have it or we don't have it, the Result type is more about saying we have it or we have this error. Just like Option, Result is an enum type of the possible T type or possible E error:

enum Result<T, E> {
   Ok(T),
   Err(E),
}

Going back to the division by zero problem, take a look at the following simple example:

fn division(a: usize, b: usize) -> Result<f64, String> {
    if b == 0 {
        return Err(String::from("division by zero"));
    }
    return Ok(a as f64 / b as f64);
}

We don't want division by 0, so we return an error for the preceding function.

Similar to Option, Result has many convenient features we can use. Suppose we have the we_have_it and we_have_error variables:

let we_have_it: Result<usize, &'static str> = Ok(1);
let we_have_error: Result<usize, &'static str> = Err("Oh no!");
  • We can get the value or the error using pattern matching:

    match we_have_it {

        Ok(v) => println!("The value = {}", v),

        Err(e) => println!("The error = {}", e),

    };

  • Or, we can use if let to destructure and get the value or the error:

    if let Ok(v) = we_have_it {

        println!("The value = {}", v);

    }

    if let Err(e) = we_have_error {

        println!("The error = {}", e);

    }

  • We can compare the Ok variant and the Err variant:

    assert!(we_have_it != we_have_error);

  • We can check whether a variable is an Ok variant or an Err variant:

    assert!(we_have_it.is_ok());

    assert!(we_have_error.is_err());

  • We can convert Result to Option:

    assert_eq!(we_have_it.ok(), Some(1));

    assert_eq!(we_have_error.ok(), None);

    assert_eq!(we_have_it.err(), None);

    assert_eq!(we_have_error.err(), Some("Oh no!"));

  • Just like Option, we can use unwrap(), unwrap_or(), or unwrap_or_else():

    assert_eq!(we_have_it.unwrap(), 1);

    // assert_eq!(we_have_error.unwrap(), 1);

    // panic

    assert_eq!(we_have_it.expect("Oh no!"), 1);

    // assert_eq!(we_have_error.expect("Oh no!"), 1);

    // panic

    assert_eq!(we_have_it.unwrap_or(0), 1);

    assert_eq!(we_have_error.unwrap_or(0), 0);

    assert_eq!(we_have_it.unwrap_or_else(|_| 0), 1);

    assert_eq!(we_have_error.unwrap_or_else(|_| 0), 0);

  • And, we can use map(), map_err(), map_or(), or map_or_else():

    assert_eq!(we_have_it.map(|v| format!("The value = {}", v)), Ok("The value = 1".to_string()));

    assert_eq!(

        we_have_error.map(|v| format!("The error = {}",

        v)),

        Err("Oh no!")

    );

    assert_eq!(we_have_it.map_err(|s| s.len()), Ok(1));

    assert_eq!(we_have_error.map_err(|s| s.len()), Err(6));

    assert_eq!(we_have_it.map_or("Default value".to_string(), |v| format!("The value = {}", v)), "The value = 1".to_string());

    assert_eq!(we_have_error.map_or("Default value".to_string(), |v| format!("The value = {}", v)), "Default value".to_string());

    assert_eq!(we_have_it.map_or_else(|_| "Default value".to_string(), |v| format!("The value = {}", v)), "The value = 1".to_string());

    assert_eq!(we_have_error.map_or_else(|_| "Default value".to_string(), |v| format!("The value = {}", v)), "Default value".to_string());

There are other important methods besides those methods in the std::result::Result documentation. Do check them because Option and Result are very important in Rust and Rocket.

Returning a string or numbers as an error might be acceptable in some cases, but most likely, we want a real error type with a message and possible backtrace that we can process further. In the next section, we are going to learn about (and use) the Error trait and return the dynamic error type in our application.

Creating a custom error type

Rust has a trait to unify propagating errors by providing the std::error::Error trait. Since the Error trait is defined as pub trait Error: Debug + Display, any type that implements Error should also implement the Debug and Display traits.

Let's see how we can create a custom error type by creating a new module:

  1. In src/lib.rs, add the new errors module:

    pub mod errors;

  2. After that, create a new folder, src/errors, and add the src/errors/mod.rs and src/errors/our_error.rs files. In src/errors/mod.rs, add this line:

    pub mod our_error;

  3. In src/errors/our_error.rs, add the custom type for error:

    use rocket::http::Status;

    use std::error::Error;

    use std::fmt;

    #[derive(Debug)]

    pub struct OurError {

        pub status: Status,

        pub message: String,

        debug: Option<Box<dyn Error>>,

    }

    impl fmt::Display for OurError {

        fn fmt(&self, f: &mut fmt::Formatter<'_>) ->

        fmt::Result {

            write!(f, "{}", &self.message)

        }

    }

  4. Then, we can implement the Error trait for OurError. In src/errors/our_error.rs, add the following lines:

    impl Error for OurError {

        fn source(&self) -> Option<&(dyn Error + 'static)> {

            if self.debug.is_some() {

                self.debug.as_ref().unwrap().source();

            }

            None

        }

    }

Currently, for the User module, we return a Result<..., Box<dyn Error>> dynamic error for each method. This is a common pattern of returning an error by using any type that implements Error and then putting the instance in the heap using Box.

The problem with this approach is we can only use methods provided by the Error trait, that is, source(). We want to be able to use the OurError status, message, and debug information.

  1. So, let's add a couple of builder methods to OurError. In src/errors/our_error.rs, add the following lines:

    impl OurError {

        fn new_error_with_status(status: Status, message:

        String, debug: Option<Box<dyn Error>>) -> Self {

            OurError {

                status,

                message,

                debug,

            }

        }

        pub fn new_bad_request_error(message: String,

        debug: Option<Box<dyn Error>>) -> Self {

            Self::new_error_with_status(Status::

            BadRequest, message, debug)

        }

        pub fn new_not_found_error(message: String,

        debug: Option<Box<dyn Error>>) -> Self {

            Self::new_error_with_status(Status::NotFound,

            message, debug)

        }

        pub fn new_internal_server_error(

            message: String,

            debug: Option<Box<dyn Error>>,

        ) -> Self {

            Self::new_error_with_status(Status::

            InternalServerError, message, debug)

        }

    }

  2. If we take a look at src/models/user.rs, there are three sources of errors: sqlx::Error, uuid::Error, and argon2. Let's create a conversion for sqlx::Error and uuid::Error to OurError. Add the following use directive in src/errors/our_error.rs:

    use sqlx::Error as sqlxError;

    use uuid::Error as uuidError;

  3. Inside the same file, src/errors/our_error.rs, add the following lines:

    impl OurError {

        ...

        pub fn from_uuid_error(e: uuidError) -> Self {

            OurError::new_bad_request_error(

                String::from("Something went wrong"),

                Some(Box::new(e)))

        }

    }

  4. For sqlx::Error, we want to convert not_found error to HTTP status 404 and duplicate index error to an HTTP status 400bad request. Add the following lines to src/errors/our_error.rs:

    use std::borrow::Cow;

    ....

    impl OurError {

        ....

        pub fn from_sqlx_error(e: sqlxError) -> Self {

            match e {

                sqlxError::RowNotFound => {

                    OurError::new_not_found_error(

                        String::from("Not found"),

                        Some(Box::new(e)))

                }

                sqlxError::Database(db) => {

                    if db.code().unwrap_or(Cow::

                    Borrowed("2300")).starts_with("23") {

                        return OurError::new_bad_

                        request_error(

                            String::from("Cannot create or

                            update resource"),

                            Some(Box::new(db)),

                        );

                    }

                    OurError::new_internal_server_error(

                        String::from("Something went

                        wrong"),

                        Some(Box::new(db)),

                    )

                }

                _ => OurError::new_internal_server_error(

                    String::from("Something went wrong"),

                    Some(Box::new(e)),

                ),

            }

        }

    }

  5. We need to do one more thing before we modify our User entity. Some crates in Rust do not compile the std library by default to make the resulting binary smaller and embeddable in IoT (Internet of Things) devices or WebAssembly. For example, the argon2 crate does not include the Error trait implementation by default, so we need to enable the std feature. In Cargo.toml, modify the argon2 dependencies to enable the std library features:

    argon2 = {version = "0.3", features = ["std"]}

  6. In src/models/user.rs, delete use std::error::Error; and replace it with use crate::errors::our_error::OurError;. Then, we can replace the methods for User to use OurError instead. Here is an example:

    pub async fn find(connection: &mut PgConnection, uuid: &str) -> Result<Self, OurError> {

        let parsed_uuid = Uuid::parse_str(

        uuid).map_err(OurError::from_uuid_error)?;

        let query_str = "SELECT * FROM users WHERE uuid =

        $1";

        Ok(sqlx::query_as::<_, Self>(query_str)

            .bind(parsed_uuid)

            .fetch_one(connection)

            .await

            .map_err(OurError::from_sqlx_error)?)

    }

  7. For the argon2 error, we can create a function or method, or convert it manually. For example, in src/models/user.rs, we can do this:

    let password_hash = argon2

        .hash_password(new_user.password.as_bytes(),

         &salt)

        .map_err(|e| {

            OurError::new_internal_server_error(

                String::from("Something went wrong"),

                Some(Box::new(e)),

            )

        })?;

Change all the methods to use OurError. Just a reminder: you can find the complete source code for src/models/user.rs in the GitHub repository at https://github.com/PacktPublishing/Rust-Web-Development-with-Rocket/tree/main/Chapter07.

  1. We will then use the OurError status and message in src/routes/user.rs. Because the Error type already implements the Display trait, we can use e directly inside format!(). Here is an example:

    pub async fn get_user(...) -> HtmlResponse {

    ...

        let user = User::find(connection,

        uuid).await.map_err(|e| e.status)?;

    ...

    }

    ...

    pub async fn delete_user(...) -> Result<Flash<Redirect>, Flash<Redirect>> {

    ...

        User::destroy(connection, uuid)

            .await

            .map_err(|e| Flash::error(Redirect::to("/

             users"), format!("<div>{}</div>", e)))?;

    ...

    }

You can find the complete source code for src/routes/user.rs in the GitHub repository. Now that we have implemented errors, it might be a good time to try to implement the catchers that we defined before in src/catchers/mod.rs to show default errors for the user. You can also see the example of the default catchers in the source code.

In an application, tracking and logging errors are an important part of maintaining the application. Since we implemented the Error trait, we can log the source() of an error in our application. Let's take a look at how to do that in the next section.

Logging errors

In Rust, there's a log crate that provides a facade for application logging. The log provides five macros: error!, warn!, info!, debug!, and trace!. An application can then create a log based on the severity and filter what needs to be logged, also based on the severity. For example, if we filter based on warn, then we only log error! and warn! and ignore the rest. Since the log crate does not implement the logging itself, people often use another crate to do the actual implementation. In the documentation for the log crate, we can find examples of other logging crates that can be used: env_logger, simple_logger, simplelog, pretty_env_logger, stderrlog, flexi_logger, log4rs, fern, syslog, and slog-stdlog.

Let's implement custom logging in our application. We will use the fern crate for logging and wrap that in async_log to make logging asynchronous:

  1. First, add these crates in Cargo.toml:

    async-log = "2.0.0"

    fern = "0.6"

    log = "0.4"

  2. In Rocket.toml, add the config for log_level:

    log_level = "normal"

  3. We can then create the function to initialize a global logger in our application. In src/main.rs, create a new function called setup_logger:

    fn setup_logger() {}

  4. Inside the function, let's initialize the logger:

    use log::LevelFilter;

    ...

    let (level, logger) = fern::Dispatch::new()

        .format(move |out, message, record| {

            out.finish(format_args!(

                "[{date}] [{level}][{target}] [{

                 message}]",

                date = chrono::Local::now().format("[

                %Y-%m-%d][%H:%M:%S%.3f]"),

                target = record.target(),

                level = record.level(),

                message = message

            ))

        })

        .level(LevelFilter::Info)

        .chain(std::io::stdout())

        .chain(

            fern::log_file("logs/application.log")

                .unwrap_or_else(|_| panic!("Cannot open

                logs/application.log")),

        )

        .into_log();

First, we create a new instance of fern::Dispatch. After that, we configure the output format using the format() method. After setting the output format, we set the log level using the level() method.

For the logger, we want to not only output the log to the operating system stdout, but we also want to write to a log file. We can do it using the chain() method. To avoid panicking, don't forget to create a logs folder in the application directory.

  1. After we set up the level and logger, we wrap it inside async_log:

    async_log::Logger::wrap(logger, || 0).start(level).unwrap();

  2. We will log OurError when it's created. Inside src/errors/our_error.rs, add the following lines:

    impl OurError {

        fn new_error_with_status(...) ... {

            if debug.is_some() {

                log::error!("Error: {:?}", &debug);

            }

            ...

        }

    }

  3. Add the setup_logger() function to src/main.rs:

    async fn rocket() -> Rocket<Build> {

        setup_logger();

    ...

    }

  4. Now, let's try to see OurError inside the application log. Try creating users with the same username; the application should emit a duplicate username error in the terminal and logs/application.log similar to the following:

    [[2021-11-21][17:50:49.366]] [ERROR][our_application::errors::our_error]

    [Error: Some(PgDatabaseError { severity: Error, code: "23505", message:

    "duplicate key value violates unique constraint "users_username_key""

    , detail: Some("Key (username)=(karuna) already exists."), hint: None, p

    osition: None, where: None, schema: Some("public"), table: Some("users")

    , column: None, data_type: None, constraint: Some("users_username_key"),

    file: Some("nbtinsert.c"), line: Some(649), routine: Some("_bt_check_un

    ique") })]

Now that we have learned how to log errors, we can implement logging functionalities to improve the application. For example, we might want to create server-side analytics, or we can combine the logs with third-party monitoring as a service to improve the operations and create business intelligence.

Summary

In this chapter, we have learned some ways to handle errors in Rust and Rocket applications. We can use panic!, Option, and Result as a way to propagate errors and create handling for the errors.

We have also learned about creating a custom type that implements the Error trait. The type can store another error, creating an error chain.

Finally, we learned ways to log errors in our application. We can also use log capability to improve the application itself.

Our user pages are looking good, but using String all over the place is cumbersome, so in the next chapter, we are going to learn more about templating using CSS, JavaScript, and other assets in our application.

..................Content has been hidden....................

You can't read the all page of ebook, please click here login for view all page.
Reset
18.188.216.249