I shipped a small Axum service last month, a webhook receiver for some Stripe events, and the first thing I had to wrestle with again was error handling. Not because Axum makes it hard. Because Rust gives you about four reasonable shapes for an error type, and a vocal opinion online for every one of them.
Here’s what I landed on after the third iteration. It’s not novel. It’s the split that finally felt sane: thiserror for the errors my code can actually branch on, anyhow for the “this is a bug or an outage” errors, and a thin IntoResponse impl that turns either one into a real HTTP response without leaking my internals to the caller.
If you’ve been writing Axum handlers and you keep slapping anyhow::Result on every function, then squinting at the 500 in your logs trying to figure out which step failed, this post is for you.
Why anyhow on its own falls over inside Axum handlers
I started the service the lazy way. Every handler returned anyhow::Result<Json<T>> and I added a single From<anyhow::Error> impl that turned everything into a 500 with a generic body.
async fn create_subscription(
State(db): State<PgPool>,
Json(input): Json<NewSub>,
) -> anyhow::Result<Json<Subscription>> {
let row = sqlx::query_as!(...).fetch_one(&db).await?;
Ok(Json(row.into()))
}
That worked for about a week. Then I caught myself returning 500 when a customer sent a malformed UUID in the path, 500 when a Stripe ID was already used, and 500 when our Postgres connection pool tripped. Three different failures, same status code, same opaque message. I couldn’t even write a useful integration test for the duplicate case, because my own handler couldn’t tell the difference between a 409 and a service outage.
The Axum docs are pretty direct about this. The error-handling guide tells you to implement IntoResponse on your own error type. Once I read that for the third time, I gave in and did the work.
The split I keep landing on
The mental model is small. An error is either “the caller did something I can describe to them” (validation failed, not found, unauthorized) or “something inside the service broke and the caller can’t help” (DB down, third-party API timed out, my own code panicked).
The first kind wants a specific variant, a stable status code, and a stable response shape. That’s what thiserror is for. The second kind is just “anything that went wrong” and the caller doesn’t need detail. That’s what anyhow is for.
use thiserror::Error;
#[derive(Debug, Error)]
pub enum AppError {
#[error("not found")]
NotFound,
#[error("conflict: {0}")]
Conflict(String),
#[error("invalid input: {0}")]
BadRequest(String),
#[error("unauthorized")]
Unauthorized,
#[error(transparent)]
Internal(#[from] anyhow::Error),
}
The #[error(transparent)] plus #[from] anyhow::Error is the bit that makes this play nicely with ?. I can still write .context("loading subscription")? on a sqlx call and the error rolls up into AppError::Internal without me writing any glue. Anything I want the caller to know about, I match on and return a specific variant.
I covered some of this when I wrote about Axum patterns that actually stuck, but the error angle is the one I keep tweaking.
Making IntoResponse do the boring work
The conversion from AppError to an HTTP response is where most teams I’ve seen get cute. Don’t. Keep it boring.
use axum::{
http::StatusCode,
response::{IntoResponse, Response},
Json,
};
use serde_json::json;
impl IntoResponse for AppError {
fn into_response(self) -> Response {
let (status, code, message) = match &self {
AppError::NotFound => (
StatusCode::NOT_FOUND,
"not_found",
"resource not found".to_string(),
),
AppError::Conflict(m) => (StatusCode::CONFLICT, "conflict", m.clone()),
AppError::BadRequest(m) => (StatusCode::BAD_REQUEST, "bad_request", m.clone()),
AppError::Unauthorized => (
StatusCode::UNAUTHORIZED,
"unauthorized",
"auth required".to_string(),
),
AppError::Internal(err) => {
tracing::error!(error = ?err, "internal error");
(
StatusCode::INTERNAL_SERVER_ERROR,
"internal",
"something went wrong".to_string(),
)
}
};
(status, Json(json!({ "code": code, "message": message }))).into_response()
}
}
A few things I do on purpose here. The internal variant logs the full error chain with ?err, so I get the anyhow context in my logs, but the response body never sees it. Every variant returns the same envelope, { code, message }, so the frontend has exactly one parser to write. Status codes get picked once at the type level, not at every call site.
Once this is wired up, every handler can go back to being small:
async fn get_subscription(
State(db): State<PgPool>,
Path(id): Path<Uuid>,
) -> Result<Json<Subscription>, AppError> {
let row = sqlx::query_as!(Subscription, "select ... where id = $1", id)
.fetch_optional(&db)
.await
.context("loading subscription")?
.ok_or(AppError::NotFound)?;
Ok(Json(row))
}
The .context() call attaches a label that ends up in the log line for Internal errors. The .ok_or(AppError::NotFound)? turns a missing row into a real 404 with the right body shape. I do not have to think about HTTP at this layer.
Logging without leaking internals to the client
One thing I had to remind myself: 4xx errors are messages to the caller, 5xx errors are messages to me. So I log them differently.
For AppError::Internal, I emit tracing::error! with the full chain. The response body stays vague on purpose. For 4xx variants, I log at debug, or I skip the log entirely. They’re not bugs, they’re conversation.
I learned this the hard way when our log volume tripled overnight, because I had tracing::warn! on every BadRequest. Anyone bored could spam our API and fill the logs. I dropped the broad warns and only logged what was actually surprising.
The anyhow docs on .context() are worth a re-read here. The point isn’t that anyhow gives you fancier errors. It’s that anyhow makes building a useful chain trivial. I attach context at every layer that might be ambiguous later: “loading subscription”, “decoding Stripe payload”. Future-me reading the log at 2 a.m. only has to read the chain top to bottom.
When I reach for AppError vs just punting
I have a smaller side project, a CLI that hits a couple of REST APIs, where the whole error type is just anyhow::Result. No AppError. No IntoResponse. The CLI prints the error chain and exits. That’s correct. The shape above only earns its keep when there’s a contract with a caller that cares about status codes and stable bodies.
A rough rule I use:
- One process, one human user, errors print to stderr:
anyhow::Resulteverywhere. - HTTP API with a real client on the other end:
AppErrorplusIntoResponse. - gRPC or a background worker: the same idea, but with
tonic::Statusor a job-specific error.
Don’t reach for the second one before you have a real caller. I’ve seen Rust codebases drown in enum variants for errors no one ever matches on. If a variant has never appeared in any match arm except the response converter, it’s probably a string.
For the database side of this, I went deeper in my sqlx in production post. The same Internal variant catches sqlx::Error cleanly via anyhow::Context, so I don’t end up writing a custom From impl for every DB call.
What I’d tell past-me
Three things, condensed.
First, don’t return anyhow::Result from a handler. Wrap it in a domain error type that implements IntoResponse. Future-you trying to add a 409 will thank present-you.
Second, keep the response body shape constant. One { code, message } envelope across every variant. Don’t invent a new shape per error class.
Third, log internals at error, log 4xx errors sparingly. Your log volume should track your surprise level, not your traffic level.
If you’re building small Rust web services in 2026, the kind of work I do a lot of in my client projects, this exact pattern has held up across four services so far. Nothing fancy. Just enough structure that I can stop staring at opaque 500s.
This week, if you have an Axum service you wrote in a hurry: pull thiserror into your Cargo.toml, add a five-variant AppError, implement IntoResponse, and delete every unwrap() in your handlers. Twenty minutes of work. It pays for itself the first time you hit a real 409.