DEV Community

Syeed Talha
Syeed Talha

Posted on

Error Handling in Axum

Axum is a web framework for Rust built on top of Tokio and Hyper. One of its
standout features is a simple and predictable error handling model — once
you understand the pattern, writing safe, expressive web servers becomes
surprisingly natural.

This guide walks you from the very basics to real-world patterns you'll use in
production.


The Core Idea: Errors Must Become Responses

In Axum, every error must eventually become an HTTP response. There is no
unhandled-exception mechanism. If something goes wrong, you decide what the
client sees — a 404, a 500, a JSON error body, or anything else.

The key trait is IntoResponse. Anything that implements it can be returned
from a handler, including error types.


1. The Simplest Error: Return a StatusCode

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

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

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

#[tokio::main]
async fn main() {
    let app = Router::new().route("/users/{id}", get(get_user)); // ✅ use {id}
    let listener = tokio::net::TcpListener::bind("0.0.0.0:3000").await.unwrap();
    axum::serve(listener, app).await.unwrap();
}
Enter fullscreen mode Exit fullscreen mode

Here the handler returns Result<String, StatusCode>. When Ok, Axum sends
the string as a 200 OK. When Err, it sends the status code you chose. Clean
and simple!


2. Returning Richer Errors with Tuples

A bare StatusCode doesn't tell the client why something went wrong. You can
pair a status code with a message using a tuple.

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

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

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

    Ok(format!("Created user: {}", payload.username))
}
Enter fullscreen mode Exit fullscreen mode

The tuple (StatusCode, String) is automatically turned into a response with
the given status and a text body. Axum knows how to convert it thanks to the
IntoResponse trait.


3. Building a Custom Error Type (The Right Way)

For real applications, you want a single error enum that covers all the things
that can go wrong. Then you implement IntoResponse for it so Axum can turn
your errors into HTTP responses automatically.

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

// Your application's error type
enum AppError {
    NotFound(String),
    Unauthorized,
    InternalError(String),
}

// Teach Axum how to turn your error into a 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()
    }
}

// Now use it in your handlers
async fn get_document(Path(id): Path<u32>) -> Result<String, AppError> {
    if id == 99 {
        return Err(AppError::Unauthorized);
    }
    if id > 100 {
        return Err(AppError::NotFound(format!("Document {} not found", id)));
    }
    Ok(format!("Document #{}", id))
}
Enter fullscreen mode Exit fullscreen mode

This is the pattern you'll use most often. One error type, one place to define
what each error looks like to clients.


4. Returning JSON Errors

APIs usually return JSON error bodies instead of plain text. Here's how to
return structured JSON from your error type.

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()
    }
}
Enter fullscreen mode Exit fullscreen mode

Now your clients get responses like:

{
  "error": "Document 999 not found",
  "code": 404
}
Enter fullscreen mode Exit fullscreen mode

5. Converting ? Errors with From

In real code, you'll use ? to propagate errors from libraries like database
drivers or HTTP clients. You can implement From<SomeError> for AppError to
make this seamless.

use std::num::ParseIntError;

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

impl IntoResponse for AppError {
    fn into_response(self) -> Response {
        // ... (same as above)
    }
}

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

async fn parse_id(Path(raw): Path<String>) -> Result<String, AppError> {
    // The `?` operator automatically calls From<ParseIntError> here
    let id: u32 = raw.parse()?;
    Ok(format!("Parsed id: {}", id))
}
Enter fullscreen mode Exit fullscreen mode

The ? after raw.parse() will, on failure, call your From impl and return
an Err(AppError::BadRequest(...)) — no manual conversion needed.


6. Using anyhow for Quick Prototyping

The anyhow crate is popular for applications where you don't need fine-grained
error types yet. You can bridge it into Axum with a small wrapper.

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

// A wrapper so we can impl 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 using `?` on anyhow::Result in handlers
impl<E: Into<anyhow::Error>> From<E> for AnyhowError {
    fn from(err: E) -> Self {
        AnyhowError(err.into())
    }
}

async fn risky_handler() -> Result<String, AnyhowError> {
    let data = std::fs::read_to_string("config.txt")
        .context("Failed to read config file")?;
    Ok(data)
}
Enter fullscreen mode Exit fullscreen mode

7. Handler-Level Error Handling with HandleError

Sometimes you use a Tower Service or middleware that returns errors Axum
doesn't know how to handle. The HandleError layer lets you convert those.

use axum::{
    error_handling::HandleError,
    http::StatusCode,
    routing::get,
    Router,
};
use std::convert::Infallible;

async fn might_fail() -> Result<String, String> {
    Err("Something broke".to_string())
}

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

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

This is more advanced and mainly relevant when integrating third-party Tower
services.


Quick Reference

Pattern Best For
Result<T, StatusCode> Simple, code-only errors
Result<T, (StatusCode, String)> Errors with a message
Custom enum + impl IntoResponse Production apps with typed errors
impl IntoResponse returning Json APIs that return JSON error bodies
impl From<LibError> for AppError Using ? with library errors
anyhow wrapper Quick prototypes or scripts

Summary

Axum's error handling boils down to one rule: your errors must implement
IntoResponse
. Once they do, you can use Result<T, YourError> in any
handler and Axum handles the rest.

The recommended path for most apps:

  1. Define an enum AppError with variants for each failure mode.
  2. Implement IntoResponse for it (returning JSON is usually best).
  3. Implement From<LibError> for AppError for each third-party error type you use with ?.
  4. Return Result<impl IntoResponse, AppError> from your handlers.

That's it. No magic, no hidden panics — just clean, explicit error paths.

Top comments (0)