DEV Community

Nivethan
Nivethan

Posted on • Updated on • Originally published at nivethan.dev

A Web App in Rust - 14 Error Handling

Welcome back! We just cleaned up our passwords and now in this chapter we're finally going to do something we should have been doing before. We're going to actually handle our errors. Buckle in! This is going to be a long one. (Simple though!)

Currently, any and all errors just cause our application to crash. This means that we are always blowing up when our user does something that triggers an error. For some errors this is helpful but because this is a web application, we can't be causing our users to have to deal with our application just breaking in the middle of a request. This is why we need to set up a error handler.

Let's get started!

Preamble

Before we start, let's take a look at one function in particular and how to correct it. We're going to look at our process_login function because it is the first page where our user and our application interact and can run into problems.

./src/main.rs

...
async fn process_login(data: web::Form<LoginUser>, id: Identity, pool: web::Data<Pool>) -> impl Responder {
    use schema::users::dsl::{username, users};

    let connection = pool.get().unwrap();
    let user = users.filter(username.eq(&data.username)).first::<User>(&connection);

    match user {
        Ok(u) => {
            dotenv().ok();
            let secret = std::env::var("SECRET_KEY")
                .expect("SECRET_KEY must be set");

            let valid = Verifier::default()
                .with_hash(u.password)
                .with_password(data.password.clone())
                .with_secret_key(secret)
                .verify()
                .unwrap();

            if valid {
                let session_token = String::from(u.username);
                id.remember(session_token);
                HttpResponse::Ok().body(format!("Logged in: {}", data.username))
            } else {
                HttpResponse::Ok().body("Password is incorrect.")
            }
        },
        Err(e) => {
            println!("{:?}", e);
            HttpResponse::Ok().body("User doesn't exist.")
        }
    }
}
...
Enter fullscreen mode Exit fullscreen mode

We have 4 places in our function that we can panic, blow up, break, end the world, error out of.

The first error spot is our pool.get function. We currently have an unwrap that will cause a panic if pool.get() comes back with an error.

The second error is our users.filter function. In this case we are doing a match statement to handle the error. This is a valid way to handle the error but we'll be making it a touch better.

The third error we need to deal with is our secret, currently we use the .expect function where rust will panic but will print our error message along with it.

The fourth error is our Verifier function, our verify call could come back with an error and we simply have an unwrap there. Our application would just panic and stop.

As you can see, we have quite a few errors, and currently besides just one, we let the errors cause our application to break. For the user this means they get a broken page.

Let's duplicate this, one error we can trigger is our secret key.

./src/main.rs

            let secret = std::env::var("SECRET_KEY_FAKE")
                .expect("SECRET_KEY must be set");
Enter fullscreen mode Exit fullscreen mode

Let's change our var to something that we know doesn't exist in our .env file.

Now if we navigate to our 127.0.0.1:8000/login and try to login we will cause rust to panic.

In our terminal we should see the following.

thread 'actix-rt:worker:2' panicked at 'SECRET_KEY must be set: NotPresent', src/main.rs:127:51
Enter fullscreen mode Exit fullscreen mode

This is a helpful message. However the user sees something completely different.

User - Firefox

The connection was reset

The connection to the server was reset while the page was loading.

    The site could be temporarily unavailable or too busy. Try again in a few moments.
    If you are unable to load any pages, check your computer’s network connection.
    If your computer or network is protected by a firewall or proxy, make sure that Firefox is permitted to access the Web.
Enter fullscreen mode Exit fullscreen mode

Unhelpful to say the least!

Now you can see the problem. Let's fix it!

Handling Errors

What we want to do when an error is encountered is we want to send the user back a message saying that there is an error on the server, please try again later. Depending on the error we may want to give them more or less information.

After all if our database is blowing up, that isn't something our users want to know, however if there password is causing issues, then of course we want to let them know.

This is why errors can fall into 2 categories, we have Internal Errors that are our application's errors and we have User Error for errors driven by the user.

3 of our errors in process_login are Internal Errors, 1 is a User Error.

Actix supplies us with the ResponseError trait from actix_web::error, this trait allows us to make it so that we can define errors and HttpResponses to send.

./src/main.rs

...
#[derive(Debug)]
enum ServerError {
    ArgonauticError, 
    DieselError,
    EnvironmentError,
    R2D2Error,
    UserError(String)
}
...
Enter fullscreen mode Exit fullscreen mode

The first thing we do is set up an enum with a variety of errors. Really we only need two errors, Internal and User but to make it a little bit clear, we're going to have separate options for the different errors we can generate.

