Just added: Algorithms you can run and practice
Updated Jul 10, 2026 Edit on GitHub

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 IntoResponse on your error type; actix has you implement ResponseError. 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 actix_web::{web, App, HttpServer, HttpResponse};
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::sync::Mutex;

#[derive(Clone, Serialize, Deserialize)]
struct Article {
    id: u32,
    title: String,
    body: String,
}

// what the client sends on create/update - no id, the server owns that
#[derive(Deserialize)]
struct ArticleInput {
    title: String,
    body: String,
}

struct AppState {
    articles: Mutex<HashMap<u32, Article>>,
    next_id: Mutex<u32>,
}

// GET /api/v1/articles  → 200 with the list
async fn list(state: web::Data<AppState>) -> Result<HttpResponse, ApiError> {
    let map = state.articles.lock().unwrap();
    let articles: Vec<Article> = map.values().cloned().collect();
    Ok(HttpResponse::Ok().json(articles))
}

// GET /api/v1/articles/{id}  → 200, or 404 if missing
async fn show(
    path: web::Path<u32>,
    state: web::Data<AppState>,
) -> Result<HttpResponse, ApiError> {
    let map = state.articles.lock().unwrap();
    let article = map.get(&path).cloned().ok_or(ApiError::NotFound)?;
    Ok(HttpResponse::Ok().json(article))
}

// POST /api/v1/articles  → 201 with the created article
async fn create(
    body: web::Json<ArticleInput>,
    state: web::Data<AppState>,
) -> Result<HttpResponse, ApiError> {
    let input = body.into_inner();
    if input.title.trim().is_empty() {
        return Err(ApiError::BadRequest("title must not be empty".into()));
    }

    let mut id_guard = state.next_id.lock().unwrap();
    let id = *id_guard;
    *id_guard += 1;

    let article = Article { id, title: input.title, body: input.body };
    state.articles.lock().unwrap().insert(id, article.clone());
    Ok(HttpResponse::Created().json(article))
}

// PUT /api/v1/articles/{id}  → 200, or 404 if missing
async fn update(
    path: web::Path<u32>,
    body: web::Json<ArticleInput>,
    state: web::Data<AppState>,
) -> Result<HttpResponse, ApiError> {
    let id = path.into_inner();
    let input = body.into_inner();
    let mut map = state.articles.lock().unwrap();

    let article = map.get_mut(&id).ok_or(ApiError::NotFound)?;
    article.title = input.title;
    article.body = input.body;
    Ok(HttpResponse::Ok().json(article.clone()))
}

// DELETE /api/v1/articles/{id}  → 204, or 404 if missing
async fn delete(
    path: web::Path<u32>,
    state: web::Data<AppState>,
) -> Result<HttpResponse, ApiError> {
    let mut map = state.articles.lock().unwrap();
    map.remove(&path.into_inner()).ok_or(ApiError::NotFound)?;
    Ok(HttpResponse::NoContent().finish())
}

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:

#[actix_web::main]
async fn main() -> std::io::Result<()> {
    let state = web::Data::new(AppState {
        articles: Mutex::new(HashMap::new()),
        next_id: Mutex::new(1),
    });

    HttpServer::new(move || {
        App::new()
            .app_data(state.clone())
            .service(
                web::scope("/api/v1")
                    .route("/articles", web::get().to(list))
                    .route("/articles", web::post().to(create))
                    .route("/articles/{id}", web::get().to(show))
                    .route("/articles/{id}", web::put().to(update))
                    .route("/articles/{id}", web::delete().to(delete)),
            )
    })
    .bind(("127.0.0.1", 8080))?
    .run()
    .await
}

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 actix_web::{http::StatusCode, HttpResponse, ResponseError};
use std::fmt;

#[derive(Debug)]
enum ApiError {
    NotFound,
    BadRequest(String),
}

impl fmt::Display for ApiError {
    fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
        write!(f, "{:?}", self)
    }
}

impl ResponseError for ApiError {
    fn status_code(&self) -> StatusCode {
        match self {
            ApiError::NotFound => StatusCode::NOT_FOUND,
            ApiError::BadRequest(_) => StatusCode::BAD_REQUEST,
        }
    }

    fn error_response(&self) -> HttpResponse {
        HttpResponse::build(self.status_code())
            .json(serde_json::json!({ "error": self.to_string() }))
    }
}

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.

⚠️ ResponseError requires your type to be Display + Debug. If the compiler complains that your error "doesn't implement ResponseError," check that you actually impl'd Display - 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:

impl From<std::num::ParseIntError> for ApiError {
    fn from(_: std::num::ParseIntError) -> Self {
        ApiError::BadRequest("invalid number".into())
    }
}

async fn show_by_str(
    path: web::Path<String>,
    state: web::Data<AppState>,
) -> Result<HttpResponse, ApiError> {
    let id: u32 = path.parse()?; // ParseIntError → ApiError via From, then ? returns it
    let map = state.articles.lock().unwrap();
    let article = map.get(&id).cloned().ok_or(ApiError::NotFound)?;
    Ok(HttpResponse::Ok().json(article))
}

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 Display and From impls by hand gets tedious as the enum grows. The thiserror crate 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; thiserror just 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 actix_web::web::JsonConfig;

// inside the App builder, alongside .app_data(state.clone()):
.app_data(JsonConfig::default().error_handler(|err, _req| {
    let msg = err.to_string();
    actix_web::error::InternalError::from_response(
        err,
        HttpResponse::BadRequest().json(serde_json::json!({ "error": msg })),
    )
    .into()
}))

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)
curl -s -X POST http://127.0.0.1:8080/api/v1/articles \
  -H 'Content-Type: application/json' \
  -d '{"title":"Hello","body":"first post"}'

# list them → 200 with a JSON array
curl -s http://127.0.0.1:8080/api/v1/articles

# fetch one that doesn't exist → 404 {"error":"NotFound"}
curl -s http://127.0.0.1:8080/api/v1/articles/999

# update it → 200 with the new values
curl -s -X PUT http://127.0.0.1:8080/api/v1/articles/1 \
  -H 'Content-Type: application/json' \
  -d '{"title":"Hello (edited)","body":"updated"}'

# delete it → 204, empty body
curl -s -i -X DELETE http://127.0.0.1:8080/api/v1/articles/1

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 for sqlx queries against the PgPool from Phase 4 and add a From<sqlx::Error> impl so DB failures ? straight into ApiError as 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 through web::Data<AppState> from Phase 4.
  • Handlers return Result<HttpResponse, ApiError> and use ?; a returned Err becomes the HTTP response, so you stop juggling response types across branches.
  • The ResponseError trait (with Display) 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 your ApiError; thiserror generates the Display/From boilerplate once the enum grows.
  • ⚠️ Extractor failures (bad JSON) already produce 400s automatically - customize the response with JsonConfig::error_handler if 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?