← Оглавление

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/.