Now the next step is to implement the Display trait for our enum. This is what will get printed if we do a println!("{}", ServerError::DieselError). This is mandatory.

./src/main.rs

impl std::fmt::Display for ServerError {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result{
        write!(f, "Test")
    }
}
Enter fullscreen mode Exit fullscreen mode

Unfortunately I can't explain too much besides that it works!

  • Amusingl, this code was basically written by the compiler as it told me what to do.

Now let's get to the fun part. Let's implement the actix trait ResponseError onto our ServerError.

./src/main.rs

...
impl actix_web::error::ResponseError for ServerError {
    fn error_response(&self) -> HttpResponse {
        match self {
            ServerError::ArgonauticError => HttpResponse::InternalServerError().json("Argonautica Error."),
            ServerError::DieselError => HttpResponse::InternalServerError().json("Diesel Error."),
            ServerError::EnvironmentError => HttpResponse::InternalServerError().json("Environment Error."),
            ServerError::UserError(data) => HttpResponse::InternalServerError().json(data)
        }
    }
}
...
Enter fullscreen mode Exit fullscreen mode

error_response is an actix function inside ResponseError that gets run. So when we write this function for our ServerError, it will override the error_response function that exists by default on ResponseError.

So in our error_response function, all we're going to do is send back certain HttpResponses depending on the match. If we trigger an ArgonauticaError, we're going to sending something different from our DieselError.

This is because we want to be clear but in reality the user doesn't care and so these messages could all be collapsed into one generic internal error message.

Now for the key part. What we need to do is now implement a From trait on our ServerError so that if an error is generated in our process_login function, it will get automatically converted into one of our errors.

The first step we need to do is change our return type on our function. Instead of a Responder, we're now going to return a Result.

./src/main.rs

