Skip to content
rusthacks.dev
Web

Axum Error Handling With IntoResponse and Custom Errors

Make every Axum handler return Result with one AppError enum that implements IntoResponse, so the question mark works and clients get consistent JSON errors.

7 min read
Summarize in AI
Flat illustration for the post “Axum Error Handling With IntoResponse and Custom Errors”, composed around ownership.

Out of the box, an Axum service answers failure in three different voices. A handler that returns StatusCode::NOT_FOUND sends an empty body. An extractor that cannot parse its input sends a plain-text sentence about what it could not parse. A handler that panics sends nothing useful at all. A client that has to handle all three will eventually mishandle one. The fix is a single error type that every handler returns and every failure passes through, producing one JSON shape. This post builds it in about fifty lines, shows each response, converts library errors with ?, takes control of the extractor rejections, and covers what must stay out of an error body. All code ran against axum 0.8.9 and serde_json 1 on Rust 1.99.

One error enum that is a response

Axum's rule for handler return values is that the type must implement IntoResponse, and Result<T, E> implements it when both T and E do. So your error type needs the one trait and nothing else:

src/main.rsrust
use axum::{
    Json, Router,
    extract::Path,
    http::StatusCode,
    response::{IntoResponse, Response},
    routing::get,
};
use serde_json::json;

#[derive(Debug)]
enum AppError {
    NotFound(String),
    BadInput(String),
    Internal(Box<dyn std::error::Error + Send + Sync>),
}

impl IntoResponse for AppError {
    fn into_response(self) -> Response {
        let (status, code, message) = match self {
            AppError::NotFound(what) => {
                (StatusCode::NOT_FOUND, "not_found", format!("{what} was not found"))
            }
            AppError::BadInput(why) => (StatusCode::UNPROCESSABLE_ENTITY, "bad_input", why),
            AppError::Internal(err) => {
                eprintln!("internal error: {err}");
                (StatusCode::INTERNAL_SERVER_ERROR, "internal", "something went wrong".to_string())
            }
        };
        (status, Json(json!({ "error": { "code": code, "message": message } }))).into_response()
    }
}

impl From<std::num::ParseIntError> for AppError {
    fn from(e: std::num::ParseIntError) -> Self {
        AppError::BadInput(e.to_string())
    }
}

impl From<std::io::Error> for AppError {
    fn from(e: std::io::Error) -> Self {
        AppError::Internal(Box::new(e))
    }
}

async fn parse(Path(raw): Path<String>) -> Result<String, AppError> {
    let n: i64 = raw.parse()?;
    if n == 0 {
        return Err(AppError::NotFound("item 0".into()));
    }
    Ok(format!("double is {}", n * 2))
}

async fn read_config() -> Result<String, AppError> {
    let text = std::fs::read_to_string("/no/such/file")?;
    Ok(text)
}
Shell
curl -i localhost:3000/n/21
curl -i localhost:3000/n/abc
curl -i localhost:3000/n/0
curl -i localhost:3000/config
Text
HTTP/1.1 200 OK
double is 42

HTTP/1.1 422 Unprocessable Entity
content-type: application/json
{"error":{"code":"bad_input","message":"invalid digit found in string"}}

HTTP/1.1 404 Not Found
content-type: application/json
{"error":{"code":"not_found","message":"item 0 was not found"}}

HTTP/1.1 500 Internal Server Error
content-type: application/json
{"error":{"code":"internal","message":"something went wrong"}}

Notice what happens in the handlers. raw.parse()? produces a ParseIntError; the ? operator calls From::from to convert it into AppError, and the From impl chooses the variant. The same applies to read_to_string(...)? and io::Error. Handlers read as straight-line code, and the mapping from library error to HTTP status lives in exactly one place. The server's own log received the real cause (internal error: No such file or directory (os error 2)) while the client saw only the generic message.

Keep the cause out of the body

That asymmetry is deliberate and is the security-relevant part of the design. An internal error's text can contain file paths, SQL, hostnames and library versions, which are precisely what an attacker wants. So the Internal variant logs everything and returns a fixed string, while variants that describe a client mistake (BadInput, NotFound) carry messages written to be shown. Make that a rule in review: a message in a client-facing variant is a public sentence. For a request correlation, add a request id to both the log line and the response body so a user can quote it in a bug report, which logging and request ids sets up. The broader treatment of what errors may reveal is in the Rust security checklist.

Keep the machine-readable code stable. Clients branch on "not_found", not on the English sentence, so you can reword messages or translate them without a breaking change. The status code carries the HTTP-level class and the code string carries the domain-level meaning, so two different 422 causes can be told apart.

Making the framework's own errors match

Everything above covers errors your code returns. Axum's extractors return their own rejections as plain text before your handler runs: post {"nm":1} to a handler that takes Json<Item> and you get a text sentence with status 422. To bring those into line, wrap the extractor so its rejection becomes your error. Axum's documentation recommends this over the older WithRejection helper:

