Skip to content

Обработка ошибок в Axum

Эффективная обработка ошибок является ключевым аспектом разработки надежных веб-приложений. Axum предоставляет гибкие механизмы для обработки и представления ошибок. В этом разделе рассмотрим общие подходы к обработке ошибок в Axum.

Содержание

Основные концепции

В Axum обработка ошибок основана на нескольких ключевых концепциях:

  1. Возвращаемые значения из обработчиков - тип Result<T, E>, где E реализует IntoResponse
  2. Типаж IntoResponse - позволяет преобразовать ошибки в HTTP-ответы
  3. Middleware для обработки ошибок - перехватывает и обрабатывает ошибки глобально
  4. Механизм отклонений (rejections) - обрабатывает ошибки в экстракторах

Простая обработка ошибок

Базовая обработка ошибок с использованием Result:

rust
use axum::{
    routing::get,
    http::StatusCode,
    response::IntoResponse,
    Json,
    Router,
};
use serde_json::{json, Value};
use std::sync::Arc;

// Простой обработчик с обработкой ошибок
async fn get_user(
    axum::extract::Path(user_id): axum::extract::Path<String>,
) -> Result<Json<Value>, (StatusCode, String)> {
    // Имитация поиска пользователя
    if user_id == "123" {
        let user = json!({
            "id": "123",
            "name": "Иван",
            "email": "ivan@example.com"
        });
        
        Ok(Json(user))
    } else {
        Err((StatusCode::NOT_FOUND, format!("Пользователь с ID {} не найден", user_id)))
    }
}

// Обработчик с более сложным объектом ошибки
async fn create_post(
    Json(payload): Json<Value>,
) -> Result<Json<Value>, (StatusCode, Json<Value>)> {
    // Проверка данных
    if payload.get("title").is_none() {
        let error = json!({
            "error": "Отсутствует обязательное поле",
            "field": "title",
            "code": "FIELD_REQUIRED"
        });
        
        return Err((StatusCode::BAD_REQUEST, Json(error)));
    }
    
    // Имитация создания поста
    let post = json!({
        "id": "456",
        "title": payload["title"],
        "created_at": "2023-01-01T12:00:00Z"
    });
    
    Ok(Json(post))
}

// Настройка маршрутов
let app = Router::new()
    .route("/users/:user_id", get(get_user))
    .route("/posts", axum::routing::post(create_post));

Типажи для обработки ошибок

Использование типажа IntoResponse для создания собственных типов ошибок:

rust
use axum::{
    http::StatusCode,
    response::{IntoResponse, Response},
    Json,
};
use serde::{Deserialize, Serialize};
use thiserror::Error;

