A REST API with Error Handling
Everything else was building toward this. You have an App, routing, extractors, responders, shared
state, and middleware. Now wire them into a real REST resource - full CRUD over the articles store -
and fix a pain you've been quietly living with since Phase 3.
The mental model
Two ideas, and the rest of this phase writes itself.
One: a REST resource is just five handlers over the shared store. "Articles" isn't some special
construct - it's a list endpoint, a show endpoint, a create, an update, and a delete, all reaching through
the same web::Data<AppState> you built in Phase 4. The HTTP method plus the path
decides which handler runs; the body of each handler is a small read or write against that Mutex<HashMap>.
Two: the clean way to vary status is to return a Result and let actix render the error. Back in
Phase 3 you likely hit this wall: one branch returns HttpResponse::Ok().json(...), another wants
HttpResponse::NotFound().finish(), and the types don't line up unless you match and rebuild responses by
hand in every handler. actix's answer is to push the error out of the happy path. Your handler returns
Result<HttpResponse, ApiError>, the success arm builds the 200, and a returned Err becomes the HTTP
response automatically - because your error type implements the ResponseError trait. One place defines
what a "not found" looks like on the wire; every handler just says ?.
💡 If you've read the axum guide, this is the same shape under a different name. axum has you implement
IntoResponseon your error type; actix has you implementResponseError. Both turn "a value my handler returned" into "an HTTP response." Once you see one, you've seen both.
The five handlers
Five functions, mounted on a web::scope("/api/v1") so the version prefix lives in one place. Each reads
or writes the shared store and returns Result<HttpResponse, ApiError> (we'll define ApiError next -
read these first to see why we want it):
use ;
use ;
use HashMap;
use Mutex;
// what the client sends on create/update - no id, the server owns that
// GET /api/v1/articles → 200 with the list
async
// GET /api/v1/articles/{id} → 200, or 404 if missing
async
// POST /api/v1/articles → 201 with the created article
async
// PUT /api/v1/articles/{id} → 200, or 404 if missing
async
// DELETE /api/v1/articles/{id} → 204, or 404 if missing
async
What just happened: five handlers, one shared store. show, update, and delete share the same
"look it up, ? away the NotFound" move - map.get(...).ok_or(ApiError::NotFound)? reads as "give me the
article or bail out with a 404," and the bail-out is the response. create mints an id from the next_id
counter (a second Mutex, locked independently of the map) and returns 201 Created. delete returns
204 No Content via .finish() - no body. Notice what's not here: no match rebuilding error responses,
no juggling HttpResponse types - the error arm left the building via ?.
Now mount them. The five handlers live on a scope, and the state is built once and cloned in per worker, exactly the pattern from Phase 4:
async
What just happened: web::scope("/api/v1") prefixes every route inside it, so /articles becomes
/api/v1/articles. The same path string carries multiple methods - web::get() and web::post() on
/articles are two distinct routes. state.clone() hands each worker a handle to the one AppState,
the load-bearing detail from Phase 4.
The error type: one shape, defined once
Everything above leaned on ApiError. Here's the whole thing - an enum of the failures this API can
produce, plus the two trait impls teaching actix how to turn it into an HTTP response:
use ;
use fmt;
What just happened: ResponseError is the whole trick. When a handler returns Err(ApiError::NotFound),
actix calls error_response() on it and sends back whatever that produces - the right status with a
consistent {"error": "..."} body. status_code() maps each variant to its HTTP status; the default
error_response() would use plain text, but we override it so every error speaks the same JSON dialect.
Display is required by the trait, cheaply derived off Debug here. Define this once and every handler
returning Result<_, ApiError> gets it for free.
⚠️
ResponseErrorrequires your type to beDisplay + Debug. If the compiler complains that your error "doesn't implementResponseError," check that you actually impl'dDisplay- that's the usual missing piece, and the error message can point at the wrong line.
?, From, and foreign errors
The ? operator does quiet, important work. On a Result<T, ApiError> it's a clean early return - but it
has a superpower: it converts error types on the way out if there's a From impl connecting them. That's
how ? swallows errors from libraries that know nothing about your ApiError.
Say a handler parses something and gets a std::num::ParseIntError. Teach ApiError how to absorb it:
async
What just happened: path.parse() returns Result<u32, ParseIntError>. Because From<ParseIntError> for
ApiError exists, ? converts the foreign error into an ApiError::BadRequest - which ResponseError
renders as a 400. One From impl and every ? on a ParseIntError maps to a sensible response. This is
how real handlers stay short: DB errors, parse errors, and your own all funnel through ? into one type.
💡 Writing
DisplayandFromimpls by hand gets tedious as the enum grows. Thethiserrorcrate generates both from attributes - annotate each variant with a#[error("...")]message and add#[from]to a field to auto-derive the conversion. Same mental model;thiserrorjust deletes the boilerplate. It's the standard choice once an API has more than a couple of error variants.
⚠️ Extractor failures are already errors - don't panic
One thing the framework handles before your code runs: if a client POSTs malformed JSON, the
web::Json<ArticleInput> extractor fails during extraction and actix returns a 400 Bad Request on its
own - you never see a bad body inside your handler. The principle: a handler that can't proceed should
return an error, never panic. A .unwrap() on something a client controls is a 500 waiting to happen.
If you want the default extractor error to match your JSON error shape, configure it with app_data:
use JsonConfig;
// inside the App builder, alongside .app_data(state.clone()):
.app_data
What just happened: JsonConfig's error_handler intercepts extractor-level JSON failures and returns a
custom response - the same {"error": "..."} shape your ResponseError produces. Without this you still
get a 400 on bad JSON; this just matches the body to your house style.
Take it for a spin
With the server running, exercise the full lifecycle with curl:
# create one → 201 with the new article (including its server-assigned id)
# list them → 200 with a JSON array
# fetch one that doesn't exist → 404 {"error":"NotFound"}
# update it → 200 with the new values
# delete it → 204, empty body
What just happened: you drove all five handlers and saw all four status codes (200, 201, 204, 404) without
a hand-built error branch in your handler bodies - Result + ResponseError rendered the 404s for you. A
bad JSON body gets you the 400 the extractor (or your JsonConfig) produces.
💡 The
Mutex<HashMap>is a teaching prop, not a database. To go real, swap the handler bodies forsqlxqueries against thePgPoolfrom Phase 4 and add aFrom<sqlx::Error>impl so DB failures?straight intoApiErroras 500s. The error model doesn't change - only what happens between the lock and the response does.
Recap
- A REST resource is five handlers over the shared store - list, show, create, update, delete -
mounted on
web::scope("/api/v1"), each reaching throughweb::Data<AppState>from Phase 4. - Handlers return
Result<HttpResponse, ApiError>and use?; a returnedErrbecomes the HTTP response, so you stop juggling response types across branches. - The
ResponseErrortrait (withDisplay) defines status and body in one place -status_code()maps variants to codes,error_response()gives every error the same JSON shape. From<E>impls let?convert foreign errors (parse, DB) into yourApiError;thiserrorgenerates theDisplay/Fromboilerplate once the enum grows.- ⚠️ Extractor failures (bad JSON) already produce 400s automatically - customize the response with
JsonConfig::error_handlerif you want it to match your shape.
Quick check
[
{
"q": "A handler returns Result<HttpResponse, ApiError> and returns Err(ApiError::NotFound). How does that become a 404 response?",
"choices": [
"actix checks the variant name and guesses the status",
"ApiError implements ResponseError, so actix calls its status_code/error_response to render the Err",
"The ? operator sets the status code directly",
"You must add a match in the handler to convert it"
],
"answer": 1,
"explain": "Implementing ResponseError on your error type teaches actix how to turn a returned Err into an HTTP response - status_code() and error_response() define the status and body once, for every handler."
},
{
"q": "Why does writing impl From<std::num::ParseIntError> for ApiError help in a handler?",
"choices": [
"It makes ParseIntError print nicer in logs",
"It lets the ? operator auto-convert a ParseIntError into your ApiError on the way out",
"It is required before you can call .parse()",
"It changes the HTTP status of every response to 400"
],
"answer": 1,
"explain": "The ? operator converts error types when a From impl connects them. With From<ParseIntError> for ApiError, a ? on a parse result returns your ApiError, which ResponseError then renders."
},
{
"q": "A client POSTs malformed JSON to your create handler. What happens by default?",
"choices": [
"Your handler runs with an empty struct",
"The web::Json extractor fails during extraction and actix returns a 400 before your handler runs",
"The request panics and the server crashes",
"actix returns a 500 because there is no body"
],
"answer": 1,
"explain": "Extractor failures are handled before your handler body - bad JSON makes web::Json fail and actix returns a 400 on its own. You can customize that response with JsonConfig::error_handler, but you should never panic on client input."
}
]
Before the quiz: without looking back, say (or jot down) the core idea of this phase in your own words.
Check your understanding 3 questions
1. A handler returns Result<HttpResponse, ApiError> and returns Err(ApiError::NotFound). How does that become a 404 response?
2. Why does writing impl From<std::num::ParseIntError> for ApiError help in a handler?
3. A client POSTs malformed JSON to your create handler. What happens by default?