| Crates.io | axum-anyhow |
| lib.rs | axum-anyhow |
| version | 0.10.5 |
| created_at | 2025-10-19 21:57:22.197559+00 |
| updated_at | 2026-01-20 01:35:27.96599+00 |
| description | Ergonomic error handling for Axum using anyhow |
| homepage | |
| repository | https://github.com/kosolabs/axum-anyhow |
| max_upload_size | |
| id | 1890970 |
| size | 163,214 |
A library for ergonomic error handling in Axum applications using anyhow.
This crate provides extension traits and utilities to easily convert Result and Option types into HTTP error responses with proper status codes, titles, and details.
anyhow::Result to an ApiError with custom HTTP status codes.Option to an ApiError when None is encountered.Add this to your Cargo.toml:
[dependencies]
anyhow = "1.0"
axum = "0.8"
axum-anyhow = "0.10"
serde = "1.0"
tokio = { version = "1.48", features = ["full"] }
use anyhow::Result;
use axum::{extract::Path, routing::get, Json, Router};
use axum_anyhow::{ApiResult, OptionExt, ResultExt};
use std::collections::HashMap;
#[tokio::main]
async fn main() {
let app = Router::new().route("/users/{id}", get(get_user_handler));
let listener = tokio::net::TcpListener::bind("0.0.0.0:3000").await.unwrap();
axum::serve(listener, app).await.unwrap();
}
#[derive(serde::Serialize, Clone)]
struct User {
id: u32,
name: String,
}
async fn get_user_handler(Path(id): Path<String>) -> ApiResult<Json<User>> {
// Convert parsing errors to 400 Bad Request
let id = parse_id(&id).context_bad_request("Invalid User ID", "User ID must be a u32")?;
// Convert unexpected errors to 500 Internal Server Error
let db = Database::connect()?;
// Convert Option::None to 404 Not Found
let user = db
.get_user(&id)
.context_not_found("User Not Found", "No user with that ID")?;
Ok(Json(user))
}
// Mock database
struct Database {
users: HashMap<u32, &'static str>,
}
impl Database {
fn connect() -> Result<Self> {
Ok(Database {
users: HashMap::from([(1, "Alice"), (2, "Bob"), (3, "Eve")]),
})
}
fn get_user(&self, id: &u32) -> Option<User> {
self.users.get(id).map(|name| User {
id: *id,
name: name.to_string(),
})
}
}
fn parse_id(id: &str) -> Result<u32> {
Ok(id.parse::<u32>()?)
}
Use the ResultExt trait to convert any anyhow::Result into an HTTP error response:
use axum_anyhow::{ApiResult, ResultExt};
use anyhow::Result;
async fn validate_email(email: String) -> ApiResult<String> {
// Validate and return 400 if invalid
check_email_format(&email)
.context_bad_request("Invalid Email", "Email must contain @")?;
Ok(email)
}
fn check_email_format(email: &str) -> Result<()> {
if email.contains('@') {
Ok(())
} else {
Err(anyhow::anyhow!("Invalid format"))
}
}
Use the OptionExt trait to convert Option into an HTTP error response:
use axum_anyhow::{ApiResult, OptionExt};
async fn find_user(id: u32) -> ApiResult<String> {
// Return 404 if user not found
let user = database_lookup(id)
.context_not_found("User Not Found", "No user with that ID exists")?;
Ok(user)
}
fn database_lookup(id: u32) -> Option<String> {
(id == 1).then(|| "Alice".to_string())
}
The library provides helper methods for common HTTP status codes:
| Method | Status Code | Use Case |
|---|---|---|
context_bad_request |
400 | Invalid client input |
context_unauthorized |
401 | Authentication required |
context_forbidden |
403 | Insufficient permissions |
context_not_found |
404 | Resource doesn't exist |
context_method_not_allowed |
405 | HTTP method not supported |
context_conflict |
409 | Resource conflict |
context_unprocessable_entity |
422 | Validation errors |
context_too_many_requests |
429 | Rate limit exceeded |
context_internal |
500 | Server errors |
context_bad_gateway |
502 | Invalid upstream response |
context_service_unavailable |
503 | Service temporarily unavailable |
context_gateway_timeout |
504 | Upstream timeout |
context_status |
Custom | Any custom status code |
You can also create errors directly without Results or Options:
use axum_anyhow::{
bad_request, unauthorized, forbidden, not_found, method_not_allowed,
conflict, unprocessable_entity, too_many_requests, internal_error,
bad_gateway, service_unavailable, gateway_timeout, ApiError
};
use axum::http::StatusCode;
// Using helper functions for common status codes
let error = bad_request("Invalid Input", "Name cannot be empty");
let error = unauthorized("Unauthorized", "Authentication token required");
let error = forbidden("Forbidden", "Insufficient permissions");
let error = not_found("Not Found", "Resource does not exist");
let error = method_not_allowed("Method Not Allowed", "POST not supported");
let error = conflict("Conflict", "Email already exists");
let error = unprocessable_entity("Validation Failed", "Password too short");
let error = too_many_requests("Rate Limited", "Try again in 60 seconds");
let error = internal_error("Internal Error", "Database connection failed");
let error = bad_gateway("Bad Gateway", "Upstream service error");
let error = service_unavailable("Service Unavailable", "Under maintenance");
let error = gateway_timeout("Gateway Timeout", "Upstream service timeout");
// Using the builder for custom status codes
let error = ApiError::builder()
.status(StatusCode::IM_A_TEAPOT)
.title("I'm a teapot")
.detail("This server is a teapot, not a coffee maker")
.build();
All errors are serialized as JSON with the following structure:
{
"status": 404,
"title": "Not Found",
"detail": "The requested user does not exist"
}
You can include custom metadata in error responses using the meta field. This is useful for adding request IDs, trace information, timestamps, or other contextual data:
use axum::http::StatusCode;
use axum_anyhow::ApiError;
use serde_json::json;
let error = ApiError::builder()
.status(StatusCode::NOT_FOUND)
.title("User Not Found")
.detail("No user with the given ID")
.meta(json!({
"request_id": "abc-123",
"timestamp": "2024-01-01T12:00:00Z",
"user_id": 42
}))
.build();
This produces a JSON response like:
{
"status": 404,
"title": "User Not Found",
"detail": "No user with the given ID",
"meta": {
"request_id": "abc-123",
"timestamp": "2024-01-01T12:00:00Z",
"user_id": 42
}
}
The meta field is omitted from the response if not set, keeping responses clean when metadata isn't needed.
Error responses can be enriched with metadata using the ErrorInterceptorLayer middleware:
use axum::{Router, routing::get};
use axum_anyhow::{ErrorInterceptorLayer, ApiResult};
use serde_json::json;
#[tokio::main]
async fn main() {
// Create an error interceptor layer that adds request context to the metadata
let middleware = ErrorInterceptorLayer::new(|builder, ctx| {
builder.meta(json!({
"method": ctx.method().as_str(),
"uri": ctx.uri().to_string(),
"user_agent": ctx.headers()
.get("user-agent")
.and_then(|v| v.to_str().ok())
.unwrap_or("unknown"),
"timestamp": chrono::Utc::now().to_rfc3339(),
}))
});
// Build the router with the error interceptor middleware
let app: Router = Router::new()
.route("/users/{id}", get(handler))
.layer(middleware);
let listener = tokio::net::TcpListener::bind("0.0.0.0:3000").await.unwrap();
axum::serve(listener, app).await.unwrap();
}
async fn handler() -> ApiResult<String> {
// Any error returned will automatically include the metadata
// from the middleware (method, uri, user_agent, timestamp)
Ok("Hello!".to_string())
}
With this setup, any error created in your handlers will automatically include request context in the meta field:
{
"status": 404,
"title": "User Not Found",
"detail": "No user with that ID",
"meta": {
"method": "GET",
"uri": "/users/123",
"user_agent": "Mozilla/5.0...",
"timestamp": "2024-01-01T12:00:00Z"
}
}
The error interceptor callback receives:
ApiErrorBuilder - you can add metadata or modify any fieldRequestSnapshot providing access to request information through getter methods:
method() - returns the HTTP methoduri() - returns the request URIheaders() - returns the request headersThis works seamlessly with all error types (Result, Option) and the ? operator.
See the examples/with-enricher.rs for a complete working example.
By default, when an anyhow::Error is automatically converted to an ApiError (via the From trait), the error detail is set to the generic message "Something went wrong". This protects against accidentally leaking sensitive information in production.
However, during development, it can be helpful to see the actual error messages. You can enable this in two ways:
use axum_anyhow::set_expose_errors;
// Enable for development
set_expose_errors(true);
// Disable for production
set_expose_errors(false);
This is especially useful in tests or when you want fine-grained control:
use axum_anyhow::set_expose_errors;
#[cfg(debug_assertions)]
set_expose_errors(true);
You can also set the AXUM_ANYHOW_EXPOSE_ERRORS environment variable:
AXUM_ANYHOW_EXPOSE_ERRORS=1 cargo run
# or
AXUM_ANYHOW_EXPOSE_ERRORS=true cargo run
With this enabled:
use anyhow::anyhow;
use axum_anyhow::ApiError;
// Without expose_errors: detail = "Something went wrong"
// With expose_errors: detail = "Database connection failed"
let error: ApiError = anyhow!("Database connection failed").into();
[!WARNING] Error messages may contain sensitive information like file paths, database details, or internal system information that should not be exposed to end users in production.
You can set a global hook that will be called whenever an ApiError is created. This is useful for logging, monitoring, or debugging errors in your application.
use axum_anyhow::on_error;
// Set up error logging
on_error(|err| {
tracing::error!("API Error: {} ({}): {}", err.status(), err.title(), err.detail());
});
The hook receives a reference to the ApiError and will be called automatically whenever an error is built, whether through the builder pattern, helper functions, or automatic conversions:
use axum_anyhow::{on_error, bad_request, ApiError, IntoApiError, ResultExt};
use anyhow::anyhow;
use axum::http::StatusCode;
// Set up the hook once at application startup
on_error(|err| {
eprintln!("Error occurred: {}", err.detail());
});
// The hook will be called for all of these:
let error1: ApiError = bad_request("Invalid Input", "Name is required");
let result: ApiError = anyhow!("Database error")
.context_internal("Internal Error", "Failed to connect");
let error2 = ApiError::builder()
.status(StatusCode::NOT_FOUND)
.title("Not Found")
.detail("Resource missing")
.build();
Common use cases for error hooks:
tracing, log, slog)[!TIP] The error hook is global and thread-safe. You can call
on_errormultiple times to replace the hook, but only one hook can be active at a time.
Without axum-anyhow, the code in our quick start example would look like this:
use anyhow::Result;
use axum::extract::Path;
use axum::http::StatusCode;
use axum::{routing::get, Json, Router};
use std::collections::HashMap;
#[tokio::main]
async fn main() {
let app = Router::new().route("/users/{id}", get(get_user_handler));
let listener = tokio::net::TcpListener::bind("0.0.0.0:3000").await.unwrap();
axum::serve(listener, app).await.unwrap();
}
#[derive(serde::Serialize, Clone)]
struct User {
id: u32,
name: String,
}
#[derive(serde::Serialize)]
struct ErrorResponse {
status: u16,
title: String,
detail: String,
}
async fn get_user_handler(
Path(id): Path<String>,
) -> Result<Json<User>, (StatusCode, Json<ErrorResponse>)> {
// Convert parsing errors to 400 Bad Request
let id = match parse_id(&id) {
Ok(id) => id,
Err(_) => {
return Err((
StatusCode::BAD_REQUEST,
Json(ErrorResponse {
status: 400,
title: "Invalid User ID".to_string(),
detail: "User ID must be a u32".to_string(),
}),
));
}
};
// Convert unexpected errors to 500 Internal Server Error
let db = match Database::connect() {
Ok(db) => db,
Err(_) => {
return Err((
StatusCode::INTERNAL_SERVER_ERROR,
Json(ErrorResponse {
status: 500,
title: "Internal Error".to_string(),
detail: "Something went wrong".to_string(),
}),
));
}
};
// Convert Option::None to 404 Not Found
let user = match db.get_user(&id) {
Some(u) => u,
None => {
return Err((
StatusCode::NOT_FOUND,
Json(ErrorResponse {
status: 404,
title: "User Not Found".to_string(),
detail: "No user with that ID".to_string(),
}),
));
}
};
Ok(Json(user))
}
// Mock database
struct Database {
users: HashMap<u32, &'static str>,
}
impl Database {
fn connect() -> Result<Self> {
Ok(Database {
users: HashMap::from([(1, "Alice"), (2, "Bob"), (3, "Eve")]),
})
}
fn get_user(&self, id: &u32) -> Option<User> {
self.users.get(id).map(|name| User {
id: *id,
name: name.to_string(),
})
}
}
fn parse_id(id: &str) -> Result<u32> {
Ok(id.parse::<u32>()?)
}
Axum encourages you to create your own error types and conversion logic to reduce this boilerplate. axum-anyhow does this for you, providing extension traits and helper functions to convert standard Rust types (Result and Option) into properly formatted HTTP error responses.
axum-anyhow is designed for REST APIs and returns errors formatted according to RFC 9457. If you need more flexibility, please file an issue or copy the code into your project and modify it as needed.
Contributions are welcome! Please feel free to submit a Pull Request.
This project is licensed under the MIT License - see the LICENSE file for details.