// Определение перечисления ошибок приложения
#[derive(Debug, Error)]
enum AppError {
    #[error("Ошибка базы данных: {0}")]
    Database(#[from] sqlx::Error),
    
    #[error("Ошибка валидации: {0}")]
    Validation(String),
    
    #[error("Ресурс не найден: {0}")]
    NotFound(String),
    
    #[error("Неавторизованный доступ: {0}")]
    Unauthorized(String),
    
    #[error("Внутренняя ошибка сервера: {0}")]
    InternalError(String),
}

// Структура для JSON-представления ошибок
#[derive(Serialize)]
struct ErrorResponse {
    status: String,
    message: String,
    #[serde(skip_serializing_if = "Option::is_none")]
    code: Option<String>,
    #[serde(skip_serializing_if = "Option::is_none")]
    details: Option<serde_json::Value>,
}

// Реализация IntoResponse для AppError
impl IntoResponse for AppError {
    fn into_response(self) -> Response {
        let (status, error_response) = match &self {
            AppError::Database(err) => {
                println!("Ошибка базы данных: {:?}", err);
                (
                    StatusCode::INTERNAL_SERVER_ERROR,
                    ErrorResponse {
                        status: "error".to_string(),
                        message: "Ошибка базы данных".to_string(),
                        code: Some("DATABASE_ERROR".to_string()),
                        details: None,
                    },
                )
            },
            AppError::Validation(message) => (
                StatusCode::BAD_REQUEST,
                ErrorResponse {
                    status: "error".to_string(),
                    message: message.clone(),
                    code: Some("VALIDATION_ERROR".to_string()),
                    details: None,
                },
            ),
            AppError::NotFound(resource) => (
                StatusCode::NOT_FOUND,
                ErrorResponse {
                    status: "error".to_string(),
                    message: format!("Ресурс не найден: {}", resource),
                    code: Some("RESOURCE_NOT_FOUND".to_string()),
                    details: None,
                },
            ),
            AppError::Unauthorized(message) => (
                StatusCode::UNAUTHORIZED,
                ErrorResponse {
                    status: "error".to_string(),
                    message: message.clone(),
                    code: Some("UNAUTHORIZED".to_string()),
                    details: None,
                },
            ),
            AppError::InternalError(message) => {
                println!("Внутренняя ошибка: {}", message);
                (
                    StatusCode::INTERNAL_SERVER_ERROR,
                    ErrorResponse {
                        status: "error".to_string(),
                        message: "Внутренняя ошибка сервера".to_string(),
                        code: Some("INTERNAL_SERVER_ERROR".to_string()),
                        details: None,
                    },
                )
            },
        };
        
        (status, Json(error_response)).into_response()
    }
}

// Пример использования в обработчике
async fn get_user_by_id(
    Path(user_id): Path<String>,
    State(db): State<DatabasePool>,
) -> Result<Json<User>, AppError> {
    // Поиск пользователя в базе данных
    let user = sqlx::query_as::<_, User>("SELECT * FROM users WHERE id = $1")
        .bind(user_id.clone())
        .fetch_optional(&db)
        .await
        .map_err(AppError::Database)?;
        
    // Проверка результата
    match user {
        Some(user) => Ok(Json(user)),
        None => Err(AppError::NotFound(format!("Пользователь {}", user_id))),
    }
}

Централизованная обработка ошибок

Для единообразной обработки ошибок можно использовать middleware:

rust
use axum::{
    http::{Request, StatusCode},
    middleware::{self, Next},
    response::{IntoResponse, Response},
    Router,
};
use std::panic::{self, AssertUnwindSafe};
use std::sync::Arc;

// Middleware для обработки паник
async fn handle_panic_middleware<B>(
    request: Request<B>,
    next: Next<B>,
) -> Result<Response, StatusCode> {
    // Оборачиваем вызов следующего обработчика в catch_unwind
    let result = AssertUnwindSafe(next.run(request)).catch_unwind().await;
    
    match result {
        Ok(response) => Ok(response),
        Err(panic_error) => {
            // Логирование паники
            eprintln!("Паника в обработчике: {:?}", panic_error);
            
            // Возвращение ответа 500
            Err(StatusCode::INTERNAL_SERVER_ERROR)
        }
    }
}

// Логгер ошибок
async fn error_logger<B>(
    request: Request<B>,
    next: Next<B>,
) -> Response {
    let path = request.uri().path().to_string();
    let method = request.method().clone();
    
    // Выполнение запроса
    let response = next.run(request).await;
    
    // Проверка статуса ответа
    if response.status().is_client_error() || response.status().is_server_error() {
        eprintln!("Ошибка при обработке запроса {} {}: {}", 
            method, path, response.status());
    }
    
    response
}

// Применение middleware
let app = Router::new()
    // Маршруты приложения
    .route("/users/:id", get(get_user_by_id))
    .route("/posts", post(create_post))
    // Middleware для обработки ошибок
    .layer(middleware::from_fn(error_logger))
    .layer(middleware::from_fn(handle_panic_middleware));

Обработка ошибок различных типов

Обработка ошибок экстракторов и валидации:

rust
use axum::{
    extract::{Path, Query, Json, rejection::*},
    http::StatusCode,
    response::{IntoResponse, Response},
    routing::get,
    Router,
};
use serde::Deserialize;
use validator::Validate;

// Параметры запроса с валидацией
#[derive(Debug, Deserialize, Validate)]
struct UserParams {
    #[validate(length(min = 3, message = "имя должно содержать не менее 3 символов"))]
    name: Option<String>,
    
