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
tqcode for this chapter is in8-ready/03-graceful-refusal/.