Skip to main content

Crate axum_anyhow

Crate axum_anyhow 

Source
Expand description

§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:

MethodStatus CodeUse Case
context_bad_request400Invalid client input
context_unauthorized401Authentication required
context_forbidden403Insufficient permissions
context_not_found404Resource doesn’t exist
context_method_not_allowed405HTTP method not supported
context_conflict409Resource conflict
context_unprocessable_entity422Validation errors
context_too_many_requests429Rate limit exceeded
context_internal500Server errors
context_bad_gateway502Invalid upstream response
context_service_unavailable503Service temporarily unavailable
context_gateway_timeout504Upstream timeout
context_statusCustomAny 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:

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

Structs§

ApiError
An API error that can be converted into an HTTP response.
ApiErrorBuilder
A builder for constructing ApiError instances.
ErrorInterceptorLayer
Middleware layer that enables error enrichment with request context.

Traits§

IntoApiError
Extension trait for converting any error type into ApiError with HTTP status codes.
OptionExt
Extension trait for Option<T> to convert None into ApiError with HTTP status codes.
ResultExt
Extension trait for anyhow::Result to convert errors into ApiError with HTTP status codes.

Functions§

bad_gateway
Creates a 502 Bad Gateway error.
bad_request
Creates a 400 Bad Request error.
conflict
Creates a 409 Conflict error.
forbidden
Creates a 403 Forbidden error (authenticated but lacks permissions).
gateway_timeout
Creates a 504 Gateway Timeout error.
internal_error
Creates a 500 Internal Server Error.
is_expose_errors_enabled
Returns whether error details are currently being exposed.
method_not_allowed
Creates a 405 Method Not Allowed error.
not_found
Creates a 404 Not Found error.
on_error
Sets a global hook that will be called whenever an ApiError is created.
service_unavailable
Creates a 503 Service Unavailable error.
set_expose_errors
Sets whether error details should be exposed in API responses.
too_many_requests
Creates a 429 Too Many Requests error.
unauthorized
Creates a 401 Unauthorized error (missing or invalid credentials).
unprocessable_entity
Creates a 422 Unprocessable Entity error.

Type Aliases§

ApiResult
A type alias for Result<T, ApiError>.