Error Handling

This content was generated with the assistance of AI and should be verified against the official documentation before being relied on in production.

The reference material consulted while preparing these pages is listed in the section bibliography.

This page is written against axum 0.8.9 on Rust 1.99 (edition 2024) with thiserror 2.0, anyhow 1.0, sqlx 0.9 and tower-http 0.7, checked on 2026-10-06. It builds the AppError type that every Bookshelf handler returns. The language side — Result, ?, the Error trait, thiserror and anyhow — is covered on Rust: Error Handling.

Where it runs in the request

In the request lifecycle (tokio accepts the connection, hyper parses the request, tower layers wrap the router, the router matches a route, middleware and extractors run, the handler runs, the response travels back out), errors can arise at three points, and each one ends as a response travelling back out:

  • in a fallible tower layer around the router or a route (timeouts, load shedding) — converted with HandleErrorLayer, because routed services must be infallible;

  • in an extractor, after routing and middleware — a rejection that becomes the response before the handler runs;

  • in the handler — an Err(AppError) turned into a response by IntoResponse, the main subject of this page.

axum’s error model

axum’s documentation states the rule plainly: handlers are infallible. There is no exception mechanism and no framework-level "500 handler". A handler returns a value that implements IntoResponse; if it returns Result<T, E>, then E must implement IntoResponse too, and the Err value is the response. The same applies to services: a routed service must have Error = Infallible.

This makes error handling explicit and local. Every failure path is visible in the types, and you decide in one place — the IntoResponse implementation of your error type — which status code, which body and which log line each kind of failure produces.