    #[validate(range(min = 1, max = 100, message = "возраст должен быть от 1 до 100"))]
    age: Option<u8>,
}

// Обработчик с обработкой ошибок валидации
async fn search_users(
    query: Query<UserParams>,
) -> Result<Json<Vec<User>>, AppError> {
    // Валидация параметров
    if let Err(err) = query.validate() {
        return Err(AppError::Validation(format!("Ошибка валидации: {:?}", err)));
    }
    
    // Обработка запроса
    let users = find_users(&query).await?;
    Ok(Json(users))
}

// Обработка ошибок экстрактора JSON
async fn create_user(
    json_result: Result<Json<CreateUser>, JsonRejection>,
) -> Response {
    match json_result {
        Ok(Json(payload)) => {
            // Валидация данных
            if let Err(err) = payload.validate() {
                return AppError::Validation(format!("Ошибка валидации: {:?}", err))
                    .into_response();
            }
            
            // Создание пользователя
            match create_user_in_db(&payload).await {
                Ok(user) => Json(user).into_response(),
                Err(err) => err.into_response(),
            }
        },
        Err(err) => {
            let message = match err {
                JsonRejection::JsonDataError(err) => {
                    format!("Ошибка данных JSON: {}", err)
                },
                JsonRejection::JsonSyntaxError(err) => {
                    format!("Ошибка синтаксиса JSON: {}", err)
                },
                JsonRejection::MissingJsonContentType(err) => {
                    format!("Отсутствует Content-Type: {}", err)
                },
                _ => "Неизвестная ошибка при разборе JSON".to_string(),
            };
            
            AppError::Validation(message).into_response()
        }
    }
}

// Обработка других типов отклонений
async fn handle_rejection(rejection: BoxRejection) -> Response {
    if let Some(err) = rejection.downcast_ref::<AxumQueryRejection>() {
        return AppError::Validation(format!("Ошибка в параметрах запроса: {}", err))
            .into_response();
    }
    
    if let Some(err) = rejection.downcast_ref::<AxumPathRejection>() {
        return AppError::Validation(format!("Ошибка в параметрах пути: {}", err))
            .into_response();
    }
    
    // Обработка других типов отклонений
    
    // Если тип отклонения неизвестен
    (
        StatusCode::INTERNAL_SERVER_ERROR,
        "Произошла внутренняя ошибка сервера".to_string(),
    )
        .into_response()
}

// Настройка маршрутов
let app = Router::new()
    .route("/users", get(search_users).post(create_user))
    // Обработчик отклонений по умолчанию
    .fallback(handle_rejection);

Логирование ошибок

Интеграция с системами логирования для отслеживания ошибок:

rust
use axum::{
    http::StatusCode,
    response::{IntoResponse, Response},
    routing::get,
    Router,
};
use serde_json::json;
use tracing::{error, info, instrument, warn};

// Функция для логирования ошибок
fn log_error(err: &AppError, request_id: &str) {
    match err {
        AppError::Database(db_err) => {
            error!(
                request_id = %request_id,
                error = %db_err,
                "Ошибка базы данных"
            );
        },
        AppError::Validation(message) => {
            warn!(
                request_id = %request_id,
                message = %message,
                "Ошибка валидации"
            );
        },
        AppError::NotFound(resource) => {
            info!(
                request_id = %request_id,
                resource = %resource,
                "Ресурс не найден"
            );
        },
        AppError::Unauthorized(message) => {
            warn!(
                request_id = %request_id,
                message = %message,
                "Неавторизованный доступ"
            );
        },
        AppError::InternalError(message) => {
            error!(
                request_id = %request_id,
                message = %message,
                "Внутренняя ошибка сервера"
            );
        },
    }
}

// Обновленная реализация IntoResponse для AppError с логированием
impl IntoResponse for AppError {
    fn into_response(self) -> Response {
        // Генерация ID запроса
        let request_id = uuid::Uuid::new_v4().to_string();
        
        // Логирование ошибки
        log_error(&self, &request_id);
        
        // Преобразование ошибки в ответ
        let (status, error_message) = match &self {
            // ... (как в предыдущем примере)
        };
        
        // Добавление ID запроса в ответ
        let body = json!({
            "status": "error",
            "message": error_message,
            "request_id": request_id,
        });
        
        (status, Json(body)).into_response()
    }
}

// Обработчик с трассировкой
#[instrument(skip(db))]
async fn get_user(
    Path(user_id): Path<String>,
    State(db): State<DatabasePool>,
) -> Result<Json<User>, AppError> {
    info!("Получение пользователя с ID: {}", user_id);
    
    let user = db.get_user(&user_id).await
        .map_err(|e| {
            error!("Ошибка при получении пользователя: {:?}", e);
            AppError::Database(e)
        })?;
        
    Ok(Json(user))
}

Лучшие практики

