Step 4 of 7
Errors become status codes
Handlers fail in a handful of ways: not found, bad input, conflict, a bug. Writing return Response { status: 404, body: json!(...)... } in every handler gets repetitive and inconsistent. The pattern every Rust web codebase converges on: handlers return Result<Response, ApiError>, and ApiError knows how to turn itself into a response. Then ? works inside handlers, just like in the error modules.
use serde::Deserialize;
use serde_json::json;
use std::collections::BTreeMap;
use thiserror::Error;
struct Response {
status: u16,
body: String,
}
#[derive(Debug, Error)]
enum ApiError {
#[error("{0} not found")]
NotFound(String),
#[error("invalid JSON: {0}")]
BadJson(String),
#[error("{0}")]
Conflict(String),
}
impl From<serde_json::Error> for ApiError {
fn from(e: serde_json::Error) -> Self {
ApiError::BadJson(e.to_string())
}
}
impl ApiError {
fn status(&self) -> u16 {
match self {
ApiError::NotFound(_) => 404,
ApiError::BadJson(_) => 400,
ApiError::Conflict(_) => 409,
}
}
fn into_response(self) -> Response {
Response { status: self.status(), body: json!({ "error": self.to_string() }).to_string() }
}
}
#[derive(Deserialize)]
struct NewScore {
player: String,
points: u32,
}
fn add_score(scores: &mut BTreeMap<String, u32>, body: &str) -> Result<Response, ApiError> {
let input: NewScore = serde_json::from_str(body)?;
if scores.contains_key(&input.player) {
return Err(ApiError::Conflict(format!("{} already has a score", input.player)));
}
scores.insert(input.player.clone(), input.points);
Ok(Response { status: 201, body: json!({ "player": input.player, "points": input.points }).to_string() })
}
fn get_score(scores: &BTreeMap<String, u32>, player: &str) -> Result<Response, ApiError> {
let points = scores.get(player).ok_or_else(|| ApiError::NotFound(format!("player {player}")))?;
Ok(Response { status: 200, body: json!({ "player": player, "points": points }).to_string() })
}
fn respond(result: Result<Response, ApiError>) -> Response {
result.unwrap_or_else(ApiError::into_response)
}
fn main() {
let mut scores = BTreeMap::new();
let responses = [
respond(add_score(&mut scores, r#"{"player": "ana", "points": 40}"#)),
respond(add_score(&mut scores, r#"{"player": "ana", "points": 99}"#)),
respond(add_score(&mut scores, r#"{"player": "bo"}"#)),
respond(get_score(&scores, "ana")),
respond(get_score(&scores, "cy")),
];
for r in responses {
println!("{} {}", r.status, r.body);
}
}
201 {"player":"ana","points":40}
409 {"error":"ana already has a score"}
400 {"error":"invalid JSON: missing field `points` at line 1 column 16"}
200 {"player":"ana","points":40}
404 {"error":"player cy not found"}
Piece by piece:
ApiErroris an ordinary thiserror enum: itsDisplaymessages become theerrorfield of the JSON.status()maps each variant to its status code in one place. Add a variant and the compiler makes you pick its code (thematchmust be exhaustive).impl From<serde_json::Error> for ApiErrormakes?work onserde_json::from_str. (It's written by hand here, storing the message, so the message isn't repeated in a source chain;#[from]would work too.)- Handlers now read like the happy path, with
?andok_or_elsefor the failures.respondturns theResultinto aResponseat the boundary:unwrap_or_else(ApiError::into_response)passes the method as the closure. - Notice
json!printed{"player":...,"points":...}in sorted key order, as you saw in the serde module.
500s: log the details, hide them from the client
When something breaks on the server (the database is down, a file is missing), the client gets 500 and a generic message. The details go to your logs. Error messages can contain file paths, SQL, or internal hostnames, and attackers read error responses carefully. A typical variant: Internal(String), whose response body is always {"error":"internal server error"}, while the String is written to the log.
Common mistakes
- Status codes scattered across handlers: two handlers answer "not found" with different codes or shapes. Keep the mapping in
ApiError. - Returning 200 with
{"error": ...}: clients, proxies, monitoring and retry logic all look at the status code first. - A catch-all
_ => 500instatus(): new variants silently become server errors. List every variant.
Your task
Build the error type and handler for a bank API's withdraw endpoint. Complete ApiError with thiserror messages: NotFound(id): account 7 not found (404); InsufficientFunds { balance, amount }: insufficient funds: balance 50, need 80 (409); BadRequest(msg): the message itself (400). Add From<serde_json::Error> producing BadRequest("invalid JSON"), write status and into_response (body {"error":"<message>"}), and withdraw: parse {"amount": 80} with ?, reject an amount of 0 with BadRequest("amount must be positive"), find the account, check the balance, subtract, and answer 200 with {"balance":<new balance>,"id":<id>}.
Challenge 2
Hide server errors. ApiError has NotFound(String) (404) and Internal(String) (500). Write into_response: for NotFound, the body is {"error":"<message>"}; for Internal, log the details with eprintln!("internal error: {detail}") and send only {"error":"internal server error"}. Then write fn respond(result: Result<Response, ApiError>) -> Response.
Challenge 3
Validation errors should list every problem at once, so a form can highlight all bad fields. Write fn validate_signup(s: &Signup) -> Result<(), ApiError> checking: name not empty after trimming ("required"), email contains @ ("must contain @"), age at least 13 ("must be at least 13"). If any check fails, return ApiError::Validation(map) with one entry per bad field. Then write into_response: Validation gives 422 and {"error":"validation failed","fields":{...}}.
Previous: JSON in, JSON out Next: Shared state: an in-memory store