axum-anyhow

Crates.ioaxum-anyhow
lib.rsaxum-anyhow
version0.10.5
created_at2025-10-19 21:57:22.197559+00
updated_at2026-01-20 01:35:27.96599+00
descriptionErgonomic error handling for Axum using anyhow
homepage
repositoryhttps://github.com/kosolabs/axum-anyhow
max_upload_size
id1890970
size163,214
Shad Sharma (shadanan)

documentation

README

axum-anyhow

Crates.io Documentation License: MIT

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.

Features

  • Convert anyhow::Result to an ApiError with custom HTTP status codes.
  • Convert Option to an ApiError when None is encountered.
  • Returns JSON responses in RFC 9457 format.
  • Optional environment variable to expose error details in development mode.

Installation

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"] }

Quick Start

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>()?)
}

Usage Examples

Working with Results

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"))
    }
}

Working with Options

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())
}

Available Status Codes

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

Creating Errors Directly

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();

Error Response Format

All errors are serialized as JSON with the following structure:

{
  "status": 404,
  "title": "Not Found",
  "detail": "The requested user does not exist"
}

Adding Metadata to Errors

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 Enrichment

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:

  • A mutable reference to the ApiErrorBuilder - you can add metadata or modify any field
  • A RequestSnapshot providing access to request information through getter methods:
    • method() - returns the HTTP method
    • uri() - returns the request URI
    • headers() - returns the request headers

This works seamlessly with all error types (Result, Option) and the ? operator.

See the examples/with-enricher.rs for a complete working example.

Development Features

Exposing Error Details

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:

Programmatically (Recommended)

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);

Via Environment Variable

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.

Error Hook

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:

  • Logging: Send errors to your logging system (e.g., tracing, log, slog)
  • Monitoring: Report errors to monitoring services (e.g., Sentry, Datadog, New Relic)
  • Metrics: Increment error counters for observability
  • Debugging: Print detailed error information during development

[!TIP] The error hook is global and thread-safe. You can call on_error multiple times to replace the hook, but only one hook can be active at a time.

Motivation

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.

Contributing

Contributions are welcome! Please feel free to submit a Pull Request.

License

This project is licensed under the MIT License - see the LICENSE file for details.

Repository

https://github.com/kosolabs/axum-anyhow

Commit count: 106

cargo fmt