# Глава 1.7. Работа с ошибками
В реальном приложении хендлер может завершиться неудачей по разным причинам: запрошенного пользователя нет, клиент прислал некорректные данные, база данных не отвечает. Клиент в каждом случае должен получить понятный ответ с подходящим HTTP-статусом, а разработчик — подробности в логах. В этой главе разберём, как правильно обрабатывать ошибки, а не писать на каждый случай `unwrap()`.
## Почему нельзя использовать `unwrap` в хендлерах
Методы `unwrap` и `expect` вызывают панику, если в `Option` оказалось `None` или в `Result` — `Err`. Паника в хендлере не останавливает сервер: Tokio перехватывает её, и остальные запросы продолжают обрабатываться. Но клиент, чей запрос вызвал панику, вообще не получит ответа: соединение просто закроется. Клиент не может отличить такую ситуацию от сетевого сбоя и не узнаёт, что пошло не так. В логе сервера останется только сообщение о панике.
Поэтому код, который выполняется после запуска сервера, должен обрабатывать ошибки, а не просто паниковать.
## Когда `unwrap` допустим
Другое дело — код, который выполняется при старте программы: чтение настроек, занятие порта, подключение к базе данных. Если что-то из этого не удалось, сервер всё равно не сможет работать. Лучше сразу остановить программу с понятным сообщением, чем запустить сервер, который будет отвечать ошибкой на каждый запрос.
При старте вместо `unwrap` лучше использовать `expect`: он тоже вызывает панику, но с вашим сообщением, из которого сразу понятно, что пошло не так:
```rust
let port: u16 = std::env::var("PORT")
.unwrap_or_else(|_| "3000".to_string())
.parse()
.expect("PORT должен быть числом от 0 до 65535");
```
Если запустить программу с `PORT=abc`, она сразу завершится:
```text
thread 'main' panicked at src/main.rs:97:10:
PORT должен быть числом от 0 до 65535:
ParseIntError { kind: InvalidDigit }
```
## `anyhow`: любая ошибка с контекстом
Разные библиотеки возвращают разные типы ошибок: `std::io::Error`, `serde_json::Error`, ошибки базы данных. Если функция вызывает несколько таких библиотек, ей нужен тип ошибки, в который помещается любая из них. Такой тип предоставляет крейт `anyhow`:
```bash
cargo add anyhow
```
Тип `anyhow::Error` может хранить любую ошибку, реализующую трейт `std::error::Error`, поэтому оператор `?` преобразует в него ошибку любой библиотеки. Метод `context` добавляет к ошибке описание того, что программа пыталась сделать:
```rust
use anyhow::Context;
use tokio::net::TcpListener;
#[tokio::main]
async fn main() -> anyhow::Result<()> {
let listener = TcpListener::bind("127.0.0.1:3000")
.await
.context("не удалось занять порт 3000")?;
// ...
Ok(())
}
```
Если порт занят, программа выведет и описание, и исходную причину:
```text
Error: не удалось занять порт 3000
Caused by:
Address already in use (os error 98)
```
`anyhow` удобен, когда вызывающему коду достаточно знать, что операция не удалась и почему. Но по `anyhow::Error` неудобно понять, что именно произошло: по одному этому типу хендлер не отличит ошибку «пользователь не найден» от ошибки «база данных недоступна». Для таких случаев нужен собственный тип ошибки.
## `thiserror`: ошибки, которые нужно различать
Ошибки, на которые приложение должно реагировать по-разному, удобно описывать перечислением. Крейт `thiserror` избавляет от шаблонного кода, который для этого требуется:
```bash
cargo add thiserror
```
```rust
#[derive(Debug, thiserror::Error)]
enum AppError {
#[error("пользователь не найден")]
NotFound,
#[error("некорректные данные: {0}")]
Validation(String),
#[error(transparent)]
Internal(#[from] anyhow::Error),
}
```
Макрос `thiserror::Error` генерирует реализацию трейтов `std::fmt::Display` и `std::error::Error`. Атрибут `#[error(...)]` задаёт текст ошибки, а `{0}` подставляет в него значение поля. Атрибут `#[from]` генерирует преобразование `From<anyhow::Error>`, поэтому оператор `?` сам превратит `anyhow::Error` в `AppError::Internal`. Атрибут `transparent` означает, что у варианта нет своего текста и выводится текст вложенной ошибки.
Так `anyhow` и `thiserror` работают вместе: ошибки, которые клиент должен различать, получают отдельные варианты перечисления, а всё непредвиденное попадает в вариант `Internal`.
## Превращение ошибки в HTTP-ответ
Чтобы хендлер мог вернуть `AppError`, для этого типа нужно реализовать трейт `IntoResponse`. Реализация выбирает HTTP-статус по варианту ошибки и формирует тело ответа в формате JSON:
```rust
use axum::{
http::StatusCode,
response::{IntoResponse, Response},
Json,
};
use serde_json::json;
impl IntoResponse for AppError {
fn into_response(self) -> Response {
let status = match &self {
AppError::NotFound => StatusCode::NOT_FOUND,
AppError::Validation(_) => StatusCode::BAD_REQUEST,
AppError::Internal(_) => {
StatusCode::INTERNAL_SERVER_ERROR
}
};
let message = match &self {
AppError::Internal(err) => {
eprintln!("внутренняя ошибка: {err:#}");
"внутренняя ошибка сервера".to_string()
}
other => other.to_string(),
};
let body = Json(json!({ "error": message }));
(status, body).into_response()
}
}
```
Обратите внимание на вариант `Internal`. Его подробности записываются в лог сервера, а клиент получает общее сообщение. Текст внутренней ошибки может раскрыть устройство сервера, например пути к файлам или структуру базы данных, поэтому показывать его клиенту небезопасно.
Теперь хендлеры возвращают `Result<_, AppError>`, а оператор `?` сам приводит ошибки к нужному варианту:
```rust
use anyhow::anyhow;
use axum::extract::{Path, State};
async fn get_user(
State(state): State<AppState>,
Path(id): Path<usize>,
) -> Result<Json<String>, AppError> {
let users = state
.users
.lock()
.map_err(|_| anyhow!("мьютекс отравлен"))?;
let user = users.get(id).ok_or(AppError::NotFound)?;
Ok(Json(user.clone()))
}
async fn create_user(
State(state): State<AppState>,
Json(user): Json<CreateUser>,
) -> Result<StatusCode, AppError> {
if user.name.trim().is_empty() {
let reason = "имя не может быть пустым".to_string();
return Err(AppError::Validation(reason));
}
let mut users = state
.users
.lock()
.map_err(|_| anyhow!("мьютекс отравлен"))?;
users.push(user.name);
Ok(StatusCode::CREATED)
}
```
Макрос `anyhow!` создаёт `anyhow::Error` из строки. Он нужен, потому что ошибку отравленного мьютекса нельзя сохранить в `anyhow::Error` напрямую: она содержит защитный объект мьютекса, который нельзя передавать между потоками.
Проверим:
```bash
$ curl -i http://127.0.0.1:3000/users/0
HTTP/1.1 404 Not Found
{"error":"пользователь не найден"}
$ curl -i -H 'content-type: application/json' \
-d '{"name":" "}' http://127.0.0.1:3000/users
HTTP/1.1 400 Bad Request
{"error":"некорректные данные: имя не может быть пустым"}
```
Хендлеры описывают только успешный путь, а преобразование ошибок в ответы собрано в одном месте. Если позже понадобится изменить формат ошибок, например добавить в ответ код ошибки, менять придётся только реализацию `IntoResponse`.
## Когда что использовать
- `unwrap` и `expect` — только при старте программы, когда без результата сервер всё равно не сможет работать. Предпочтительнее `expect` с понятным сообщением.
- `anyhow` — когда вызывающему коду достаточно знать, что операция не удалась и почему: в `main`, при старте и для непредвиденных ошибок внутри приложения.
- `thiserror` — когда вызывающий код должен по-разному реагировать на разные ошибки, например отвечать клиенту разными HTTP-статусами.
Теперь хендлер корректно завершается в любом случае: при успехе он возвращает данные, а при ошибке клиент получает ответ с подходящим статусом и понятным сообщением вместо оборванного соединения. Во втором модуле в `AppError` добавятся ошибки базы данных, а хендлеры останутся такими же простыми.
В следующей главе займёмся настройкой приложения: вынесем параметры в переменные окружения, разрешим запросы из браузерного фронтенда через CORS и разберём, как понять, почему хендлер не компилируется.
Наша группа в telegram. Задавайте вопросы и общайтесь.
Задонатить. Поддержите развитие площадки.