async fn process_login(data: web::Form<LoginUser>, id: Identity, pool: web::Data<Pool>) -> Result<HttpResponse, ServerError> {
Enter fullscreen mode Exit fullscreen mode

Now we will return a Result of HttpResponse or a Result of ServerError.

Next we need to update our function so that it sends back a Result type. This means we need to wrap all the HttpResponses in process_login with Ok().

./src/main.rs

async fn process_login(data: web::Form<LoginUser>, id: Identity, pool: web::Data<Pool>) -> impl Responder {
    use schema::users::dsl::{username, users};

    let connection = pool.get().unwrap();
    let user = users.filter(username.eq(&data.username)).first::<User>(&connection);

    match user {
        Ok(u) => {
            dotenv().ok();
            let secret = std::env::var("SECRET_KEY")
                .expect("SECRET_KEY must be set");

            let valid = Verifier::default()
                .with_hash(u.password)
                .with_password(data.password.clone())
                .with_secret_key(secret)
                .verify()
                .unwrap();

            if valid {
                let session_token = String::from(u.username);
                id.remember(session_token);
                Ok(HttpResponse::Ok().body(format!("Logged in: {}", data.username)))
            } else {
                Ok(HttpResponse::Ok().body("Password is incorrect."))
            }
        },
        Err(e) => {
            println!("{:?}", e);
            Ok(HttpResponse::Ok().body("User doesn't exist."))
        }
    }
}
...
Enter fullscreen mode Exit fullscreen mode

Now let's change our secret statement to instead of using .expect we will now use the shorthand ?.

./src/main.rs

let secret = std::env::var("SECRET_KEY_FAKE")?;
Enter fullscreen mode Exit fullscreen mode

What this means is that if SECRET_KEY_FAKE isn't there then it should immediately return with the Error. Before we had it panic, now we return with the error.

Our return type is set to Result ServerError so this means that rust will attempt convert the error we get from std::env to our ServerError.

To do this we need to implement a From trait on our ServerError.

./src/main.rs

...
impl From<std::env::VarError> for ServerError {
    fn from(_: std::env::VarError) -> ServerError {
        ServerError::EnvironmentError
    }
}
...
Enter fullscreen mode Exit fullscreen mode

All this trait is doing is if it gets an Error of type std::env:VarError then it will return a ServerError::EnvironmentError.

Then our ResponseError's error_response function will run and match against EnvironmentErrr.

Now we should be able to navigate to 127.0.0.1:8000/login on our browser and try to login. Instead of panicking, we should see nothing in our terminal and our user should see an error saying "Environment Error".

! That is error handling! We just took an error of the secret key and instead of our request dying immediately, we handled it and sent back a message to the user.

R2D2Error

Now let's handle the pool.get().unwrap. To handle the error we need to implement a From trait for the specific error. Connection pooling is done through through the crate r2d2 so we will need to get the error from there.

./src/main.rs

...
impl From<r2d2::Error> for ServerError {
    fn from(_: r2d2::Error) -> ServerError {
        ServerError::R2D2Error
    }
}
...
Enter fullscreen mode Exit fullscreen mode

We could combine our Diesel and r2d2 errors into one database error type if wanted but we'll leave it for now.

./src/main.rs

let connection = pool.get()?;
Enter fullscreen mode Exit fullscreen mode

We can now get rid of our unwrap.

That's 2 errors down. Another 2 to go. Now let's deal with a user error.

UserError

./src/main.rs

    let user = users.filter(username.eq(&data.username)).first::<User>(&connection);
Enter fullscreen mode Exit fullscreen mode

Currently we filter our users table looking for a user and we get Result back in our user variable. If the Result is Ok, we have a user, if we get an Err, it means the user doesn't exist.

Our match statement is very much a valid strategy for error handling but it does cause our logic to start getting nested. Instead of a match we can clean it up by adding the From trait to ServerError for diesel Errors.

./src/main.rs

impl From<diesel::result::Error> for ServerError {
    fn from(err: diesel::result::Error) -> ServerError {
        match err {
            diesel::result::Error::NotFound => ServerError::UserError("Username not found.".to_string()),
            _ => ServerError::DieselError
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

This trait is slightly different from our other traits. This is because when diesel returns with an Error here, it doesn't mean that we have an application error, it could be something user did as well.

In this case, the user could enter a username not in the database and that would cause our filter statement above to error out.

So in our From trait, we meed to process the diesel error. In this case we match only against NotFound, if the error from diesel is of that type, then we send back a UserError with a message.

The _ means, match the rest, if any other error from diesel is being processed, simply send back ServerError::DieselError.

With that trait, we can now remove our match statement.

./src/main.rs

async fn process_login(data: web::Form<LoginUser>, id: Identity, pool: web::Data<Pool>) -> Result<HttpResponse, ServerError> {
    use schema::users::dsl::{username, users};

    let connection = pool.get()?;
    let user = users.filter(username.eq(&data.username)).first::<User>(&connection)?;

    dotenv().ok();
    let secret = std::env::var("SECRET_KEY")?;

    let valid = Verifier::default()
        .with_hash(user.password)
        .with_password(data.password.clone())
        .with_secret_key(secret)
        .verify()
        .unwrap();

    if valid {
        let session_token = String::from(user.username);
        id.remember(session_token);
        Ok(HttpResponse::Ok().body(format!("Logged in: {}", data.username)))
    } else {
        Ok(HttpResponse::Ok().body("Password is incorrect."))
    }
}

Enter fullscreen mode Exit fullscreen mode

Now you can see that without the errors handled in our function itself, the code gets quite clean.

ArgonauticaError

We're almost there, the final error we'll handle is the Argonautica error from our Verifier.

Let's implement the trait.

./src/main.rs

impl From<argonautica::Error> for ServerError {
    fn from(_: argonautica::Error) -> ServerError {
        ServerError::ArgonauticError
    }
}
Enter fullscreen mode Exit fullscreen mode

With that, all we have to do is change our verify() to use the question mark.

./src/main.rs

    let valid = Verifier::default()
        .with_hash(user.password)
        .with_password(data.password.clone())
        .with_secret_key(secret)
        .verify()?;
Enter fullscreen mode Exit fullscreen mode

Voila! We have set up error handling for quite a few errors now. Now we should be able to go through the rest of our application and rewrite all of our .expects and .unwraps to get handled rather than causing our application to panic and the request to just end.

This chapter is getting a little bit long for my tastes but hopefully you can see the structure that error handling really is. An enum that has a few From traits is enough to transform a wide variety of errors into something we can handle.

Note - Handling errors for tera would be quite useful, change the return type of afunction from responder to to Result, add an enum or use an existing one, implement the From trait to convert tera errors to ServerErrors. Have fun!

Discussion (2)

Collapse
direstrepo24 profile image
direstrepo24

Hi, excellent post, I have followed it step by step, but I got the following error in impl actix_web::error::ResponseError for ServerError, in self:
non-exhaustive patterns: & R2D2Error not covered
ensure that all possible cases are being handled, possibly by adding wildcards or more match arms
the matched value is of type & ServerError, can you help me or do you have the github repo? Thanks

Collapse
direstrepo24 profile image
direstrepo24

I think I will solve it by implementing the missing error case: ServerError :: R2D2Error => HttpResponse :: InternalServerError (). json ("r2d2 error"), in the implementation of: impl actix_web :: error :: ResponseError for ServerError