Error Handling in Handlers
Map domain failures to consistent HTTP responses with IntoResponse, thiserror, and ? in Axum handlers.
Busca en todas las páginas de la documentación
Map domain failures to consistent HTTP responses with IntoResponse, thiserror, and ? in Axum handlers.
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.
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.thiserror for readable variants and #[from] for sqlx errors.code field separate from human message.IntoResponse for Result<T, E> when both T and E implement IntoResponse.? operator propagates errors from helper functions returning Result<_, ApiError>.IntoResponse, not in every handler.| Layer | Responsibility |
|---|---|
| Domain | Return Result with typed errors |
| Handler | Use ? to propagate |
IntoResponse | Map to status + JSON |
| Middleware | Catch panics, add request IDs |
// 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())
}
}sqlx::Error text to clients exposes schema details. Fix: Log full error; return generic message.ErrorBody struct for all responses.unwrap() in handlers becomes 500 with connection drop. Fix: Replace with ? and typed errors.IntoResponse for rejections - Custom extractors need rejection types. Fix: Implement IntoResponse on rejection enums.| 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 |
Prefer Result with ? - less boilerplate and consistent error path.
Log in IntoResponse or a dedicated middleware with the request ID.
Consolidate into one ApiError with variants or use Box<dyn Error> only at boundaries.
404 when resource ID does not exist; 400 when ID format is invalid.
Map validator or garde errors to 422 with field paths.
tracing::error!(?err) in IntoResponse for 5xx variants.
oneshot request and assert status + JSON code field.
Catch-all variant Internal mapping to 500.
Use 401 for missing auth, 403 for insufficient scope.
SSE and WebSocket need separate error signaling on the stream.
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+.
Revisado por Chris St. John·Última actualización: 16 jul 2026