src/main.rsrust
use axum::{
    extract::{FromRequest, Request, rejection::JsonRejection},
    routing::post,
};
use serde::{Deserialize, Serialize};

struct AppJson<T>(T);

impl<S, T> FromRequest<S> for AppJson<T>
where
    Json<T>: FromRequest<S, Rejection = JsonRejection>,
    S: Send + Sync,
{
    type Rejection = AppError;

    async fn from_request(req: Request, state: &S) -> Result<Self, Self::Rejection> {
        match Json::<T>::from_request(req, state).await {
            Ok(Json(v)) => Ok(AppJson(v)),
            Err(rej) => Err(AppError::BadInput(rej.body_text())),
        }
    }
}

impl<T: Serialize> IntoResponse for AppJson<T> {
    fn into_response(self) -> Response {
        Json(self.0).into_response()
    }
}

#[derive(Deserialize, Serialize)]
struct Item {
    name: String,
}

async fn create(AppJson(item): AppJson<Item>) -> AppJson<Item> {
    AppJson(Item { name: item.name.to_uppercase() })
}
Shell
curl -s -X POST localhost:3000/items -H 'content-type: application/json' -d '{"nm":1}'
curl -s -X POST localhost:3000/items -H 'content-type: application/json' -d '{"name":"ada"}'
Text
{"error":{"code":"bad_input","message":"Failed to deserialize the JSON body into the target type: missing field `name` at line 1 column 8"}}
{"name":"ADA"}

Use AppJson everywhere you would have used Json, and malformed bodies now answer in the same shape as every other failure. Do the same for Path and Query with their own rejection types (PathRejection, QueryRejection). The extractor ordering rules from extractors and the order that matters still apply: the wrapper is a body extractor, so it goes last. Request bodies in more depth, including size limits and content-type behavior, are in JSON requests and responses.

Errors from layers and panics

Two sources escape this scheme. Middleware that rejects a request, such as an auth layer returning StatusCode::UNAUTHORIZED, produces whatever that layer returns, so make your layers return AppError values too (it implements IntoResponse, so a from_fn middleware can return Result<Response, AppError>). And a panic in a handler is caught by Hyper, which closes the connection with no response; add tower_http::catch_panic::CatchPanicLayer and give it a handler that returns your internal-error shape, so a panic becomes a 500 JSON body and not a dropped connection. The layering order for these is in middleware with Tower layers.

Libraries that already do half of this

Write the enum yourself while the number of variants is small. As it grows, thiserror derives Display and From for the enum, and anyhow::Error can serve as the single catch-all for internal failures so the Internal variant holds one instead of a boxed trait object. The division of labor between domain errors that callers match on and opaque errors that only get logged is explained in error handling in Rust and, for the layering between modules, error boundaries and layers. A database-specific case, translating a unique-constraint violation into a 409 without leaking the SQL, is worked through in the unique violation guide.

Testing the error mapping

The error type is the one place that decides every failure response, so it deserves direct tests. Convert each variant into a response and assert on the status and the body, without going through a router:

src/main.rsrust
#[cfg(test)]
mod tests {
    use super::*;
    use http_body_util::BodyExt;

    async fn status_and_body(e: AppError) -> (StatusCode, serde_json::Value) {
        let res = e.into_response();
        let status = res.status();
        let bytes = res.into_body().collect().await.unwrap().to_bytes();
        (status, serde_json::from_slice(&bytes).unwrap())
    }

    #[tokio::test]
    async fn not_found_is_404_with_a_code() {
        let (status, body) = status_and_body(AppError::NotFound("item 0".into())).await;
        assert_eq!(status, StatusCode::NOT_FOUND);
        assert_eq!(body["error"]["code"], "not_found");
    }

    #[tokio::test]
    async fn internal_errors_hide_their_cause() {
        let cause = std::io::Error::other("secret path /var/lib/db");
        let (status, body) = status_and_body(AppError::Internal(Box::new(cause))).await;
        assert_eq!(status, StatusCode::INTERNAL_SERVER_ERROR);
        assert!(!body.to_string().contains("secret"));
    }
}

The second test is the one that matters most: it pins the promise that an internal error never leaks its text, so a future edit that interpolates the cause into the message fails the build. Both tests call into_response directly because AppError is just a type implementing the trait; the full request-level tests in testing with Tower oneshot cover the wiring.

When a plain status code is enough

Internal tools and webhooks often do not need a body: a receiver that only looks at the status does not benefit from JSON, and returning StatusCode directly is clearer. Do not add an AppError to a three-route service. And if your API follows a standard format, such as RFC 9457 problem details, use that shape instead of inventing one: the same IntoResponse impl serves, with application/problem+json as the content type and type, title, status and detail as fields. The design principle is the same either way: one type, one place that decides the response, and no failure that bypasses it. The way a larger service lays this out is in an Axum production service.

Also inRust
Share

Keep reading

Get new posts by email

We send one email when a new post is published. No spam.

Unsubscribe in one click. No spam, ever.