flowchart LR subgraph sources["Error sources"] D["domain rule
CatalogError::AlreadyLent"] V["validation
invalid ISBN"] A["authentication
missing token"] S["sqlx::Error
RowNotFound / unique violation / pool timeout"] X["anything else
anyhow::Error"] end D -->|From| E["AppError"] V -->|From| E A -->|From| E S -->|From| E X -->|From| E E -->|IntoResponse| R["status code +
Problem Details JSON"] E -->|IntoResponse| L["tracing event:
warn for 4xx, error with details for 5xx"]

An AppError for Bookshelf

use axum::{
    Json, Router,
    extract::{Path, rejection::JsonRejection},
    http::{StatusCode, header},
    response::{IntoResponse, Response},
    routing::{get, post},
};
use serde::Serialize;
use shelf_core::{CatalogError, Isbn, IsbnError};

#[derive(Debug, thiserror::Error)]
pub enum AppError {
    #[error(transparent)]
    InvalidIsbn(#[from] IsbnError),
    #[error(transparent)]
    Catalog(#[from] CatalogError),
    #[error(transparent)]
    BadBody(#[from] JsonRejection),
    #[error("authentication required")]
    Unauthorized,
    #[error("database error")]
    Database(#[from] sqlx::Error),
    #[error(transparent)]
    Internal(#[from] anyhow::Error),
}

/// RFC 9457 Problem Details.
#[derive(Serialize)]
struct Problem {
    #[serde(rename = "type")]
    kind: &'static str,
    title: &'static str,
    status: u16,
    #[serde(skip_serializing_if = "Option::is_none")]
    detail: Option<String>,
}

impl AppError {
    fn status_and_title(&self) -> (StatusCode, &'static str) {
        match self {
            AppError::InvalidIsbn(_) => (StatusCode::BAD_REQUEST, "Invalid ISBN"),
            AppError::Catalog(CatalogError::NotFound(_)) => (StatusCode::NOT_FOUND, "Book not found"),
            AppError::Catalog(CatalogError::Duplicate(_)) => (StatusCode::CONFLICT, "Book already exists"),
            AppError::Catalog(CatalogError::AlreadyLent(_) | CatalogError::NotLent(_)) => {
                (StatusCode::CONFLICT, "Loan state conflict")
            }
            AppError::BadBody(r) => (r.status(), "Invalid request body"),
            AppError::Unauthorized => (StatusCode::UNAUTHORIZED, "Authentication required"),
            AppError::Database(sqlx::Error::RowNotFound) => (StatusCode::NOT_FOUND, "Not found"),
            AppError::Database(sqlx::Error::Database(db)) if db.is_unique_violation() => {
                (StatusCode::CONFLICT, "Already exists")
            }
            AppError::Database(sqlx::Error::PoolTimedOut) => (StatusCode::SERVICE_UNAVAILABLE, "Try again later"),
            AppError::Database(_) | AppError::Internal(_) => (StatusCode::INTERNAL_SERVER_ERROR, "Internal error"),
        }
    }
}

impl IntoResponse for AppError {
    fn into_response(self) -> Response {
        let (status, title) = self.status_and_title();
        // Client errors: safe to explain. Server errors: log everything, expose nothing.
        let detail = if status.is_server_error() {
            tracing::error!(error = ?self, "request failed");
            None
        } else {
            tracing::warn!(error = %self, %status, "request rejected");
            Some(self.to_string())
        };
        let body = Problem { kind: "about:blank", title, status: status.as_u16(), detail };
        (status, [(header::CONTENT_TYPE, "application/problem+json")], Json(body)).into_response()
    }
}

async fn show_book(Path(isbn): Path<String>) -> Result<String, AppError> {
    let isbn = Isbn::parse(&isbn)?; // IsbnError -> AppError::InvalidIsbn
    Err(CatalogError::NotFound(isbn).into())
}

async fn report() -> Result<String, AppError> {
    let ratio = "x".parse::<f64>().map_err(anyhow::Error::from)?; // any error -> Internal
    Ok(ratio.to_string())
}

async fn create(payload: Result<Json<serde_json::Value>, JsonRejection>) -> Result<StatusCode, AppError> {
    let Json(_book) = payload?; // JsonRejection -> AppError::BadBody
    Ok(StatusCode::CREATED)
}

#[tokio::main]
async fn main() {
    tracing_subscriber::fmt().with_env_filter("info").init();
    let app = Router::new()
        .route("/api/v1/books/{isbn}", get(show_book))
        .route("/api/v1/books", post(create))
        .route("/api/v1/report", get(report));
    let listener = tokio::net::TcpListener::bind("127.0.0.1:3000").await.unwrap();
    axum::serve(listener, app).await.unwrap();
}
$ curl -s localhost:3000/api/v1/books/123
{"type":"about:blank","title":"Invalid ISBN","status":400,"detail":"an ISBN-13 has 13 digits, found 3"}
$ curl -s localhost:3000/api/v1/books/9780441013593
{"type":"about:blank","title":"Book not found","status":404,"detail":"no book with ISBN 9780441013593"}
$ curl -s localhost:3000/api/v1/report
{"type":"about:blank","title":"Internal error","status":500}

The design choices:

  • One enum per application, with a #[from] conversion for every error source, so handlers just use ?. The #[error(transparent)] attribute forwards Display to the wrapped error.

  • Mapping lives in one function. status_and_title is the only place that decides that a duplicate ISBN is a 409 and a pool timeout a 503. sqlx’s DatabaseError::is_unique_violation() (and is_foreign_key_violation, is_check_violation) inspect the SQLSTATE without string matching.

  • Log server errors with full detail, never expose them. A 500 body must not contain SQL, file paths or stack traces; the Debug representation goes to the logs, correlated by the request’s trace ID (see Logging & Tracing). Client errors are logged at warn (or debug for noisy ones such as 404).

  • Domain errors stay domain errors. shelf_core::CatalogError knows nothing about HTTP; the web crate decides how to present it.

anyhow for the long tail

For failures that callers cannot act on — a corrupt file, an unexpected parse error — defining a variant each time is not worth it. The Internal(#[from] anyhow::Error) variant accepts anything, and anyhow’s .context("...") adds a human-readable trail for the logs. The official anyhow-error-response example uses a wrapper struct AppError(anyhow::Error) with a blanket impl<E: Into<anyhow::Error>> From<E>, so ? works on every error type — a good start for small services, at the cost of mapping everything to 500.

Problem Details (RFC 9457)

RFC 9457 (which obsoletes RFC 7807) defines a standard JSON shape for HTTP API errors, served as application/problem+json:

{
  "type": "https://bookshelf.example/problems/loan-limit",
  "title": "Loan limit reached",
  "status": 409,
  "detail": "Member 42 already has 5 books on loan.",
  "instance": "/api/v1/loans",
  "max_loans": 5
}

type is a URI identifying the problem type (about:blank when the status code says it all), title is a short summary that does not change between occurrences, detail explains this occurrence, instance identifies it, and extension members (max_loans) carry machine-readable data. FastAPI, ASP.NET Core and Spring all support the format, so clients written for those stacks understand Bookshelf's errors unchanged. Validation errors add a list of field errors as an extension member (see Validation).

Errors from middleware: HandleErrorLayer

Many tower middlewares are fallible: tower::timeout::Timeout fails with Elapsed, LoadShed with Overloaded, Buffer with a closed-channel error — all as BoxError. axum’s router requires infallible services, so applying such a layer directly does not compile. HandleErrorLayer sits outside the fallible layer and converts its error into a response; its handler can also take extractors (Method, Uri, …​) before the error argument:

use std::time::Duration;

use axum::{
    BoxError, Router,
    error_handling::HandleErrorLayer,
    http::{Method, StatusCode, Uri},
    routing::get,
};
use tower::ServiceBuilder;

async fn handle_middleware_error(method: Method, uri: Uri, err: BoxError) -> (StatusCode, String) {
    if err.is::<tower::timeout::error::Elapsed>() {
        (StatusCode::REQUEST_TIMEOUT, format!("{method} {uri} took too long"))
    } else if err.is::<tower::load_shed::error::Overloaded>() {
        (StatusCode::SERVICE_UNAVAILABLE, "server overloaded, try again".to_owned())
    } else {
        tracing::error!(%err, "unhandled middleware error");
        (StatusCode::INTERNAL_SERVER_ERROR, "internal error".to_owned())
    }
}

#[tokio::main]
async fn main() {
    let app = Router::new()
        .route("/api/v1/reports/slow", get(|| async {
            tokio::time::sleep(Duration::from_secs(3)).await;
            "done"
        }))
        .layer(
            ServiceBuilder::new()
                .layer(HandleErrorLayer::new(handle_middleware_error)) // outermost: catches errors below
                .load_shed()
                .concurrency_limit(64)
                .timeout(Duration::from_secs(1)),
        );
    let listener = tokio::net::TcpListener::bind("127.0.0.1:3000").await.unwrap();
    axum::serve(listener, app.into_make_service()).await.unwrap(); // build the limit once, not per connection
}
$ curl -s localhost:3000/api/v1/reports/slow
GET /api/v1/reports/slow took too long

tower-http’s middleware is infallible by design (its TimeoutLayer responds with a status code), which is why most applications need HandleErrorLayer only for tower’s generic layers. Where HandleErrorLayer goes in the stack is covered on Applying Middleware & Ordering.

Panics: CatchPanicLayer as the last resort

A panic in a handler unwinds the task serving that connection: the client sees the connection close without a response, and the panic message goes to stderr. tower-http’s CatchPanicLayer catches the unwind and turns it into a 500 response, optionally with a custom body:

use std::any::Any;

use axum::{
    Router,
    body::Body,
    http::{Response, StatusCode, header},
    routing::get,
};
use tower_http::catch_panic::CatchPanicLayer;

fn panic_response(err: Box<dyn Any + Send + 'static>) -> Response<Body> {
    let message = err
        .downcast_ref::<String>()
        .map(String::as_str)
        .or_else(|| err.downcast_ref::<&str>().copied())
        .unwrap_or("unknown panic");
    tracing::error!(panic = message, "handler panicked");
    Response::builder()
        .status(StatusCode::INTERNAL_SERVER_ERROR)
        .header(header::CONTENT_TYPE, "application/problem+json")
        .body(Body::from(r#"{"type":"about:blank","title":"Internal error","status":500}"#))
        .expect("static response is valid")
}

async fn boom() -> &'static str {
    let shelves: Vec<&str> = Vec::new();
    shelves[3] // panics: index out of bounds
}

#[tokio::main]
async fn main() {
    let app = Router::new()
        .route("/boom", get(boom))
        .layer(CatchPanicLayer::custom(panic_response));
    let listener = tokio::net::TcpListener::bind("127.0.0.1:3000").await.unwrap();
    axum::serve(listener, app).await.unwrap();
}

CatchPanicLayer does not work with panic = "abort" in the release profile, and it is not a substitute for error handling: a panic may leave shared state half-updated (a poisoned Mutex), so treat every caught panic as a bug to fix.

Replace unwrap() in handlers

unwrap() and expect() are appropriate where a failure means the program is wrong or cannot start: building a static regex, parsing a compile-time constant, binding the listener in main. In a handler, the input comes from the network and the database, and failure is normal. The replacement is mechanical:

Instead of Write

pool.fetch_one(q).await.unwrap()

pool.fetch_one(q).await? (sqlx error → AppError::Database)

isbn.parse::<Isbn>().unwrap()

isbn.parse::<Isbn>()? or extract Path<Isbn> directly

map.get(&k).unwrap()

map.get(&k).ok_or(AppError::...)?

serde_json::to_string(&x).unwrap()

return Json(x); serialisation errors become a 500 automatically

state.lock().unwrap()

.expect("... lock poisoned") is acceptable: poisoning means an earlier panic

Where the books are outdated

Yoon (Beginning Axum, chapter 3) calls unwrap() on database results inside handlers and returns plain strings for errors; Crossley et al. (Rust for Scientific Computing, ch. 15) does the same in its web example. Use ? with an AppError that implements IntoResponse instead, and keep expect for start-up. Problem Details was RFC 7807 in older material; RFC 9457 (2023) replaces it with a compatible format.