zurück / back

Beautiful errors in Rust using exn, thiserror and axum

Error handling in Rust

I've been interested in implementing ActivityPub in Rust for quite a while. Recently Juan and I joined forces to work on such a project. Quickly it became obvious to me, that I have to level up my error handling skills.

When starting with Rust I used anyhow a lot. After writing more code and getting a better understanding of the type system, I switched to thiserror. Having more specific errors is nice, but I missed the ability to add context to errors. I had a look at snafu, but before giving it a try, I was pointed to this article.

I immediately liked the approach of exn. But if you want to use it in Axum, you will hit some of those details of Rust that are obvious for experienced Rustaceans, but confusing for beginners. So I thought it might be helpful for somebody if I wrote down the details.

What is exn?

The exn docs are good, but they don't use thiserror which makes the examples a bit more verbose than they need to be. So I created an example using thiserror:

use exn::{Result, ResultExt};

#[derive(Debug, thiserror::Error)]
#[error("{0}")]
struct Error(&'static str);

fn inner() -> Result<(), Error> {
    let _ = std::env::var("NON_EXISTING")
        .or_raise(
            || Error("Additional details from inner function")
        )?;
    Ok(())
}

fn outer() -> Result<(), Error> {
    inner().or_raise(|| Error("Context from outer function"))
}

fn main() {
    if let Err(e) = outer() {
        eprintln!("{e:?}");
    }
}

And here is the output generated by this program:

Context from outer function, at src/main.rs:14:13
|
|-> Additional details from inner function, at src/main.rs:9:10
|
|-> environment variable not found, at src/main.rs:9:10

That's pretty cool, in my opinion. You can handle all error types of underlying libraries without having to define enum variants for all of them. And you can use the full power of thiserror to implement specific errors that carry more dynamic context information.

Let's see how this works out in an Axum web application!

exn, axum and the orphan rule

The axum documentation says:

In axum a “handler” is an async function that accepts [...] and returns something that can be converted into a response.

So the IntoResponse trait needs to be implemented for the return type of a handler. Axum provides the implementation for Result, but it requires its underlying types to implement IntoResponse too. Otherwise it could not do its job. Let's have a look at the following handler:

async fn my_handler() -> exn::Result<String, Error> {
    // ...
}

Error is our own error type from the example above. Let's have a closer look at exn:Result to understand why this will not work:

pub type Result<T, E> = std::result::Result<T, Exn<E>>;

exn::Result is a type alias for std::result::Result. But the error type in our case is Exn<Error>, which does not implement IntoResponse, which in turn means that IntoResponse is not implemented for this Result.

Implementing the trait is not rocket science, but we control neither IntoResponse nor Exn<>. We are simply not allowed to do so. That's the orphan rule in action.

A wrapper type to the rescue

We can solve this by wrapping Exn<T> into a type controlled by us. Afterwards, we can implement IntoResponse for our own type. Let's get started with the wrapper type:

#[derive(Debug, thiserror::Error)]
#[error("{0}")]
pub struct ResponseError(Exn<Error>);

I'm using Error here. In real-world code you would probably parametrize the error type, but to show the idea, I preferred to keep things simple. Now we are ready to implement IntoResponse for ResponseError.

impl IntoResponse for ResponseError {
    fn into_response(self) -> axum::response::Response {
        (
            StatusCode::INTERNAL_SERVER_ERROR,
            format!("{:?}", self.0)
        ).into_response()
    }
}

We are getting closer. Axum would be happy if we use std::result::Result<_,ResponseError> as return type. But the rest of our code still uses Exn<Error>. We could convert between both manually, but that will lead to cluttered and ugly code.

Question mark magic

The question mark operator has a built-in feature that will keep our code clean and tidy. If you apply the question mark operator to a Result<_,E1>::Err, but your result type is Result<_E2>, then it will try to convert E1 into E2. In our case, Exn<Error> would need to be converted into ReponseError(Exn<Error>). That's easily enabled by implementing the From trait:

impl From<Exn<Error>> for ResponseError<Error> {
    fn from(value: Exn<Error>) -> Self {
        Self(value)
    }
}

Let's introduce another type alias to hide the wrapper from the handler code.

pub type HttpResult<T> = Result<T, ResponseError<Error>>;

We are done! Axum handlers can now be written like this:

async fn my_handler() -> HttpResult<String> {
    outer()
        .or_raise(||Error("Context from handler"))?;
    // ...
}

As mentioned already: In real code, you would probably parametrize the types more, but that would involve various trait bounds, which can be confusing when just getting started. It is perfectly OK, to tackle one topic after another.

Please let me know if this was helpful, if you have questions or if you have proposals how to imporove the code.

Impressum