Guide contents

Lesson 5 of 30

Error Handling and Rejections in Axum

A service returns errors in two places: inside handlers (a product is missing) and before handlers (a request body does not parse). This lesson builds one ApiError type, one JSON error format, and custom extractors so both places produce the same response. It builds on Axum routing, state, and handlers and REST API design. For Rust error basics see Tokio error handling.

Project setup

Cargo.toml

TOML
[package]
name = "storefront-errors"
version = "0.1.0"
edition = "2021"

[dependencies]
axum = { version = "0.8.4", features = ["macros"] }
tokio = { version = "1.44", features = ["full"] }
serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0"
thiserror = "2"
tower-http = { version = "0.6", features = ["catch-panic"] }
tracing = "0.1"
tracing-subscriber = "0.3"

The macros feature of axum enables the FromRequest derive used in Example 3. The catch-panic feature of tower-http enables CatchPanicLayer.

Example 1: A handler that returns Result

A handler may return Result<T, E> when both T and E implement IntoResponse, the trait that turns a value into an HTTP response. An Err value becomes a response like an Ok value. This file is complete and runs as src/main.rs.

Rust
use axum::{
    extract::Path,
    http::StatusCode,
    response::{IntoResponse, Response},
    routing::get,
    Json, Router,
};
use serde_json::{json, Value};

enum ApiError {
    NotFound(u64),
}

impl IntoResponse for ApiError {
    fn into_response(self) -> Response {
        match self {
            ApiError::NotFound(id) => (
                StatusCode::NOT_FOUND,
                Json(json!({ "code": "product_not_found", "message": format!("product {id} not found") })),
            )
                .into_response(),
        }
    }
}

async fn get_product(Path(id): Path<u64>) -> Result<Json<Value>, ApiError> {
    if id == 7 {
        Ok(Json(json!({ "id": 7, "name": "Notebook" })))
    } else {
        Err(ApiError::NotFound(id))
    }
}

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

Returning a bare StatusCode also compiles, but the client receives an empty body and cannot tell a missing product from a wrong route. The enum gives every failure a status and a body in one place.

Example 2: One error type with thiserror and problem details

thiserror derives std::error::Error and Display for an enum. #[error("...")] sets the message of a variant. #[from] on a field derives From for that type, so ? and the extractor derive can convert into the enum.

The response body follows RFC 9457 problem details, a standard JSON error shape with the media type application/problem+json. Its members are type (a URI naming the kind of problem), title (constant per type), status, detail (specific to this occurrence), and optional extension members such as errors.

src/error.rs

Rust
use axum::{
    extract::rejection::{JsonRejection, PathRejection, QueryRejection},
    http::{header, HeaderValue, StatusCode},
    response::{IntoResponse, Response},
    Json,
};
use serde::Serialize;
use thiserror::Error;

#[derive(Debug, Clone, Serialize)]
pub struct FieldError {
    pub field: &'static str,
    pub message: String,
}

