Error Handling in Axum

Published: (May 2, 2026 at 01:06 PM EDT)
7 min read
Source: Dev.to

Source: Dev.to

Axum is a web framework for Rust built on top of Tokio and Hyper.
Its error‑handling model is simple and predictable: every error must eventually become an HTTP response. The key trait that makes this possible is IntoResponse. Anything that implements IntoResponse can be returned from a handler and Axum will turn it into an HTTP response.

1. Returning a StatusCode directly

The easiest way to signal an error is to return a StatusCode from a handler.

use axum::{
    extract::Path,
    http::StatusCode,
    routing::get,
    Router,
};

async fn get_user(Path(id): Path) -> Result {
    if id == 0 {
        return Err(StatusCode::NOT_FOUND);
    }
    Ok(format!("User #{}", id))
}

#[tokio::main]
async fn main() {
    // ✅ Use `{id}` in the route pattern
    let app = Router::new().route("/users/{id}", get(get_user));
    let listener = tokio::net::TcpListener::bind("0.0.0.0:3000")
        .await
        .unwrap();
    axum::serve(listener, app).await.unwrap();
}
  • When the handler returns Ok, Axum sends 200 OK.
  • When it returns Err(StatusCode), Axum sends the supplied status code.

A bare StatusCode doesn’t explain why something failed, so in most cases you’ll want to return more information.

2. Returning a (StatusCode, String) tuple

You can return a tuple that contains a status code and a plain‑text message. The tuple automatically implements IntoResponse.

use axum::{
    extract::Path,
    http::StatusCode,
    routing::post,
    Json,
    Router,
};
use serde::Deserialize;

#[derive(Deserialize)]
struct CreateUser {
    username: String,
}

async fn create_user(
    Json(payload): Json,
) -> Result {
    if payload.username.is_empty() {
        return Err((
            StatusCode::BAD_REQUEST,
            "Username cannot be empty".to_string(),
        ));
    }

    Ok(format!("Created user: {}", payload.username))
}

3. A single error enum for the whole application

For real‑world projects you usually define one error type that covers all possible failure modes and implement IntoResponse for it.

use axum::{
    extract::Path,
    http::StatusCode,
    response::{IntoResponse, Response},
    routing::get,
    Router,
};

/// Application‑wide error type
enum AppError {
    NotFound(String),
    Unauthorized,
    InternalError(String),
}

/// Convert `AppError` into an HTTP response
impl IntoResponse for AppError {
    fn into_response(self) -> Response {
        let (status, message) = match self {
            AppError::NotFound(msg) => (StatusCode::NOT_FOUND, msg),
            AppError::Unauthorized => (
                StatusCode::UNAUTHORIZED,
                "You are not authorized".to_string(),
            ),
            AppError::InternalError(msg) => (StatusCode::INTERNAL_SERVER_ERROR, msg),
        };

        (status, message).into_response()
    }
}

// Example handler using the custom error type
async fn get_document(Path(id): Path) -> Result {
    if id == 99 {
        return Err(AppError::Unauthorized);
    }
    if id > 100 {
        return Err(AppError::NotFound(format!("Document {} not found", id)));
    }
    Ok(format!("Document #{}", id))
}

Pattern: one error type, one place to define how it becomes a response.

4. JSON error bodies

APIs usually return JSON error payloads instead of plain text. Below is a common way to do that.

use axum::{
    http::StatusCode,
    response::{IntoResponse, Response},
    Json,
};
use serde::Serialize;

#[derive(Serialize)]
struct ErrorBody {
    error: String,
    code: u16,
}

enum ApiError {
    NotFound(String),
    BadRequest(String),
    Internal,
}

impl IntoResponse for ApiError {
    fn into_response(self) -> Response {
        let (status, message) = match self {
            ApiError::NotFound(msg) => (StatusCode::NOT_FOUND, msg),
            ApiError::BadRequest(msg) => (StatusCode::BAD_REQUEST, msg),
            ApiError::Internal => (
                StatusCode::INTERNAL_SERVER_ERROR,
                "Something went wrong".to_string(),
            ),
        };

        let body = Json(ErrorBody {
            error: message,
            code: status.as_u16(),
        });

        (status, body).into_response()
    }
}

Resulting response example

{
  "error": "Document 999 not found",
  "code": 404
}

5. Propagating errors with ? and From

In real code you’ll often want to use the ? operator to bubble up errors from lower‑level libraries. Implement From for your AppError so the conversion happens automatically.

use std::num::ParseIntError;

enum AppError {
    BadRequest(String),
    Internal(String),
}

impl IntoResponse for AppError {
    fn into_response(self) -> Response {
        // Same implementation as before (omitted for brevity)
        unimplemented!()
    }
}

// Convert `ParseIntError` into `AppError`
impl From<ParseIntError> for AppError {
    fn from(err: ParseIntError) -> Self {
        AppError::BadRequest(format!("Invalid number: {}", err))
    }
}

async fn parse_id(Path(raw): Path) -> Result {
    // `?` calls the `From` impl on failure
    let id: u32 = raw.parse()?;
    Ok(format!("Parsed id: {}", id))
}

