Typed errors in Axum handlers
· 6 min read · API design · Axum · Rust
Every Axum service I have written has passed through the same phase: one AppError type that wraps anyhow::Error, one IntoResponse implementation that maps everything to a 500, and a note in the backlog to fix it later. It works, right up until someone asks why a client is retrying a request that will never succeed.
The problem is not the ergonomics of anyhow, which are excellent. The problem is that the handler's signature stops telling you anything. A function returning Result<Json<User>, AppError> describes exactly one fact: it might fail. It does not tell you whether failure means the caller sent something invalid, the row does not exist, or the database is on fire — and those three cases have completely different status codes, retry semantics, and alerting behaviour.
Making the failure modes explicit
The fix is unglamorous. Write an enum with one variant per way the handler can genuinely fail, implement IntoResponse once, and let ? do the conversion through From.
use axum::http::StatusCode;
use axum::response::{IntoResponse, Response};
use axum::Json;
use serde_json::json;
#[derive(Debug, thiserror::Error)]
pub enum ApiError {
#[error("not found")]
NotFound,
#[error("invalid request: {0}")]
Invalid(String),
#[error("conflict: {0}")]
Conflict(String),
#[error(transparent)]
Internal(#[from] sqlx::Error),
}
impl IntoResponse for ApiError {
fn into_response(self) -> Response {
let (status, message) = match &self {
ApiError::NotFound => (StatusCode::NOT_FOUND, self.to_string()),
ApiError::Invalid(_) => (StatusCode::BAD_REQUEST, self.to_string()),
ApiError::Conflict(_) => (StatusCode::CONFLICT, self.to_string()),
ApiError::Internal(err) => {
// Log the detail, return none of it.
tracing::error!(error = ?err, "unhandled internal error");
(StatusCode::INTERNAL_SERVER_ERROR, "internal error".to_owned())
}
};
(status, Json(json!({ "error": message }))).into_response()
}
}
Roughly a page of code, written once per service. What it buys is that the compiler now participates. Adding a fifth failure mode forces you to decide what status code it deserves, at the point where you have the context to decide.
What this changes downstream
The part I did not expect was how much this improved things that had nothing to do with error handling.
- Client retry logic became trivial, because 4xx and 5xx finally meant what they are supposed to mean.
- Alerting stopped firing on validation failures, which had been quietly training everyone to ignore the channel.
- Integration tests could assert on status codes rather than parsing error strings.
- The
Internalvariant became a genuine signal: anything landing there is a bug, not a user mistake.
If every failure is a 500, your error rate graph is measuring how often people typo an email address.
There is a real cost, which is that the enum grows. On a large service you end up with fifteen variants and the temptation to add a generic Other(anyhow::Error) escape hatch. I have made peace with adding it, on the condition that it maps to a 500 and gets logged loudly. An escape hatch that is visibly a last resort is fine; one that is the default path is how you end up back where you started.
The general principle is one I keep relearning in different forms: types are cheap documentation that cannot go stale. Spending twenty lines to make the failure modes visible in the signature pays for itself the first time someone reads the handler at two in the morning.