{"id":244,"date":"2026-05-17T13:00:37","date_gmt":"2026-05-17T13:00:37","guid":{"rendered":"https:\/\/abrarqasim.com\/blog\/axum-error-handling-split-that-stopped-opaque-500s\/"},"modified":"2026-05-17T13:00:37","modified_gmt":"2026-05-17T13:00:37","slug":"axum-error-handling-split-that-stopped-opaque-500s","status":"publish","type":"post","link":"https:\/\/abrarqasim.com\/blog\/axum-error-handling-split-that-stopped-opaque-500s\/","title":{"rendered":"Axum Error Handling: the Split That Stopped My Opaque 500s"},"content":{"rendered":"<p>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.<\/p>\n<p>Here&rsquo;s what I landed on after the third iteration. It&rsquo;s not novel. It&rsquo;s the split that finally felt sane: <code>thiserror<\/code> for the errors my code can actually branch on, <code>anyhow<\/code> for the &ldquo;this is a bug or an outage&rdquo; errors, and a thin <code>IntoResponse<\/code> impl that turns either one into a real HTTP response without leaking my internals to the caller.<\/p>\n<p>If you&rsquo;ve been writing Axum handlers and you keep slapping <code>anyhow::Result<\/code> on every function, then squinting at the 500 in your logs trying to figure out which step failed, this post is for you.<\/p>\n<h2 id=\"why-anyhow-on-its-own-falls-over-inside-axum-handlers\">Why anyhow on its own falls over inside Axum handlers<\/h2>\n<p>I started the service the lazy way. Every handler returned <code>anyhow::Result&lt;Json&lt;T&gt;&gt;<\/code> and I added a single <code>From&lt;anyhow::Error&gt;<\/code> impl that turned everything into a 500 with a generic body.<\/p>\n<pre><code class=\"language-rust\">async fn create_subscription(\n    State(db): State&lt;PgPool&gt;,\n    Json(input): Json&lt;NewSub&gt;,\n) -&gt; anyhow::Result&lt;Json&lt;Subscription&gt;&gt; {\n    let row = sqlx::query_as!(...).fetch_one(&amp;db).await?;\n    Ok(Json(row.into()))\n}\n<\/code><\/pre>\n<p>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&rsquo;t even write a useful integration test for the duplicate case, because my own handler couldn&rsquo;t tell the difference between a 409 and a service outage.<\/p>\n<p>The Axum docs are pretty direct about this. The <a href=\"https:\/\/docs.rs\/axum\/latest\/axum\/error_handling\/index.html\" rel=\"nofollow noopener\" target=\"_blank\">error-handling guide<\/a> tells you to implement <code>IntoResponse<\/code> on your own error type. Once I read that for the third time, I gave in and did the work.<\/p>\n<h2 id=\"the-split-i-keep-landing-on\">The split I keep landing on<\/h2>\n<p>The mental model is small. An error is either &ldquo;the caller did something I can describe to them&rdquo; (validation failed, not found, unauthorized) or &ldquo;something inside the service broke and the caller can&rsquo;t help&rdquo; (DB down, third-party API timed out, my own code panicked).<\/p>\n<p>The first kind wants a specific variant, a stable status code, and a stable response shape. That&rsquo;s what <code>thiserror<\/code> is for. The second kind is just &ldquo;anything that went wrong&rdquo; and the caller doesn&rsquo;t need detail. That&rsquo;s what <code>anyhow<\/code> is for.<\/p>\n<pre><code class=\"language-rust\">use thiserror::Error;\n\n#[derive(Debug, Error)]\npub enum AppError {\n    #[error(&quot;not found&quot;)]\n    NotFound,\n    #[error(&quot;conflict: {0}&quot;)]\n    Conflict(String),\n    #[error(&quot;invalid input: {0}&quot;)]\n    BadRequest(String),\n    #[error(&quot;unauthorized&quot;)]\n    Unauthorized,\n    #[error(transparent)]\n    Internal(#[from] anyhow::Error),\n}\n<\/code><\/pre>\n<p>The <code>#[error(transparent)]<\/code> plus <code>#[from] anyhow::Error<\/code> is the bit that makes this play nicely with <code>?<\/code>. I can still write <code>.context(\"loading subscription\")?<\/code> on a <code>sqlx<\/code> call and the error rolls up into <code>AppError::Internal<\/code> without me writing any glue. Anything I want the caller to know about, I match on and return a specific variant.<\/p>\n<p>I covered some of this when I wrote about <a href=\"https:\/\/abrarqasim.com\/blog\/axum-rust-patterns-that-actually-stuck\/\" rel=\"noopener\">Axum patterns that actually stuck<\/a>, but the error angle is the one I keep tweaking.<\/p>\n<h2 id=\"making-intoresponse-do-the-boring-work\">Making IntoResponse do the boring work<\/h2>\n<p>The conversion from <code>AppError<\/code> to an HTTP response is where most teams I&rsquo;ve seen get cute. Don&rsquo;t. Keep it boring.<\/p>\n<pre><code class=\"language-rust\">use axum::{\n    http::StatusCode,\n    response::{IntoResponse, Response},\n    Json,\n};\nuse serde_json::json;\n\nimpl IntoResponse for AppError {\n    fn into_response(self) -&gt; Response {\n        let (status, code, message) = match &amp;self {\n            AppError::NotFound =&gt; (\n                StatusCode::NOT_FOUND,\n                &quot;not_found&quot;,\n                &quot;resource not found&quot;.to_string(),\n            ),\n            AppError::Conflict(m) =&gt; (StatusCode::CONFLICT, &quot;conflict&quot;, m.clone()),\n            AppError::BadRequest(m) =&gt; (StatusCode::BAD_REQUEST, &quot;bad_request&quot;, m.clone()),\n            AppError::Unauthorized =&gt; (\n                StatusCode::UNAUTHORIZED,\n                &quot;unauthorized&quot;,\n                &quot;auth required&quot;.to_string(),\n            ),\n            AppError::Internal(err) =&gt; {\n                tracing::error!(error = ?err, &quot;internal error&quot;);\n                (\n                    StatusCode::INTERNAL_SERVER_ERROR,\n                    &quot;internal&quot;,\n                    &quot;something went wrong&quot;.to_string(),\n                )\n            }\n        };\n\n        (status, Json(json!({ &quot;code&quot;: code, &quot;message&quot;: message }))).into_response()\n    }\n}\n<\/code><\/pre>\n<p>A few things I do on purpose here. The internal variant logs the full error chain with <code>?err<\/code>, so I get the <code>anyhow<\/code> context in my logs, but the response body never sees it. Every variant returns the same envelope, <code>{ code, message }<\/code>, so the frontend has exactly one parser to write. Status codes get picked once at the type level, not at every call site.<\/p>\n<p>Once this is wired up, every handler can go back to being small:<\/p>\n<pre><code class=\"language-rust\">async fn get_subscription(\n    State(db): State&lt;PgPool&gt;,\n    Path(id): Path&lt;Uuid&gt;,\n) -&gt; Result&lt;Json&lt;Subscription&gt;, AppError&gt; {\n    let row = sqlx::query_as!(Subscription, &quot;select ... where id = $1&quot;, id)\n        .fetch_optional(&amp;db)\n        .await\n        .context(&quot;loading subscription&quot;)?\n        .ok_or(AppError::NotFound)?;\n    Ok(Json(row))\n}\n<\/code><\/pre>\n<p>The <code>.context()<\/code> call attaches a label that ends up in the log line for <code>Internal<\/code> errors. The <code>.ok_or(AppError::NotFound)?<\/code> turns a missing row into a real 404 with the right body shape. I do not have to think about HTTP at this layer.<\/p>\n<h2 id=\"logging-without-leaking-internals-to-the-client\">Logging without leaking internals to the client<\/h2>\n<p>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.<\/p>\n<p>For <code>AppError::Internal<\/code>, I emit <code>tracing::error!<\/code> with the full chain. The response body stays vague on purpose. For 4xx variants, I log at <code>debug<\/code>, or I skip the log entirely. They&rsquo;re not bugs, they&rsquo;re conversation.<\/p>\n<p>I learned this the hard way when our log volume tripled overnight, because I had <code>tracing::warn!<\/code> on every <code>BadRequest<\/code>. Anyone bored could spam our API and fill the logs. I dropped the broad warns and only logged what was actually surprising.<\/p>\n<p>The <a href=\"https:\/\/docs.rs\/anyhow\/latest\/anyhow\/\" rel=\"nofollow noopener\" target=\"_blank\">anyhow docs<\/a> on <code>.context()<\/code> are worth a re-read here. The point isn&rsquo;t that anyhow gives you fancier errors. It&rsquo;s that anyhow makes building a useful chain trivial. I attach context at every layer that might be ambiguous later: &ldquo;loading subscription&rdquo;, &ldquo;decoding Stripe payload&rdquo;. Future-me reading the log at 2 a.m. only has to read the chain top to bottom.<\/p>\n<h2 id=\"when-i-reach-for-apperror-vs-just-punting\">When I reach for AppError vs just punting<\/h2>\n<p>I have a smaller side project, a CLI that hits a couple of REST APIs, where the whole error type is just <code>anyhow::Result<\/code>. No <code>AppError<\/code>. No <code>IntoResponse<\/code>. The CLI prints the error chain and exits. That&rsquo;s correct. The shape above only earns its keep when there&rsquo;s a contract with a caller that cares about status codes and stable bodies.<\/p>\n<p>A rough rule I use:<\/p>\n<ul>\n<li>One process, one human user, errors print to stderr: <code>anyhow::Result<\/code> everywhere.<\/li>\n<li>HTTP API with a real client on the other end: <code>AppError<\/code> plus <code>IntoResponse<\/code>.<\/li>\n<li>gRPC or a background worker: the same idea, but with <code>tonic::Status<\/code> or a job-specific error.<\/li>\n<\/ul>\n<p>Don&rsquo;t reach for the second one before you have a real caller. I&rsquo;ve seen Rust codebases drown in enum variants for errors no one ever matches on. If a variant has never appeared in any <code>match<\/code> arm except the response converter, it&rsquo;s probably a string.<\/p>\n<p>For the database side of this, I went deeper in my <a href=\"https:\/\/abrarqasim.com\/blog\/rust-sqlx-production-eight-months-of-lessons\/\" rel=\"noopener\">sqlx in production post<\/a>. The same <code>Internal<\/code> variant catches <code>sqlx::Error<\/code> cleanly via <code>anyhow::Context<\/code>, so I don&rsquo;t end up writing a custom <code>From<\/code> impl for every DB call.<\/p>\n<h2 id=\"what-id-tell-past-me\">What I&rsquo;d tell past-me<\/h2>\n<p>Three things, condensed.<\/p>\n<p>First, don&rsquo;t return <code>anyhow::Result<\/code> from a handler. Wrap it in a domain error type that implements <code>IntoResponse<\/code>. Future-you trying to add a 409 will thank present-you.<\/p>\n<p>Second, keep the response body shape constant. One <code>{ code, message }<\/code> envelope across every variant. Don&rsquo;t invent a new shape per error class.<\/p>\n<p>Third, log internals at <code>error<\/code>, log 4xx errors sparingly. Your log volume should track your surprise level, not your traffic level.<\/p>\n<p>If you&rsquo;re building small Rust web services in 2026, the kind of work I do a lot of in <a href=\"https:\/\/abrarqasim.com\/work\" rel=\"noopener\">my client projects<\/a>, this exact pattern has held up across four services so far. Nothing fancy. Just enough structure that I can stop staring at opaque 500s.<\/p>\n<p>This week, if you have an Axum service you wrote in a hurry: pull <code>thiserror<\/code> into your <code>Cargo.toml<\/code>, add a five-variant <code>AppError<\/code>, implement <code>IntoResponse<\/code>, and delete every <code>unwrap()<\/code> in your handlers. Twenty minutes of work. It pays for itself the first time you hit a real 409.<\/p>\n","protected":false},"excerpt":{"rendered":"<p>How I split thiserror and anyhow inside Axum handlers, then let IntoResponse map domain errors to real status codes without leaking internals to callers.<\/p>\n","protected":false},"author":2,"featured_media":243,"comment_status":"","ping_status":"open","sticky":false,"template":"","format":"standard","meta":{"rank_math_title":"","rank_math_description":"How I split thiserror and anyhow inside Axum handlers, then let IntoResponse map domain errors to real status codes without leaking internals to callers.","rank_math_focus_keyword":"axum rust","rank_math_canonical_url":"","rank_math_robots":"","footnotes":""},"categories":[147,142],"tags":[283,117,49,48,64,284,39],"class_list":["post-244","post","type-post","status-publish","format-standard","has-post-thumbnail","hentry","category-backend","category-rust","tag-anyhow","tag-axum","tag-backend","tag-error-handling","tag-rust","tag-thiserror","tag-web-development"],"_links":{"self":[{"href":"https:\/\/abrarqasim.com\/blog\/wp-json\/wp\/v2\/posts\/244","targetHints":{"allow":["GET"]}}],"collection":[{"href":"https:\/\/abrarqasim.com\/blog\/wp-json\/wp\/v2\/posts"}],"about":[{"href":"https:\/\/abrarqasim.com\/blog\/wp-json\/wp\/v2\/types\/post"}],"author":[{"embeddable":true,"href":"https:\/\/abrarqasim.com\/blog\/wp-json\/wp\/v2\/users\/2"}],"replies":[{"embeddable":true,"href":"https:\/\/abrarqasim.com\/blog\/wp-json\/wp\/v2\/comments?post=244"}],"version-history":[{"count":0,"href":"https:\/\/abrarqasim.com\/blog\/wp-json\/wp\/v2\/posts\/244\/revisions"}],"wp:featuredmedia":[{"embeddable":true,"href":"https:\/\/abrarqasim.com\/blog\/wp-json\/wp\/v2\/media\/243"}],"wp:attachment":[{"href":"https:\/\/abrarqasim.com\/blog\/wp-json\/wp\/v2\/media?parent=244"}],"wp:term":[{"taxonomy":"category","embeddable":true,"href":"https:\/\/abrarqasim.com\/blog\/wp-json\/wp\/v2\/categories?post=244"},{"taxonomy":"post_tag","embeddable":true,"href":"https:\/\/abrarqasim.com\/blog\/wp-json\/wp\/v2\/tags?post=244"}],"curies":[{"name":"wp","href":"https:\/\/api.w.org\/{rel}","templated":true}]}}