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 byIntoResponse, 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.
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();
}
Source: axum example: error-handling
$ 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}
Source: axum example: error-handling
The design choices:
-
One enum per application, with a
#[from]conversion for every error source, so handlers just use?. The#[error(transparent)]attribute forwardsDisplayto the wrapped error. -
Mapping lives in one function.
status_and_titleis the only place that decides that a duplicate ISBN is a409and a pool timeout a503. sqlx’sDatabaseError::is_unique_violation()(andis_foreign_key_violation,is_check_violation) inspect the SQLSTATE without string matching. -
Log server errors with full detail, never expose them. A
500body must not contain SQL, file paths or stack traces; theDebugrepresentation goes to the logs, correlated by the request’s trace ID (see Logging & Tracing). Client errors are logged atwarn(ordebugfor noisy ones such as404). -
Domain errors stay domain errors.
shelf_core::CatalogErrorknows 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
}
Source: axum: Applying fallible middleware and HandleErrorLayer
$ curl -s localhost:3000/api/v1/reports/slow
GET /api/v1/reports/slow took too long
Source: axum: Applying fallible middleware and HandleErrorLayer
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();
}
Source: tower-http: catch_panic
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 |
|---|---|
|
|
|
|
|
|
|
return |
|
|
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.