The ? after raw.parse() automatically transforms a ParseIntError into AppError::BadRequest, so you don’t need any manual conversion.

6. Using anyhow for quick prototyping

When you don’t need fine‑grained error types, the anyhow crate provides a convenient catch‑all error type.

use anyhow::Context;
use axum::{
    http::StatusCode,
    response::{IntoResponse, Response},
};

/// Wrapper that implements `IntoResponse` for `anyhow::Error`
struct AnyhowError(anyhow::Error);

impl IntoResponse for AnyhowError {
    fn into_response(self) -> Response {
        (
            StatusCode::INTERNAL_SERVER_ERROR,
            format!("Internal error: {}", self.0),
        )
            .into_response()
    }
}

// Allow any error that can be turned into `anyhow::Error` to be converted
impl<E> From<E> for AnyhowError {
    fn from(err: E) -> Self {
        AnyhowError(err.into())
    }
}

async fn risky_handler() -> Result {
    let data = std::fs::read_to_string("config.txt")
        .context("Failed to read config file")?;
    Ok(data)
}

Now you can use ? with any Result where E implements Into.

7. Converting Tower service errors with HandleError

If you use a Tower service or middleware that returns its own error type, Axum’s HandleError layer lets you map those errors into responses.

use axum::{
    error_handling::HandleError,
    http::StatusCode,
    response::IntoResponse,
    routing::get,
    Router,
};
use std::convert::Infallible;
use std::time::Duration;
use tower::timeout::TimeoutLayer;

// Example service that may time out
async fn timeout_handler() -> Result {
    Ok("All good".to_string())
}

#[tokio::main]
async fn main() {
    let app = Router::new()
        .route(
            "/",
            get(
                timeout_handler
                    .layer(TimeoutLayer::new(Duration::from_secs(1)))
                    .handle_error(|err| async move {
                        // Convert the timeout error into a response
                        (
                            StatusCode::REQUEST_TIMEOUT,
                            format!("Request timed out: {}", err),
                        )
                            .into_response()
                    }),
            ),
        );

    let listener = tokio::net::TcpListener::bind("0.0.0.0:3000")
        .await
        .unwrap();
    axum::serve(listener, app).await.unwrap();
}

HandleError lets you define a custom conversion from the service’s error type to an IntoResponse implementation, keeping the rest of your code clean.

Summary

  • IntoResponse is the bridge between Rust errors and HTTP responses.
  • Start simple with StatusCode or (StatusCode, String).
  • For larger apps, define a single error enum and implement IntoResponse once.
  • Return JSON bodies for API consumers.
  • Use From implementations to make the ? operator propagate errors automatically.
  • anyhow is handy for prototypes where you don’t need a detailed error hierarchy.
  • HandleError helps you convert errors coming from Tower services or middleware.

With these patterns you can build robust, ergonomic error handling in any Axum application.

Tower & Axum Error‑Handling Example

use axum::{
    http::StatusCode,
    response::IntoResponse,
    routing::get,
    Router,
};
use std::convert::Infallible;

// A function that may fail
async fn might_fail() -> Result {
    Err("Something broke".to_string())
}

// Wrap the service to handle its errors
let service = tower::service_fn(|_req| async {
    Ok::("ok")
});

let handled = HandleError::new(service, |err: String| async move {
    (StatusCode::INTERNAL_SERVER_ERROR, err)
});

This is more advanced and mainly relevant when integrating third‑party Tower components.

Error‑Handling Patterns

PatternBest For
ResultSimple, code‑only errors
Result with (StatusCode, String)Errors with a message
Custom enum + impl IntoResponseProduction apps with typed errors
impl IntoResponse returning JsonAPIs that return JSON error bodies
impl From for AppErrorUsing ? with library errors
anyhow wrapperQuick prototypes or scripts
  1. Define a central error type

    #[derive(Debug)]
    enum AppError {
        NotFound,
        InvalidInput(String),
        Internal(String),
        // …other variants
    }
  2. Implement IntoResponse for it (returning JSON is usually best)

    impl IntoResponse for AppError {
        fn into_response(self) -> axum::response::Response {
            let (status, body) = match self {
                AppError::NotFound => (
                    StatusCode::NOT_FOUND,
                    json!({ "error": "resource not found" })
                ),
                AppError::InvalidInput(msg) => (
                    StatusCode::BAD_REQUEST,
                    json!({ "error": msg })
                ),
                AppError::Internal(msg) => (
                    StatusCode::INTERNAL_SERVER_ERROR,
                    json!({ "error": msg })
                ),
            };
            (status, Json(body)).into_response()
        }
    }
  3. Convert third‑party errors

    impl From<LibError> for AppError {
        fn from(err: LibError) -> Self {
            // map `LibError` variants to `AppError` variants
            AppError::Internal(err.to_string())
        }
    }
  4. Use Result in handlers

    async fn handler() -> Result {
        let data = some_lib_call().await?; // `?` works because of `From`
        Ok(Json(data))
    }

That’s it. No hidden panics, just explicit, typed error paths.

0 views
Back to Blog

Related posts

Read more »

Rust is participating in Outreachy

Introduction The Rust Project has been building a strong history of participating in open‑source mentorship programs, including Google Summer of Codehttps://bl...