RustArena

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:

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

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