Error Handling in Axum
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
IntoResponseis the bridge between Rust errors and HTTP responses.- Start simple with
StatusCodeor(StatusCode, String). - For larger apps, define a single error enum and implement
IntoResponseonce. - Return JSON bodies for API consumers.
- Use
Fromimplementations to make the?operator propagate errors automatically. anyhowis handy for prototypes where you don’t need a detailed error hierarchy.HandleErrorhelps 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
| Pattern | Best For |
|---|---|
Result | Simple, code‑only errors |
Result with (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 for AppError | Using ? with library errors |
anyhow wrapper | Quick prototypes or scripts |
Recommended Approach for Most Apps
-
Define a central error type
#[derive(Debug)] enum AppError { NotFound, InvalidInput(String), Internal(String), // …other variants } -
Implement
IntoResponsefor 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() } } -
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()) } } -
Use
Resultin handlersasync 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.