Skip to Content
Course content

170: Error Handling in Axum Handlers

Click on the "Edit" button in the top corner of the screen to edit your slide content.

Imagine you're a waiter at a high-end restaurant. The customer orders a specific bottle of vintage wine. You head back to the cellar, but you find out the bottle is corked, or maybe it's completely sold out. Now, you have a choice: you could walk back to the table and shout, "CELLAR ERROR 404: BOTTLE MISSING!" or you could translate that internal failure into something the customer understands, like "I'm so sorry, we've just run out of that vintage; may I suggest a similar Bordeaux?"

In Axum, your handler is that waiter. The "internal failure" is your Rust Result::Err, and the "customer-facing response" is the HTTP response. The problem is that Axum doesn't know how to "translate" a random std::io::Error or a sqlx::Error into an HTTP response automatically. If you try to return a standard Result where the error type doesn't implement IntoResponse, the compiler will yell at you. I've spent more time fighting that specific compiler error than I'd like to admit.

Giving Your Errors a Voice

The most professional way to handle this is to create a custom error enum. This lets you categorize everything that could go wrong in your domain and then decide exactly how each case should look to the outside world. I usually lean on the thiserror crate for this because it saves me from writing boilerplate Display implementations.

use axum::{
    http::StatusCode,
    response::{IntoResponse, Response},
    Json,
};
use serde_json::json;
use thiserror::Error;

#[derive(Error, Debug)]
pub enum AppError {
    #[error("User not found")]
    NotFound,
    #[error("Database connection failed")]
    DatabaseError(#[from] sqlx::Error),
    #[error("Invalid input: {0}")]
    ValidationError(String),
}

Notice the #[from] attribute on the DatabaseError. This is a lifesaver. It allows you to use the ? operator on database calls, and Rust will automatically wrap those low-level errors into your high-level AppError.

Translating Internal Chaos to HTTP

Now we have to do the "waiter" part: implementing IntoResponse. This is where you map your internal enum variants to actual HTTP status codes and JSON payloads. I prefer returning a JSON object even for errors; it makes the frontend developer's life so much easier.

impl IntoResponse for AppError {
    fn into_response(self) -> Response {
        let (status, error_message) = match self {
            AppError::NotFound => (StatusCode::NOT_FOUND, self.to_string()),
            AppError::ValidationError(msg) => (StatusCode::BAD_REQUEST, msg),
            AppError::DatabaseError(_) => {
                // We don't want to leak raw DB errors to the client for security reasons
                (StatusCode::INTERNAL_SERVER_ERROR, "Internal server error".to_string())
            }
        };

        let body = Json(json!({
            "error": error_message,
        }));

        (status, body).into_response()
    }
}

See what happened with DatabaseError? I logged the internal error (implicitly via the enum) but sent a generic message to the user. Never leak your database schema or connection strings in an HTTP response. That's an open invitation for hackers.

Cleaning Up the Handlers

Once this infrastructure is in place, your handlers become incredibly clean. You stop worrying about match blocks inside your business logic and just let the ? operator do the heavy lifting. Your handler's return type just needs to be Result<T, AppError>.

async fn get_user_handler(Path(user_id): Path<u64>) -> Result<Json<User>, AppError> {
    let user = db::find_user(user_id)
        .await
        .map_err(|e| match e {
            sqlx::Error::RowNotFound => AppError::NotFound,
            _ => AppError::DatabaseError(e),
        })?;

    Ok(Json(user))
}

I'll be honest: sometimes you'll be tempted to just return Result<T, (StatusCode, String)>. It works for tiny projects, but the moment your app grows, you'll find yourself duplicating error strings everywhere. Stick to the enum pattern. It's a bit more setup upfront, but it pays dividends the moment you need to change a status code across ten different endpoints.




📋 Practical Task

Implementing a Validated Product Inventory Error Pipeline

You are building a product management API. You need to create a robust error handling system that distinguishes between different types of failures when updating product stock.

Requirements:

  • Create an enum called InventoryError.
  • Include three variants:
    • InsufficientStock (should return 400 Bad Request)
    • ProductNotFound (should return 404 Not Found)
    • InternalFailure (should return 500 Internal Server Error)
  • Implement IntoResponse for InventoryError so that it returns a JSON response in the format {"code": "ERROR_CODE", "message": "Human readable message"}.
  • Write a mock handler update_stock_handler that returns Result<StatusCode, InventoryError>.
  • In the handler, simulate a failure by returning Err(InventoryError::InsufficientStock) using the ? operator or a direct return.
Rating
0 0

There are no comments for now.

to be the first to leave a comment.