8.3 Вежливый отказ
Мастер, которому задали вопрос без ответа, может сказать “не знаю” - или упасть в обморок. Первое полезно. Второе пугает окружающих и ничего не объясняет. Сервер пока выбирает второе: запрос несуществующей задачи кончается паникой, обрывом соединения и тишиной. Клиент ждёт. Ответа не будет.
Что нужно
get_task и done получают идентификатор из URL. Если задачи с таким номером нет -
.unwrap() паникует. Клиент получает обрыв соединения: ни кода, ни объяснения.
HTTP знает слово “нет”. Для отсутствующего ресурса - 404. Для некорректного запроса
(пустое название в add) - 400. Axum вернёт нужный код если тип ответа реализует
IntoResponse. Result<Json<Task>, ApiError> axum понимает: Ok - клиент получает задачу,
Err - то, что ApiError решит вернуть.
Постройка
В crates/api/src/routes.rs добавляем новые импорты и тип ошибки:
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 - трейт axum: всё, что его реализует, можно вернуть из обработчика. Кортеж
(StatusCode, &str) его уже реализует; ApiError просто делегирует ему через match.
Обновляем три обработчика - add, get_task и 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) превращает TqError в ApiError. ? возвращает ошибку
досрочно - это тот же ? из M2.3, только теперь в async-обработчике. guard.get(id).unwrap()
в add после успешного .add() не упадёт: задача только что создана.
Чтобы guard.add() в add мог вернуть ошибку, валидацию пустого названия добавляем в
TaskStore::add в crates/core/src/store/mod.rs - туда, где этому место:
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;
// ...
}
Теперь ни CLI, ни API не смогут создать задачу с пустым названием.
CLI при этом упрощается, результат есть в коде для главы.
list и router не меняются.
Результат
$ make serve
В другом терминале:
$ 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
Сервер говорит “нет” - и объясняет почему. Клиент получает код и тело, а не тишину.
Полный код
tqдля этой главы -8-ready/03-graceful-refusal/.