← Table of contents

8.3 Graceful Refusal

A craftsman asked a question without an answer can say “I don’t know” - or faint. The first is useful. The second frightens those around him and explains nothing. The server currently chooses the second: a request for a task that does not exist ends in panic, a dropped connection, and silence. The client waits. No answer will come.

What You Need

get_task and done receive an id from the URL. If no task with that number exists - .unwrap() panics. The client gets a dropped connection: no code, no explanation.

HTTP has a word for “no”. For a missing resource - 404. For a bad request (an empty title in add) - 400. Axum will return the right code if the response type implements IntoResponse. Result<Json<Task>, ApiError> axum understands: Ok - the client receives the task, Err - whatever ApiError decides to return.

The Build

In crates/api/src/routes.rs, new imports and an error type:

use axum::http::StatusCode;
use axum::response::{IntoResponse, Response};

enum ApiError {
    NotFound,
    BadRequest(String),
}

impl IntoResponse for ApiError {
    fn into_response(self) -> Response {
        match self {
            ApiError::NotFound => (StatusCode::NOT_FOUND, "not found").into_response(),
            ApiError::BadRequest(msg) => (StatusCode::BAD_REQUEST, msg).into_response(),
        }
    }
}

IntoResponse is an axum trait: anything that implements it can be returned from a handler. The tuple (StatusCode, &str) already implements it; ApiError simply delegates to it through match.

Three handlers are updated - add, get_task, and done:

async fn add(State(state): State<AppState>, Json(req): Json<AddRequest>) -> Result<Json<Task>, ApiError> {
    let mut guard = state.store.lock().unwrap();
    let id = guard.add(req.title.as_str()).map_err(|e| ApiError::BadRequest(e.to_string()))?;
    persistence::save(guard.all(), &state.data_path).unwrap();
    Ok(Json(guard.get(id).unwrap().clone()))
}

async fn get_task(State(state): State<AppState>, Path(id): Path<u64>) -> Result<Json<Task>, ApiError> {
    let guard = state.store.lock().unwrap();
    let task = guard.get(id).map_err(|_| ApiError::NotFound)?;
    Ok(Json(task.clone()))
}

async fn done(State(state): State<AppState>, Path(id): Path<u64>) -> Result<Json<Task>, ApiError> {
    let mut guard = state.store.lock().unwrap();
    let task = guard.get_mut(id).map_err(|_| ApiError::NotFound)?;
    task.complete();
    let task = task.clone();
    persistence::save(guard.all(), &state.data_path).unwrap();
    Ok(Json(task))
}

.map_err(|_| ApiError::NotFound) converts a TqError into an ApiError. ? returns the error early - the same ? from chapter 2.3, now inside an async handler. guard.get(id).unwrap() in add after a successful .add() will not panic: the task was just created.

For guard.add() in add to be able to return an error, the empty-title validation moves into TaskStore::add in crates/core/src/store/mod.rs - where it belongs:

fn add(&mut self, item: impl Into<Task>) -> TqResult<u64> {
    let mut task = item.into();
    if task.title.trim().is_empty() {
        return Err(TqError::EmptyTitle);
    }
    let id = self.next_id;
    // ...
}

Now neither CLI nor API can create a task with an empty title. The CLI simplifies as a result; the changes are in the example code for this chapter. list and router are unchanged.

The Result

$ make serve

In another terminal:

$ curl -s -w "\ncode: %{http_code}\n" localhost:3000/tasks/99
not found
code: 404

$ curl -s -w "\ncode: %{http_code}\n" -X POST localhost:3000/tasks -H "Content-Type: application/json" -d '{"title":""}'
task title cannot be empty
code: 400

$ curl -s -w "\ncode: %{http_code}\n" localhost:3000/tasks/1
{"id":1,"title":"buy milk","status":"Todo","created_at":"2026-06-20T10:00:00Z"}
code: 200

The server says “no” - and explains why. The client gets a code and a body, not silence.

The complete tq code for this chapter is in 8-ready/03-graceful-refusal/.