Render error pages in Axum

This morning I received an email from Google telling me one of my pages had index issues by rendering a 5xx.

When I visited the page it returned a "Page not found" database error, and while it's technically an error, there are better response codes for this scenario.

Time to implement proper error pages.

Site structure

This site is split up in a couple of crates, to keep compile times low (in theory).

.
├── api
├── bin
├── global
└── web

The global crate contains code to interact with the database, and the web crate renders HTML.

Database errors

I use the thiserror crate to emit errors from my Global crate like this:

use thiserror::Error;

#[derive(Debug, Error)]
pub enum DataStoreError {
    #[error(transparent)]
    DieselError(#[from] diesel::result::Error),

    #[error("Database Pool Error: {0}")]
    PoolError(#[from] deadpool_diesel::PoolError),

    #[error("Database Result Error: {0}")]
    DeadpoolError(#[from] deadpool_diesel::Error),

    #[error("Migration error: {0}")]
    MigrationError(String),
}

It converts the Diesel and Deadpool errors to a single enum without having to implement a bunch of traits.

The goal here is to render a 404 page for a NotFound Diesel error, and render a 500 for the rest, so I Make the diesel::result::Error Transparent to save me a bit of de-structuring later on.

HTTP errors

The goal for Http errors is to not have to write map_err every time, but ideally I can just use the ? syntax everywhere and it "Just works(tm)".

To do this we need to make sure Axum understands what to do with the raised errors. And this can be done by implementing IntoResponse for the error enum.

My error enum looks like this;

pub enum AppError {
    NotFound,
    Unauthorized,
    Internal(anyhow::Error),
}

And I implement IntoResponse like so:

impl IntoResponse for AppError {
    fn into_response(self) -> Response {
        let (code, body) = match self {
            AppError::NotFound => (StatusCode::NOT_FOUND, not_found()),
            AppError::Unauthorized => (StatusCode::UNAUTHORIZED, unauthorized()),
            AppError::Internal(inner) => {
                tracing::error!(
                    "Returning http error: 500, for error: {}",
                    inner.to_string()
                );
                (StatusCode::INTERNAL_SERVER_ERROR, internal_error(inner))
            }
        };
        (code, body).into_response()
    }
}

I render HTML templates for each error and return the proper status code, depending on the error type.

Now that we can render error pages, we still need to cover the scenario that NotFound errors render as a 404, and not a 500.

I do this by implementing the From trait for the DataStoreError into the AppError.

impl From<DataStoreError> for AppError {
    fn from(inner: DataStoreError) -> Self {
        match inner {
            DataStoreError::DieselError(DieselError::NotFound) => Self::NotFound,
            _ => Self::Internal(inner.into()),
        }
    }
}

And for good measure, I also do this for anyhow::Error, to match pretty much every other error type.

impl From<anyhow::Error> for AppError {
    fn from(inner: anyhow::Error) -> Self {
        AppError::Internal(inner)
    }
}

With these traits implemented, I can now return a Result in my application code and Axum handles the rest.

pub async fn index(State(state): State<GlobalState>) -> Result<Markup, AppError> {
    let posts = state
        .store
        .list_pages_for_collection("blog_posts")
        .await?;
    
    #[...]
}