A REST API with Error Catchers
This is the payoff phase. Everything you've built — attribute routes, dynamic paths, Json data, responders, managed state — comes together here into a real REST resource. And then we add the one piece that's been missing: a single place to define what an error looks like across the entire API.
Hold this mental model: a REST resource is five attribute-routed handlers over the managed store. List, show one, create, update, delete — that's the whole vocabulary of CRUD over HTTP. Each handler pulls what it needs from the signature (&State<AppState> for the store, id: u32 from the path, Json<NewBook> from the body), and each handler's return type carries the per-request outcome — an Option that 404s when a book is missing, a (Status, Json<Book>) that says "201 Created."
There's a second layer, and the distinction matters: responders handle outcomes your handler produces; catchers handle failures the framework produces. A typo'd URL that matches no route, a malformed JSON body that never reaches your function, a guard that rejects the request — your handler never runs for those, so it can't shape the response. That's a catcher's job. By the end you'll use both, on purpose, knowing which is which.
📝 We're still growing the same books API. From Phase 5 we have the managed store, and the model from Phase 4 plus an input type for writes:
use HashMap; use Mutex; use ; // The shape clients send on create/update — no id, the server owns that.
BookgainsCloneso we can hand a copy out of the lock;NewBookisDeserializebecause it arrives as a JSON body.
The five handlers
Here's the whole resource. Read it once top to bottom — notice how little ceremony there is per endpoint, and how each return type does the talking.
use State;
use Status;
use Json;
// READ all — GET /books
// READ one — GET /books/<id>
// CREATE — POST /books
// UPDATE — PUT /books/<id>
// DELETE — DELETE /books/<id>
What just happened: five handlers, one per CRUD operation, all reading the shared store through &State<AppState> and all locking the Mutex before they touch the map.
listclones every value out of the map and wraps theVecinJson— a 200 with a JSON array. We clone because the data lives behind the lock; the clone leaves the lock as soon as the function returns.showis the Phase-4 idiom over real state:.get(&id).cloned().map(Json)isSome(Json(book))when it exists,Noneotherwise — andNonebecomes a free 404.createcomputes the next id, builds theBookfrom theNewBookbody, inserts a clone, and returns(Status::Created, Json(book))— a 201 with the created record. The server assigns the id; the client doesn't get to.updatechecks existence first and returnsNone(→ 404) for a missing book, otherwise replaces the record and returns it with a 200.deletereturns the bareStatusresponder:204 No Contentwhen something was removed,404 Not Foundwhen there was nothing to remove.
Each "not found" here is a responder-level outcome — your handler ran, looked, and decided. Keep that in mind; the catchers below handle a different category of miss.
Now mount them. Same routes! you already know, just longer:
What just happened: .manage(...) registers the store so every &State<AppState> parameter resolves to it (Phase 5), and routes![...] lists all five handlers. That's a complete, working CRUD API. The next section adds the error layer.
Error catchers: one place for what failure looks like
Try requesting GET /bookz (a typo) against the API above. No route matches, so none of your handlers run — Rocket itself produces a 404. By default that's Rocket's generic HTML error page. For a JSON API, an HTML error in the middle of JSON responses is jarring and breaks clients that always parse the body as JSON.
This is exactly what catchers are for. A catcher is a function annotated with #[catch(<status>)] that produces the response for a given error status when no responder did. You register catchers separately from routes, with .register(...).
use ;
use Request;
What just happened: three catchers, one per status we care about. The &Request parameter gives a catcher access to the failed request — not_found reads req.uri().path() so the error body tells the client which path missed. The return type is rocket::serde::json::Value (serde's dynamic JSON value), and json!({ ... }) builds it inline — that's how each catcher emits a JSON body instead of HTML. The status code is already decided by the #[catch(N)] attribute; the function only supplies the body.
⚠️ That 422 catcher is doing more than it looks. When a client
POSTs a body that isn't valid JSON, or is valid JSON but missing a fieldNewBookrequires, theJson<NewBook>data guard fails before your handler is ever called — and Rocket signals that with 422 Unprocessable Entity, not 400. Yourcreatefunction never runs, so it can't shape that response. The 422 catcher is the only place you get to.
Now register them. Catchers go through .register(base, catchers![...]), parallel to how routes go through .mount:
What just happened: .register("/", catchers![...]) attaches the catchers at the / base, meaning they apply to the whole app. The catchers! macro is the catcher counterpart to routes!. Now every framework-level 404, 422, and 500 — whatever route or handler tripped it — comes back as your consistent JSON shape instead of Rocket's HTML. Like routes, catchers can be scoped to a path by registering them under a different base (e.g. .register("/api", ...)), so a sub-tree of the app can have its own error style.
Catchers vs. responder-level errors — use both
These two mechanisms are not competitors; they cover different failures, and a real API wires up both.
- Responder-level errors (
Option,Result) — your handler ran and decided the outcome. "I looked up book 99, it doesn't exist, returnNone." This is a domain decision, and it belongs in the handler because only the handler knows the domain. (Phase 4.) - Catchers (
#[catch(...)]) — the request failed before or outside any handler's decision: no route matched, a data guard rejected a malformed body (→ 422), a request guard rejected the caller, or a handler panicked (→ 500). The handler can't shape these because, for most of them, it never ran.
The clean rule: let handlers express domain outcomes with Option/Result; let catchers express framework failures with #[catch]. When show returns None, Rocket turns it into a 404 — and your #[catch(404)] then renders the body for it. So the two even cooperate: the handler decides "this is a 404," the catcher decides "here's what a 404 looks like." Define the shape once, reuse it everywhere.
Drive it from the terminal
With the server running (cargo run), exercise the whole resource with curl:
# Create a book — expect 201 Created and the new record with an id
# List all — expect 200 and a JSON array
# Show one — expect 200; try a missing id for a 404 with your JSON body
# Update — expect 200 and the updated record
# Delete — expect 204 No Content; deleting again gives 404
# Trip a catcher: a route that doesn't exist, and a malformed body
What just happened: the first six calls walk a single book through its whole lifecycle — created (201), listed and shown (200), the missing-id show returning your catcher's JSON 404, updated (200), deleted (204), then a second delete proving the handler's own Status::NotFound path. The last two trip catchers: /bookz matches no route (framework 404), and the truncated body fails the Json<NewBook> guard before create runs (framework 422). Same JSON error shape for both, courtesy of .register(...).
💡 This API keeps its data in a
Mutex<HashMap>, which means it lives in memory and vanishes on restart. Everything you wrote above — the five handlers, the responders, the catchers — stays the same when you swap that store for a real database. You'd reach forrocket_db_poolswith a driver likesqlx, add a pool to managed state, and make your handlersasync; the route shapes and error handling don't move. The store is the detail; the resource is the design.
Recap
- A REST resource is five attribute-routed handlers over the managed store:
list,show,create,update,delete— each pulling&State<AppState>, pathid, andJsonbodies from its signature. - The return type carries the per-request outcome:
Json<Vec<Book>>andJson<Book>for reads,Option<Json<Book>>for free 404s,(Status::Created, Json<Book>)for a 201, and a bareStatusfor delete's 204/404. - Catchers handle framework-level failures — no route matched, a data guard rejected the body, a guard rejected the caller, a panic. Define them with
#[catch(404)]/#[catch(422)]/#[catch(500)]returning a JSONValue, and.register("/", catchers![...])them. - 422, not 400, is Rocket's signal for a malformed or incomplete JSON body — the
Json<T>data guard fails before your handler runs, so only a#[catch(422)]can shape that response. - Use both layers, by their jobs: handlers express domain outcomes with
Option/Result; catchers express framework failures and standardize the error body. They cooperate — handler decides "this is a 404," catcher decides what a 404 looks like.
Quick check
[
{
"q": "A client POSTs a body that's valid JSON but missing the required \"author\" field. What status does Rocket return, and where do you shape that response?",
"choices": ["400, inside the create handler", "422, with a #[catch(422)] catcher", "500, with a #[catch(500)] catcher", "404, with a #[catch(404)] catcher"],
"answer": 1,
"explain": "The Json<NewBook> data guard fails before create runs, and Rocket signals that with 422 Unprocessable Entity. Because the handler never runs, only a #[catch(422)] catcher can shape the response body."
},
{
"q": "Your show handler returns None for a missing book and also has a #[catch(404)] registered. What does the client receive?",
"choices": ["A 200 with an empty body — None means no error", "A compile error — you can't have both a None and a catcher for 404", "A 404 whose body is rendered by your #[catch(404)] catcher", "Two responses, one from each layer"],
"answer": 2,
"explain": "The handler's None tells Rocket 'this is a 404' (the domain decision); the #[catch(404)] catcher then renders the body for that 404. The two layers cooperate — outcome from the handler, error shape from the catcher."
},
{
"q": "How do you attach catchers to a Rocket app?",
"choices": [".mount(\"/\", catchers![...])", ".register(\"/\", catchers![...])", "Adding them to the routes![...] list", ".manage(catchers![...])"],
"answer": 1,
"explain": "Catchers are registered with .register(base, catchers![...]), parallel to how routes are added with .mount(base, routes![...]). The base path can scope catchers to a sub-tree of the app."
}
]
← Phase 5: Managed State & Fairings · Guide overview · Phase 7: Testing & Configuration →
Check your understanding
1. A client POSTs a body that's valid JSON but missing the required "author" field. What status does Rocket return, and where do you shape that response?
2. Your show handler returns None for a missing book and also has a #[catch(404)] registered. What does the client receive?
3. How do you attach catchers to a Rocket app?