Error Handling in Handlers
Map domain failures to consistent HTTP responses with IntoResponse, thiserror, and ? in Axum handlers.
Recipe
Quick-reference recipe card - copy-paste ready.
use axum::{http::StatusCode, response::IntoResponse, Json};
use thiserror::Error;
#[derive(Error, Debug)]
enum AppError {
#[error("not found")] NotFound,
#[error("{0}")] BadRequest(String),
}
impl IntoResponse for AppError {
fn into_response(self) -> axum::response::Response {
let (status, msg) = match self {
AppError::NotFound => (StatusCode::NOT_FOUND, self.to_string()),
AppError::BadRequest(m) => (StatusCode::BAD_REQUEST, m),
};
(status, Json(serde_json::json!({ "error": msg }))).into_response()
}
}
async fn handler() -> Result<Json<Data>, AppError> { /* ... */ }When to reach for this: Every production API needs one error type (or enum) that converts to stable JSON and status codes.
Working Example
use axum::{http::StatusCode, response::IntoResponse, routing::get, Json, Router};
use serde::Serialize;
use thiserror::Error;
#[derive(Serialize)]
struct ErrorBody { code: &'static str, message: String }
#[derive(Error, Debug)]
enum ApiError {
#[error("item not found")] NotFound,
#[error("invalid id: {0}")] InvalidId(String),
#[error(transparent)] Db(#[from] sqlx::Error),
}
impl IntoResponse for ApiError {
fn into_response(self) -> axum::response::Response {
let (status, code, message) = match &self {
ApiError::NotFound => (StatusCode::NOT_FOUND, "not_found", self.to_string()),
ApiError::InvalidId(_) => (StatusCode::BAD_REQUEST, "invalid_id", self.to_string()),
ApiError::Db(_) => (StatusCode::INTERNAL_SERVER_ERROR, "db_error", "internal error".into()),
};
(status, Json(ErrorBody { code, message })).into_response()
}
}
async fn get_item(axum::extract::Path(id): axum::extract::Path<String>) -> Result<Json<serde_json::Value>, ApiError> {
if id.is_empty() {
return Err(ApiError::InvalidId(id));
}
if id == "missing" {
return Err(ApiError::NotFound);
}
Ok(Json(serde_json::json!({ "id": id })))
}
#[tokio::main]
async fn main() {
let app = Router::new().route("/items/:id", get(get_item));
let listener = tokio::net::TcpListener::bind("127.0.0.1:3000").await.unwrap();
axum::serve(listener, app).await.unwrap();
}What this demonstrates:
Result<T, ApiError>handlers with automatic error conversion.thiserrorfor readable variants and#[from]for sqlx errors.- Stable
codefield separate from humanmessage. - Internal errors hide database details from clients.
Deep Dive
How It Works
- Axum implements
IntoResponseforResult<T, E>when bothTandEimplementIntoResponse. - The
?operator propagates errors from helper functions returningResult<_, ApiError>. - Map errors once in
IntoResponse, not in every handler.
Error Strategy
| Layer | Responsibility |
|---|---|
| Domain | Return Result with typed errors |
| Handler | Use ? to propagate |
IntoResponse | Map to status + JSON |
| Middleware | Catch panics, add request IDs |
Rust Notes
// Axum 0.8: map error types with From
impl From<validator::ValidationErrors> for ApiError {
fn from(e: validator::ValidationErrors) -> Self {
ApiError::BadRequest(e.to_string())
}
}Gotchas
- Leaking internals - Returning
sqlx::Errortext to clients exposes schema details. Fix: Log full error; return generic message. - Inconsistent JSON shapes - Some errors return plain text. Fix: One
ErrorBodystruct for all responses. - Wrong status for validation - Using 400 for serde failures when 422 is conventional. Fix: Match team API standard; document in OpenAPI.
- Panic on unwrap -
unwrap()in handlers becomes 500 with connection drop. Fix: Replace with?and typed errors. - Missing
IntoResponsefor rejections - Custom extractors need rejection types. Fix: ImplementIntoResponseon rejection enums.
Alternatives
| Alternative | Use When | Don't Use When |
|---|---|---|
anyhow in binaries | Internal CLIs and scripts | Public HTTP APIs needing stable codes |
Problem Details (RFC 7807) | Standards-compliant APIs | Simple internal tools |
axum::response::Response always | Maximum flexibility | You want typed success responses |
FAQs
Result vs manual match?
Prefer Result with ? - less boilerplate and consistent error path.
Log errors where?
Log in IntoResponse or a dedicated middleware with the request ID.
Multiple error enums?
Consolidate into one ApiError with variants or use Box<dyn Error> only at boundaries.
404 vs 400?
404 when resource ID does not exist; 400 when ID format is invalid.
Validation errors?
Map validator or garde errors to 422 with field paths.
Tracing integration?
tracing::error!(?err) in IntoResponse for 5xx variants.
Test error responses?
oneshot request and assert status + JSON code field.
Fallback for unknown errors?
Catch-all variant Internal mapping to 500.
OAuth errors?
Use 401 for missing auth, 403 for insufficient scope.
Stream errors?
SSE and WebSocket need separate error signaling on the stream.
Related
- Axum Routing & Handlers - Handler signatures
- Request Validation - Validation failures
- Extractors & State - Extractor rejections
- Web Backends Best Practices - Error strategy rules
- Error Handling Basics - Rust error fundamentals
Stack versions: This page was written for Rust 1.97.0 (edition 2024), Tokio 1.x, Axum 0.8, serde 1.0, sqlx 0.8, clap 4, and Polars 0.46+.