  1. Разделение типов ошибок

    • Создавайте разные типы ошибок для разных частей приложения
    • Используйте перечисления для группировки связанных ошибок
    rust
    // Ошибки базы данных
    enum DatabaseError {
        ConnectionFailed(String),
        QueryFailed(String),
        TransactionFailed(String),
    }
    
    // Ошибки бизнес-логики
    enum DomainError {
        InvalidOperation(String),
        ResourceNotFound(String),
        BusinessRuleViolation(String),
    }
  2. Информативные сообщения об ошибках

    • Предоставляйте понятные сообщения для пользователей
    • Включайте технические детали только в логи, не в ответы
    rust
    match db_error {
        DbError::ConnectionLost => {
            error!("Потеряно соединение с базой данных: {:?}", db_error);
            AppError::InternalError("Проблема с сервисом. Повторите попытку позже.".to_string())
        }
    }
  3. Контекстные ошибки

    • Добавляйте контекст к ошибкам при их прохождении через слои приложения
    • Используйте библиотеки вроде anyhow или eyre для обогащения ошибок
    rust
    async fn process_payment(
        payment: Payment,
        db: &Database,
    ) -> Result<(), anyhow::Error> {
        db.begin_transaction()
            .await
            .context("Не удалось начать транзакцию")?;
            
        db.update_balance(payment.account_id, payment.amount)
            .await
            .context(format!("Не удалось обновить баланс для аккаунта {}", payment.account_id))?;
            
        db.commit_transaction()
            .await
            .context("Не удалось завершить транзакцию")?;
            
        Ok(())
    }
  4. Безопасность ошибок

    • Не раскрывайте внутренние детали в ошибках
    • Скрывайте специфические сообщения от пользователей
    rust
    // Плохо (раскрывает внутренние данные)
    Err(format!("Ошибка подключения к БД: {}: {}", db_host, db_error))
    
    // Хорошо
    log_error!("Ошибка подключения к БД: {}: {}", db_host, db_error);
    Err(AppError::ServiceUnavailable("Сервис временно недоступен".to_string()))
  5. Единообразная структура ответов

    • Используйте последовательный формат для успешных и ошибочных ответов
    • Стандартизируйте коды ошибок и сообщения
    rust
    // Общая структура ответа
    #[derive(Serialize)]
    struct ApiResponse<T> {
        status: String,  // "success" или "error"
        #[serde(skip_serializing_if = "Option::is_none")]
        data: Option<T>,
        #[serde(skip_serializing_if = "Option::is_none")]
        error: Option<ErrorDetails>,
    }
    
    #[derive(Serialize)]
    struct ErrorDetails {
        code: String,
        message: String,
        #[serde(skip_serializing_if = "Option::is_none")]
        details: Option<serde_json::Value>,
    }
    
    // Функция для создания успешного ответа
    fn success<T>(data: T) -> Json<ApiResponse<T>> {
        Json(ApiResponse {
            status: "success".to_string(),
            data: Some(data),
            error: None,
        })
    }
    
    // Функция для создания ответа с ошибкой
    fn error(code: &str, message: &str) -> Json<ApiResponse<()>> {
        Json(ApiResponse {
            status: "error".to_string(),
            data: None,
            error: Some(ErrorDetails {
                code: code.to_string(),
                message: message.to_string(),
                details: None,
            }),
        })
    }

Эффективная обработка ошибок делает приложение более надежным и удобным как для пользователей, так и для разработчиков. Axum предоставляет гибкие инструменты для создания системы обработки ошибок, которая может быть адаптирована под конкретные потребности вашего приложения.