# Глава 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. Задавайте вопросы и общайтесь.
Задонатить. Поддержите развитие площадки.