#[derive(Debug, Error)]
pub enum ApiError {
    #[error("product {0} not found")]
    ProductNotFound(u64),
    #[error("validation failed")]
    Validation(Vec<FieldError>),
    #[error("a product with SKU {0} already exists")]
    DuplicateSku(String),
    #[error("invalid JSON body")]
    BadJson(#[from] JsonRejection),
    #[error("invalid path parameter")]
    BadPath(#[from] PathRejection),
    #[error("invalid query string")]
    BadQuery(#[from] QueryRejection),
    #[error("no such route")]
    RouteNotFound,
    #[error("method not allowed")]
    MethodNotAllowed,
    #[error("internal error: {0}")]
    Internal(String),
}

#[derive(Serialize)]
struct ProblemDetails {
    #[serde(rename = "type")]
    kind: String,
    title: &'static str,
    status: u16,
    detail: String,
    #[serde(skip_serializing_if = "Option::is_none")]
    errors: Option<Vec<FieldError>>,
}

impl IntoResponse for ApiError {
    fn into_response(self) -> Response {
        let (status, kind, title) = match &self {
            ApiError::ProductNotFound(_) => (StatusCode::NOT_FOUND, "product-not-found", "Product not found"),
            ApiError::Validation(_) => (StatusCode::UNPROCESSABLE_ENTITY, "validation-failed", "Validation failed"),
            ApiError::DuplicateSku(_) => (StatusCode::CONFLICT, "duplicate-sku", "Duplicate SKU"),
            ApiError::BadJson(r) => (r.status(), "bad-request-body", "Invalid request body"),
            ApiError::BadPath(r) => (r.status(), "bad-path", "Invalid path parameter"),
            ApiError::BadQuery(r) => (r.status(), "bad-query", "Invalid query string"),
            ApiError::RouteNotFound => (StatusCode::NOT_FOUND, "route-not-found", "Route not found"),
            ApiError::MethodNotAllowed => (StatusCode::METHOD_NOT_ALLOWED, "method-not-allowed", "Method not allowed"),
            ApiError::Internal(_) => (StatusCode::INTERNAL_SERVER_ERROR, "internal-error", "Internal error"),
        };

        if status.is_server_error() {
            tracing::error!(error = ?self, "request failed");
        } else {
            tracing::debug!(error = ?self, "request rejected");
        }

        let detail = match &self {
            ApiError::Internal(_) => "the server could not complete the request".to_owned(),
            other => other.to_string(),
        };
        let errors = match &self {
            ApiError::Validation(list) => Some(list.clone()),
            _ => None,
        };

        let body = ProblemDetails {
            kind: format!("https://errors.storefront.example/{kind}"),
            title,
            status: status.as_u16(),
            detail,
            errors,
        };

        let mut response = (status, Json(body)).into_response();
        response
            .headers_mut()
            .insert(header::CONTENT_TYPE, HeaderValue::from_static("application/problem+json"));
        response
    }
}
  • 4xx variants are client mistakes. 5xx variants are server failures. The split drives retries and alerts.
  • Internal is logged with its full debug text and answered with fixed text. Internal messages can contain table names or query text.
  • The Bad* variants keep the rejection as a source and take the status from it (400, 415, or 422 for JSON). The client sees a fixed detail, and the log gets the rejection through {:?}.
  • Errors are logged once, here, where they become responses. Logging at every layer produces duplicates.
  • A database error maps by meaning: a unique violation becomes 409 and a missing row becomes 404. The SQLx lesson adds that From<sqlx::Error> conversion to this pattern.

Example 3: Extractors that use ApiError

A rejection is the error an extractor returns when it cannot build its value. Json<T> returns JsonRejection, and the default response is plain text. The FromRequest derive wraps an existing extractor (via) and converts its rejection into a chosen type (rejection). The chosen type needs From<ThatRejection> and IntoResponse. ApiError has both through #[from].

src/extract.rs

Rust
use axum::extract::{FromRequest, FromRequestParts};

use crate::error::ApiError;

#[derive(FromRequest)]
#[from_request(via(axum::Json), rejection(ApiError))]
pub struct AppJson<T>(pub T);

#[derive(FromRequestParts)]
#[from_request(via(axum::extract::Path), rejection(ApiError))]
pub struct AppPath<T>(pub T);

#[derive(FromRequestParts)]
#[from_request(via(axum::extract::Query), rejection(ApiError))]
pub struct AppQuery<T>(pub T);

FromRequest reads the body, so it is used for Json. FromRequestParts reads only the request head, so it is used for Path and Query. Handlers that take AppJson, AppPath, and AppQuery return the same error format for every extraction failure. The axum-extra crate offers WithRejection<E, R> for the same conversion without a wrapper struct.

Example 4: Fallbacks and panics

Requests that never reach a handler need the same format:

  • Router::fallback handles unknown paths.
  • Router::method_not_allowed_fallback handles known paths with an unsupported method.
  • CatchPanicLayer::custom turns a handler panic into a response. Without it the connection closes with no response. Panics are bugs, so the layer is a safety net and expected failures still use Result.

The wiring is in src/main.rs in the complete example below.

Complete example

Text
storefront-errors/
  Cargo.toml
  src/
    main.rs
    error.rs
    extract.rs

Cargo.toml

TOML
[package]
name = "storefront-errors"
version = "0.1.0"
edition = "2021"

[dependencies]
axum = { version = "0.8.4", features = ["macros"] }
tokio = { version = "1.44", features = ["full"] }
serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0"
thiserror = "2"
tower-http = { version = "0.6", features = ["catch-panic"] }
tracing = "0.1"
tracing-subscriber = "0.3"

src/error.rs

Rust
use axum::{
    extract::rejection::{JsonRejection, PathRejection, QueryRejection},
    http::{header, HeaderValue, StatusCode},
    response::{IntoResponse, Response},
    Json,
};
use serde::Serialize;
use thiserror::Error;

#[derive(Debug, Clone, Serialize)]
pub struct FieldError {
    pub field: &'static str,
    pub message: String,
}

#[derive(Debug, Error)]
pub enum ApiError {
    #[error("product {0} not found")]
    ProductNotFound(u64),
    #[error("validation failed")]
    Validation(Vec<FieldError>),
    #[error("a product with SKU {0} already exists")]
    DuplicateSku(String),
    #[error("invalid JSON body")]
    BadJson(#[from] JsonRejection),
    #[error("invalid path parameter")]
    BadPath(#[from] PathRejection),
    #[error("invalid query string")]
    BadQuery(#[from] QueryRejection),
    #[error("no such route")]
    RouteNotFound,
    #[error("method not allowed")]
    MethodNotAllowed,
    #[error("internal error: {0}")]
    Internal(String),
}

#[derive(Serialize)]
struct ProblemDetails {
    #[serde(rename = "type")]
    kind: String,
    title: &'static str,
    status: u16,
    detail: String,
    #[serde(skip_serializing_if = "Option::is_none")]
    errors: Option<Vec<FieldError>>,
}

impl IntoResponse for ApiError {
    fn into_response(self) -> Response {
        let (status, kind, title) = match &self {
            ApiError::ProductNotFound(_) => (StatusCode::NOT_FOUND, "product-not-found", "Product not found"),
            ApiError::Validation(_) => (StatusCode::UNPROCESSABLE_ENTITY, "validation-failed", "Validation failed"),
            ApiError::DuplicateSku(_) => (StatusCode::CONFLICT, "duplicate-sku", "Duplicate SKU"),
            ApiError::BadJson(r) => (r.status(), "bad-request-body", "Invalid request body"),
            ApiError::BadPath(r) => (r.status(), "bad-path", "Invalid path parameter"),
            ApiError::BadQuery(r) => (r.status(), "bad-query", "Invalid query string"),
            ApiError::RouteNotFound => (StatusCode::NOT_FOUND, "route-not-found", "Route not found"),
            ApiError::MethodNotAllowed => (StatusCode::METHOD_NOT_ALLOWED, "method-not-allowed", "Method not allowed"),
            ApiError::Internal(_) => (StatusCode::INTERNAL_SERVER_ERROR, "internal-error", "Internal error"),
        };

        if status.is_server_error() {
            tracing::error!(error = ?self, "request failed");
        } else {
            tracing::debug!(error = ?self, "request rejected");
        }

        let detail = match &self {
            ApiError::Internal(_) => "the server could not complete the request".to_owned(),
            other => other.to_string(),
        };
        let errors = match &self {
            ApiError::Validation(list) => Some(list.clone()),
            _ => None,
        };

        let body = ProblemDetails {
            kind: format!("https://errors.storefront.example/{kind}"),
            title,
            status: status.as_u16(),
            detail,
            errors,
        };

        let mut response = (status, Json(body)).into_response();
        response
            .headers_mut()
            .insert(header::CONTENT_TYPE, HeaderValue::from_static("application/problem+json"));
        response
    }
}

src/extract.rs

Rust
use axum::extract::{FromRequest, FromRequestParts};

use crate::error::ApiError;

#[derive(FromRequest)]
#[from_request(via(axum::Json), rejection(ApiError))]
pub struct AppJson<T>(pub T);

#[derive(FromRequestParts)]
#[from_request(via(axum::extract::Path), rejection(ApiError))]
pub struct AppPath<T>(pub T);

#[derive(FromRequestParts)]
#[from_request(via(axum::extract::Query), rejection(ApiError))]
pub struct AppQuery<T>(pub T);

src/main.rs

Rust
mod error;
mod extract;

use std::{
    any::Any,
    collections::BTreeMap,
    sync::{Arc, Mutex},
};

use axum::{extract::State, http::StatusCode, response::IntoResponse, response::Response, routing::get, Json, Router};
use serde::{Deserialize, Serialize};
use tower_http::catch_panic::CatchPanicLayer;

use error::{ApiError, FieldError};
use extract::{AppJson, AppPath, AppQuery};

#[derive(Clone, Serialize)]
struct Product {
    id: u64,
    sku: String,
    name: String,
    price_cents: i64,
}

#[derive(Deserialize)]
struct CreateProduct {
    sku: String,
    name: String,
    price_cents: i64,
}

#[derive(Deserialize)]
struct ListParams {
    limit: Option<usize>,
}

#[derive(Default)]
struct Store {
    next_id: u64,
    items: BTreeMap<u64, Product>,
}

#[derive(Clone, Default)]
struct AppState {
    store: Arc<Mutex<Store>>,
}

fn validate(input: &CreateProduct) -> Result<(), ApiError> {
    let mut errors = Vec::new();
    if input.name.is_empty() || input.name.chars().count() > 100 {
        errors.push(FieldError { field: "name", message: "must be 1 to 100 characters".to_owned() });
    }
    if !(1..=100_000_000).contains(&input.price_cents) {
        errors.push(FieldError { field: "price_cents", message: "must be between 1 and 100000000".to_owned() });
    }
    if errors.is_empty() {
        Ok(())
    } else {
        Err(ApiError::Validation(errors))
    }
}

async fn create_product(
    State(state): State<AppState>,
    AppJson(input): AppJson<CreateProduct>,
) -> Result<(StatusCode, Json<Product>), ApiError> {
    validate(&input)?;
    let mut store = state.store.lock().unwrap();
    if store.items.values().any(|p| p.sku == input.sku) {
        return Err(ApiError::DuplicateSku(input.sku));
    }
    store.next_id += 1;
    let product = Product {
        id: store.next_id,
        sku: input.sku,
        name: input.name,
        price_cents: input.price_cents,
    };
    store.items.insert(product.id, product.clone());
    Ok((StatusCode::CREATED, Json(product)))
}

async fn get_product(State(state): State<AppState>, AppPath(id): AppPath<u64>) -> Result<Json<Product>, ApiError> {
    let store = state.store.lock().unwrap();
    store.items.get(&id).cloned().map(Json).ok_or(ApiError::ProductNotFound(id))
}

async fn list_products(State(state): State<AppState>, AppQuery(params): AppQuery<ListParams>) -> Json<Vec<Product>> {
    let store = state.store.lock().unwrap();
    let limit = params.limit.unwrap_or(10);
    Json(store.items.values().take(limit).cloned().collect())
}

async fn boom() -> StatusCode {
    panic!("simulated bug");
}

async fn route_not_found() -> ApiError {
    ApiError::RouteNotFound
}

async fn method_not_allowed() -> ApiError {
    ApiError::MethodNotAllowed
}

fn handle_panic(_payload: Box<dyn Any + Send + 'static>) -> Response {
    ApiError::Internal("handler panicked".to_owned()).into_response()
}

#[tokio::main]
async fn main() {
    tracing_subscriber::fmt::init();

    let app = Router::new()
        .route("/products", get(list_products).post(create_product))
        .route("/products/{id}", get(get_product))
        .route("/boom", get(boom))
        .fallback(route_not_found)
        .method_not_allowed_fallback(method_not_allowed)
        .layer(CatchPanicLayer::custom(handle_panic))
        .with_state(AppState::default());

    let listener = tokio::net::TcpListener::bind("127.0.0.1:3000").await.unwrap();
    axum::serve(listener, app).await.unwrap();
}

Run cargo run in the project directory. The -w option prints the status code after each body.

Terminal
curl -s -w '\n%{http_code}\n' -X POST localhost:3000/products \
  -H 'content-type: application/json' \
  -d '{"sku":"NB-1","name":"Notebook","price_cents":450}'
Output
{"id":1,"sku":"NB-1","name":"Notebook","price_cents":450}
201

Repeat the same request to hit the duplicate check.

Terminal
curl -s -w '\n%{http_code}\n' -X POST localhost:3000/products \
  -H 'content-type: application/json' \
  -d '{"sku":"NB-1","name":"Notebook","price_cents":450}'
Output
{"type":"https://errors.storefront.example/duplicate-sku","title":"Duplicate SKU","status":409,"detail":"a product with SKU NB-1 already exists"}
409

Send an invalid product.

Terminal
curl -s -w '\n%{http_code}\n' -X POST localhost:3000/products \
  -H 'content-type: application/json' \
  -d '{"sku":"NB-2","name":"","price_cents":0}'
Output
{"type":"https://errors.storefront.example/validation-failed","title":"Validation failed","status":422,"detail":"validation failed","errors":[{"field":"name","message":"must be 1 to 100 characters"},{"field":"price_cents","message":"must be between 1 and 100000000"}]}
422

Send a body that is not JSON, then a path that is not a number.

Terminal
curl -s -w '\n%{http_code}\n' -X POST localhost:3000/products -H 'content-type: application/json' -d '{"sku":'
curl -s -w '\n%{http_code}\n' localhost:3000/products/abc
Output
{"type":"https://errors.storefront.example/bad-request-body","title":"Invalid request body","status":400,"detail":"invalid JSON body"}
400
{"type":"https://errors.storefront.example/bad-path","title":"Invalid path parameter","status":400,"detail":"invalid path parameter"}
400

Request a missing product, an unknown route, an unsupported method, and the panicking route.

Terminal
curl -s -w '\n%{http_code}\n' localhost:3000/products/99
curl -s -w '\n%{http_code}\n' localhost:3000/nope
curl -s -w '\n%{http_code}\n' -X DELETE localhost:3000/products/1
curl -s -w '\n%{http_code}\n' localhost:3000/boom
Output
{"type":"https://errors.storefront.example/product-not-found","title":"Product not found","status":404,"detail":"product 99 not found"}
404
{"type":"https://errors.storefront.example/route-not-found","title":"Route not found","status":404,"detail":"no such route"}
404
{"type":"https://errors.storefront.example/method-not-allowed","title":"Method not allowed","status":405,"detail":"method not allowed"}
405
{"type":"https://errors.storefront.example/internal-error","title":"Internal error","status":500,"detail":"the server could not complete the request"}
500

Timeout and body-size layers also produce their own responses. Their default bodies do not use this format, and the middleware lesson covers them.

Summary

  • One ApiError enum represents every failure, and one IntoResponse implementation defines each response.
  • Server errors are logged in detail and answered with generic text.
  • Wrapper extractors convert rejections into ApiError, so bad requests use the same format.
  • Problem details give clients a standard error shape with errors for field-level validation.
  • Fallbacks and a panic-catching layer cover errors that never reach a handler.