Error Handling
By Phase 6 the books API works, but look closely at the handlers and you'll see
a smell. Every one that can fail spells out its own failure inline: look up a
book, and if it's missing, return StatusCode::NOT_FOUND; parse something, and
if it's bad, build a (StatusCode::BAD_REQUEST, "...") tuple by hand. The happy
path and the sad path are tangled together, and every handler invents its own
error shape. Add a tenth endpoint and you're copy-pasting the same if let None dance for the tenth time.
Here's the thing other frameworks make hard and axum makes beautiful: in axum,
an error is a return value, not an exception. There is no throw, no
exception that unwinds the stack, no global error handler you register and hope
fires. You make one type that knows how to turn itself into an HTTP response,
and from then on your handlers just hand that type back. The language does the
rest.
📝 Mental model: a handler that returns
Result<T, E>is a valid axum handler as long as bothTandEimplementIntoResponse. You already knowIntoResponsefrom Phase 3 — it's what makes a return value become a response. So define your ownAppError, implementIntoResponsefor it once, and every handler can returnResult<_, AppError>. The?operator then propagates failures for you, and axum renders whatever comes back — success or error — through the same machinery. This is the cleanest error story of any framework in this guide, and it falls straight out of Rust's type system.
One error type, one response shape
Start by naming the ways your API can fail. For the books service that's a small set: the book doesn't exist, the client sent something invalid, or something broke on our end. That's an enum.
use ;
What just happened: AppError enumerates the failure modes, with
BadRequest(String) carrying a message so callers know what was wrong. The
impl IntoResponse is the whole trick — it's the single place that decides how
an error becomes HTTP. We match the variant into a (status, message) pair,
then build the response from a tuple: (StatusCode, Json<...>) already
implements IntoResponse (you saw that pattern in Phase 3), so wrapping the
message in serde_json::json! gives every error the same JSON envelope —
{"error": "..."}. Change that shape here, once, and every endpoint's errors
change with it. No handler ever builds an error response by hand again.
Rewriting the handlers with ?
Now the payoff. Compare the Phase 6 style — manual StatusCode returns — with
what AppError lets you write. Here's a show handler, before:
// Phase 6 style: failure handling tangled into the handler.
async
And after, with AppError and ?:
use ;
async
What just happened: the match collapsed into one line. books.get(&id)
returns an Option<&Book>; .cloned() turns it into Option<Book>; and
.ok_or(AppError::NotFound) converts that Option into a Result<Book, AppError> — Some becomes Ok, None becomes Err(AppError::NotFound). The
? then says "if this is an Err, return it from the function right now;
otherwise unwrap the Ok." Because AppError: IntoResponse, returning that
Err is a complete, valid response — axum renders it as our 404 JSON. The
handler now reads as the happy path with failure points marked by ?, which is
exactly how you want to read it.
Validation gets the same treatment. Suppose creating a book requires a non-empty title:
async
What just happened: a plain return Err(...) short-circuits with a 400 and
our message; the success path returns 201 Created. Both arms are values of the
same Result<StatusCode, AppError>, both implement IntoResponse, so axum
handles either without you wiring up anything extra. The error is the return
value.
Folding foreign errors in with From
The ? operator has a second superpower you haven't used yet: it doesn't just
propagate an error, it converts it. When you write something? and the error
type doesn't match your function's error type, Rust looks for a From impl to
bridge them. That's how you let ? swallow errors from libraries — a database
driver, a JSON parser — that know nothing about your AppError.
Say a future version of the books API talks to a real database via sqlx. Its
calls return Result<_, sqlx::Error>. Teach AppError how to absorb that:
What just happened: this From<sqlx::Error> impl maps a missing row to our
NotFound and treats every other database failure as an Internal error —
logging the real cause with tracing (so you see it) while sending the client
only a generic 500 (so you don't leak internals). Now a handler can use ?
directly on a sqlx call:
async
What just happened: fetch_one yields Result<Book, sqlx::Error>, but the
function returns Result<_, AppError>. The ? sees the mismatch, finds your
From<sqlx::Error> for AppError, and converts on the way out — a RowNotFound
becomes a clean 404, anything else a logged 500. One ? does propagation
and conversion and the correct status code, with zero boilerplate in the
handler.
💡 Writing
impl IntoResponseand a pile ofFromimpls by hand gets old. Thethiserrorcrate generates them from a derive: annotate each variant with a#[from]and a display message and it writes theFromandDisplayimpls for you, leaving you just theIntoResponse. For quick application code where you don't need a typed enum at all,anyhowgives you one catch-all error type (anyhow::Error) and an ergonomic?everywhere — many people pair ananyhow-style internal error with a thinIntoResponsewrapper. Reach forthiserrorwhen callers need to distinguish variants; reach foranyhowwhen they don't.
Let the framework handle what it already handles
One trap worth naming: don't reinvent error handling axum already does for you.
⚠️ axum's built-in extractors reject bad input before your handler runs, with
sensible defaults. Send a malformed JSON body to a handler taking
Json<Book> and you get a 400 Bad Request with a useful message — you never
wrote that code. Same for a missing path segment, a bad Query, an oversized
body. You can customize these rejections (by wrapping an extractor and
implementing your own rejection type), but the defaults are good; only override
them when you genuinely need a different shape.
⚠️ And the cardinal rule: don't panic in a handler — return an error. A
.unwrap() on a failing Result, an out-of-bounds index, an expect() that
fires — these don't produce a tidy 500, they unwind the task. axum will catch
it and return a bare 500 to the client, but you've thrown away the chance to
log context, choose a status, or shape the body. Every fallible step should be a
? into your AppError, not a panic. (The one .unwrap() you'll see survive in
this guide is state.books.lock().unwrap() on a Mutex — a poisoned lock means
another thread already panicked while holding it, so the process is arguably
doomed anyway. Even that you'd harden in production code.)
💡 The shape to keep in your head: extractor rejections guard the door (bad input never reaches you),
?withAppErrorhandles everything your logic can hit, and panics are bugs, not a control-flow tool. Get those three straight and your error handling is both correct and almost invisible.
Recap
- In axum an error is a return value, not an exception: a handler returning
Result<T, E>is valid whenever bothTandEimplementIntoResponse. - Define one
AppErrorenum and implementIntoResponsefor it once so every endpoint shares a single JSON error shape — change it in one place. - Handlers return
Result<_, AppError>and use?with helpers like.ok_or(AppError::NotFound), collapsing tangledmatches into the happy path with marked failure points. - The
?operator also converts: animpl From<E> for AppErrorlets?fold foreign errors (e.g.sqlx::Error) into your type — mapping to the right status and logging the real cause while hiding internals. - Use
thiserrorto derive theFrom/Displayboilerplate, oranyhowfor a catch-all app error when callers don't need to distinguish variants. - Lean on axum's built-in extractor rejections for bad input, and never panic in a handler — return an error so you control the status, body, and logs.
Quick check
Lock in the error model before moving on to testing and production.
[
{
"q": "What makes a handler returning Result<Json<Book>, AppError> a valid axum handler?",
"choices": [
"AppError is registered in a global error handler",
"Both Json<Book> and AppError implement IntoResponse",
"The handler is wrapped in a try/catch layer",
"AppError derives Clone"
],
"answer": 1,
"explain": "axum accepts any Result handler as long as both the Ok type and the Err type implement IntoResponse — then it renders whichever one is returned."
},
{
"q": "Why does `let b = books.get(&id).cloned().ok_or(AppError::NotFound)?;` work?",
"choices": [
"ok_or turns the Option into a Result, and ? returns the Err (an IntoResponse) or unwraps the Ok",
"? catches a panic raised by get()",
"ok_or logs the error and returns 200 anyway",
"It only compiles if AppError implements Clone"
],
"answer": 0,
"explain": "ok_or maps None to Err(AppError::NotFound); ? then early-returns that Err (which is an IntoResponse, so a complete response) or unwraps the Some."
},
{
"q": "How does `?` let a handler call a sqlx function that returns sqlx::Error and still return AppError?",
"choices": [
"sqlx::Error and AppError are the same type",
"? silently discards the sqlx error and returns Internal",
"An impl From<sqlx::Error> for AppError lets ? convert the error as it propagates",
"axum auto-converts any error into AppError"
],
"answer": 2,
"explain": "When the error types differ, ? looks for a From impl. Implementing From<sqlx::Error> for AppError makes ? convert and propagate in one step."
}
]
← Phase 6: Building a REST API · Guide overview · Phase 8: Testing & Production →
Check your understanding
1. What makes a handler returning Result<Json<Book>, AppError> a valid axum handler?
2. Why does `let b = books.get(&id).cloned().ok_or(AppError::NotFound)?;` work?
3. How does `?` let a handler call a sqlx function that returns sqlx::Error and still return AppError?