Skip to content

Кастомные ошибки в Axum

Создание собственных типов ошибок позволяет повысить читаемость и поддерживаемость кода. В Axum легко интегрировать кастомные ошибки благодаря типажу IntoResponse. В этом разделе рассмотрим подходы к созданию и использованию собственных типов ошибок.

Содержание

Основной подход

Для создания кастомных ошибок в Axum нужно:

  1. Определить собственный тип ошибки
  2. Реализовать для него типаж IntoResponse
  3. Использовать его в обработчиках

Создание кастомного типа ошибок

rust
use axum::{
    http::StatusCode,
    response::{IntoResponse, Response},
    Json,
};
use serde_json::json;

// Определение перечисления для типов ошибок
#[derive(Debug)]
enum AppError {
    // Ошибки базы данных
    DatabaseError(String),
    
    // Ошибки при обращении к внешним API
    ApiError { status: u16, message: String },
    
    // Ошибки валидации
    ValidationError(Vec<String>),
    
    // Ошибки авторизации
    AuthorizationError(String),
    
    // Ошибки, связанные с отсутствующими ресурсами
    NotFound(String),
}

// Реализация преобразования в HTTP ответ
impl IntoResponse for AppError {
    fn into_response(self) -> Response {
        let (status, error_message, error_details) = match self {
            // Ошибка БД - 500 Internal Server Error
            AppError::DatabaseError(message) => {
                // Детали скрываем от клиента, но логируем
                eprintln!("Ошибка базы данных: {}", message);
                (
                    StatusCode::INTERNAL_SERVER_ERROR,
                    "Ошибка при работе с базой данных".to_string(),
                    None,
                )
            },
            
            // Ошибка внешнего API
            AppError::ApiError { status, message } => {
                let status_code = StatusCode::from_u16(status)
                    .unwrap_or(StatusCode::INTERNAL_SERVER_ERROR);
                
                (
                    status_code,
                    format!("Ошибка внешнего API: {}", message),
                    Some(json!({ "api_error": true })),
                )
            },
            
            // Ошибка валидации - 400 Bad Request
            AppError::ValidationError(errors) => (
                StatusCode::BAD_REQUEST,
                "Ошибка валидации".to_string(),
                Some(json!({ "validation_errors": errors })),
            ),
            
            // Ошибка авторизации - 403 Forbidden
            AppError::AuthorizationError(message) => (
                StatusCode::FORBIDDEN,
                message,
                None,
            ),
            
            // Ресурс не найден - 404 Not Found
            AppError::NotFound(resource) => (
                StatusCode::NOT_FOUND,
                format!("Ресурс не найден: {}", resource),
                None,
            ),
        };
        
        // Создаем структуру ответа в формате JSON
        let body = match error_details {
            Some(details) => json!({
                "error": {
                    "message": error_message,
                    "details": details,
                }
            }),
            None => json!({
                "error": {
                    "message": error_message,
                }
            }),
        };
        
        // Преобразуем в ответ с нужным кодом состояния
        (status, Json(body)).into_response()
    }
}

Интеграция с библиотеками thiserror и anyhow

Для более удобной работы с ошибками, можно интегрировать библиотеки thiserror и anyhow:

rust
use axum::{
    http::StatusCode,
    response::{IntoResponse, Response},
    Json,
};
use serde_json::json;
use thiserror::Error;

// Определение ошибок с помощью thiserror
#[derive(Debug, Error)]
enum AppError {
    // Ошибки БД - автоматическое создание реализации Display
    #[error("Ошибка базы данных: {0}")]
    Database(#[from] sqlx::Error),
    
    // Ошибки валидации
    #[error("Ошибка валидации")]
    Validation(Vec<String>),
    
    // Ошибки аутентификации
    #[error("Ошибка аутентификации: {0}")]
    Authentication(String),
    
    // Ошибки прав доступа
    #[error("Недостаточно прав: {0}")]
    Authorization(String),
    
    // Отсутствующие ресурсы
    #[error("Ресурс не найден: {0}")]
    NotFound(String),
    
    // Прочие ошибки
    #[error("Внутренняя ошибка сервера: {0}")]
    InternalError(String),
}

// Реализация преобразования в HTTP ответ
impl IntoResponse for AppError {
    fn into_response(self) -> Response {
        let (status, error_json) = match &self {
            AppError::Database(err) => {
                // Логирование подробностей ошибки
                eprintln!("Database error: {:?}", err);
                
                // Возвращаем клиенту только общую информацию
                (
                    StatusCode::INTERNAL_SERVER_ERROR,
                    json!({
                        "error": {
                            "message": "Внутренняя ошибка сервера",
                            "code": "DATABASE_ERROR"
                        }
                    }),
                )
            },
            AppError::Validation(errors) => (
                StatusCode::BAD_REQUEST,
                json!({
                    "error": {
                        "message": "Ошибка валидации",
                        "code": "VALIDATION_ERROR",
                        "details": errors
                    }
                }),
            ),
            AppError::Authentication(msg) => (
                StatusCode::UNAUTHORIZED,
                json!({
                    "error": {
                        "message": msg,
                        "code": "AUTHENTICATION_ERROR"
                    }
                }),
            ),
            AppError::Authorization(msg) => (
                StatusCode::FORBIDDEN,
                json!({
                    "error": {
                        "message": msg,
                        "code": "AUTHORIZATION_ERROR"
                    }
                }),
            ),
            AppError::NotFound(resource) => (
                StatusCode::NOT_FOUND,
                json!({
                    "error": {
                        "message": format!("Ресурс не найден: {}", resource),
                        "code": "RESOURCE_NOT_FOUND"
                    }
                }),
            ),
            AppError::InternalError(msg) => {
                // Логирование ошибки
                eprintln!("Internal error: {}", msg);
                
                (
                    StatusCode::INTERNAL_SERVER_ERROR,
                    json!({
                        "error": {
                            "message": "Внутренняя ошибка сервера",
                            "code": "INTERNAL_ERROR"
                        }
                    }),
                )
            },
        };
        
        (status, Json(error_json)).into_response()
    }
}

// Использование anyhow для контекстных ошибок в бизнес-логике
use anyhow::{Context, Result as AnyhowResult};

// Пример функции, использующей anyhow для добавления контекста к ошибкам
async fn fetch_user_data(user_id: &str, db: &DatabasePool) -> AnyhowResult<User> {
    db.get_user(user_id)
        .await
        .context(format!("Не удалось получить пользователя с ID {}", user_id))?;
    
    // Остальная логика...
    Ok(User::default())
}

// Функция для преобразования anyhow::Error в AppError
fn map_anyhow_error(err: anyhow::Error) -> AppError {
    if let Some(db_err) = err.downcast_ref::<sqlx::Error>() {
        return AppError::Database(db_err.clone());
    }
    
    // Обработка других известных типов ошибок
    // ...
    
    // Для прочих ошибок
    AppError::InternalError(err.to_string())
}

// Использование в обработчике
async fn get_user(
    Path(user_id): Path<String>,
    State(db): State<DatabasePool>,
) -> Result<Json<User>, AppError> {
    let user = fetch_user_data(&user_id, &db)
        .await
        .map_err(map_anyhow_error)?;
    
    Ok(Json(user))
}

Преобразование ошибок

Для удобного преобразования из одного типа ошибок в другой можно использовать трейты From и TryFrom:

rust
// Пример: преобразование ошибок валидации в AppError
use validator::ValidationErrors;

impl From<ValidationErrors> for AppError {
    fn from(errors: ValidationErrors) -> Self {
        let error_messages = errors
            .field_errors()
            .iter()
            .flat_map(|(field, errors)| {
                errors.iter().map(|error| {
                    let message = error.message
                        .as_ref()
                        .map(|m| m.to_string())
                        .unwrap_or_else(|| format!("Ошибка в поле {}", field));
                    
                    format!("{}: {}", field, message)
                })
            })
            .collect();
        
        AppError::Validation(error_messages)
    }
}

// Пример: преобразование ошибок базы данных
impl From<sqlx::Error> for AppError {
    fn from(err: sqlx::Error) -> Self {
        match err {
            sqlx::Error::RowNotFound => AppError::NotFound("Запись в базе данных".to_string()),
            _ => AppError::Database(err.to_string()),
        }
    }
}

// Использование в обработчике
async fn create_user(
    State(db): State<DatabasePool>,
    Json(payload): Json<CreateUser>,
) -> Result<Json<User>, AppError> {
    // Валидация
    payload.validate().map_err(AppError::from)?;
    
    // Сохранение в БД с автоматическим преобразованием ошибок
    let user = db.create_user(&payload).await?;
    
    Ok(Json(user))
}

Паттерны работы с ошибками

Паттерн "Результат с вложенной ошибкой"

rust
// Типизированный результат с кастомной ошибкой
type Result<T> = std::result::Result<T, AppError>;

// Бизнес-функции возвращают Result
async fn process_payment(payment: Payment) -> Result<PaymentStatus> {
    // Логика с возвращением Result
}

// Обработчик просто проксирует Result
async fn handle_payment(
    Json(payment): Json<Payment>,
) -> Result<Json<PaymentStatus>> {
    let status = process_payment(payment).await?;
    Ok(Json(status))
}

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

rust
// Фабрика ошибок
struct ErrorFactory;

impl ErrorFactory {
    fn validation(message: &str) -> AppError {
        AppError::Validation(vec![message.to_string()])
    }
    
    fn not_found(resource: &str) -> AppError {
        AppError::NotFound(resource.to_string())
    }
    
    fn authentication() -> AppError {
        AppError::Authentication("Требуется аутентификация".to_string())
    }
    
    fn authorization(reason: &str) -> AppError {
        AppError::Authorization(reason.to_string())
    }
    
    fn internal(message: &str) -> AppError {
        eprintln!("Внутренняя ошибка: {}", message);
        AppError::InternalError(message.to_string())
    }
}

Примеры использования

Пример полного API с кастомными ошибками

rust
use axum::{
    extract::{Path, State},
    http::StatusCode,
    response::{IntoResponse, Response},
    routing::{get, post},
    Json, Router,
};
use serde::{Deserialize, Serialize};
use thiserror::Error;
use validator::Validate;

// API для работы с пользователями
#[derive(Debug, Serialize)]
struct User {
    id: String,
    name: String,
    email: String,
}

#[derive(Debug, Deserialize, Validate)]
struct CreateUserRequest {
    #[validate(length(min = 3, message = "Имя должно содержать не менее 3 символов"))]
    name: String,
    
    #[validate(email(message = "Некорректный email"))]
    email: String,
    
    #[validate(length(min = 8, message = "Пароль должен содержать не менее 8 символов"))]
    password: String,
}

// Определение ошибок
#[derive(Debug, Error)]
enum ApiError {
    #[error("Ошибка валидации")]
    Validation(#[from] validator::ValidationErrors),
    
    #[error("Пользователь с email {0} уже существует")]
    DuplicateEmail(String),
    
    #[error("Пользователь с ID {0} не найден")]
    UserNotFound(String),
    
    #[error("Ошибка базы данных: {0}")]
    Database(String),
    
    #[error("Внутренняя ошибка: {0}")]
    Internal(String),
}

// Реализация преобразования в HTTP ответ
impl IntoResponse for ApiError {
    fn into_response(self) -> Response {
        let (status, error_message, error_details) = match &self {
            ApiError::Validation(errors) => {
                let validation_errors = errors
                    .field_errors()
                    .iter()
                    .map(|(field, errors)| {
                        format!(
                            "{}: {}",
                            field,
                            errors[0].message.clone().unwrap_or_else(|| "Invalid".into())
                        )
                    })
                    .collect::<Vec<_>>();
                
                (
                    StatusCode::BAD_REQUEST,
                    "Ошибка валидации".to_string(),
                    Some(serde_json::json!({ "errors": validation_errors })),
                )
            },
            ApiError::DuplicateEmail(email) => (
                StatusCode::BAD_REQUEST,
                format!("Пользователь с email {} уже существует", email),
                None,
            ),
            ApiError::UserNotFound(id) => (
                StatusCode::NOT_FOUND,
                format!("Пользователь с ID {} не найден", id),
                None,
            ),
            ApiError::Database(err) => {
                eprintln!("Database error: {}", err);
                (
                    StatusCode::INTERNAL_SERVER_ERROR,
                    "Ошибка при обращении к базе данных".to_string(),
                    None,
                )
            },
            ApiError::Internal(err) => {
                eprintln!("Internal error: {}", err);
                (
                    StatusCode::INTERNAL_SERVER_ERROR,
                    "Внутренняя ошибка сервера".to_string(),
                    None,
                )
            },
        };
        
        let body = match error_details {
            Some(details) => serde_json::json!({
                "error": {
                    "message": error_message,
                    "details": details,
                }
            }),
            None => serde_json::json!({
                "error": {
                    "message": error_message,
                }
            }),
        };
        
        (status, Json(body)).into_response()
    }
}

// Имитация БД
#[derive(Clone)]
struct UserRepository {
    // В реальном приложении это было бы подключение к базе данных
}

impl UserRepository {
    async fn find_by_id(&self, id: &str) -> Result<Option<User>, ApiError> {
        // Имитация поиска пользователя
        if id == "123" {
            Ok(Some(User {
                id: "123".to_string(),
                name: "Иван".to_string(),
                email: "ivan@example.com".to_string(),
            }))
        } else {
            Ok(None)
        }
    }
    
    async fn find_by_email(&self, email: &str) -> Result<Option<User>, ApiError> {
        // Проверка наличия пользователя с таким email
        if email == "ivan@example.com" {
            Ok(Some(User {
                id: "123".to_string(),
                name: "Иван".to_string(),
                email: "ivan@example.com".to_string(),
            }))
        } else {
            Ok(None)
        }
    }
    
    async fn create(&self, user: &CreateUserRequest) -> Result<User, ApiError> {
        // Проверка на существование пользователя с таким email
        if let Some(_) = self.find_by_email(&user.email).await? {
            return Err(ApiError::DuplicateEmail(user.email.clone()));
        }
        
        // Имитация создания пользователя
        Ok(User {
            id: "456".to_string(),
            name: user.name.clone(),
            email: user.email.clone(),
        })
    }
}

// Обработчики
async fn get_user(
    Path(user_id): Path<String>,
    State(repo): State<UserRepository>,
) -> Result<Json<User>, ApiError> {
    let user = repo.find_by_id(&user_id).await?
        .ok_or_else(|| ApiError::UserNotFound(user_id))?;
    
    Ok(Json(user))
}

async fn create_user(
    State(repo): State<UserRepository>,
    Json(payload): Json<CreateUserRequest>,
) -> Result<Json<User>, ApiError> {
    // Валидация данных
    payload.validate()?;
    
    // Создание пользователя
    let user = repo.create(&payload).await?;
    
    Ok(Json(user))
}

// Настройка приложения
#[tokio::main]
async fn main() {
    // Инициализация репозитория
    let user_repo = UserRepository {};
    
    // Создание маршрутов
    let app = Router::new()
        .route("/users/:id", get(get_user))
        .route("/users", post(create_user))
        .with_state(user_repo);
    
    // Запуск сервера
    axum::Server::bind(&"0.0.0.0:3000".parse().unwrap())
        .serve(app.into_make_service())
        .await
        .unwrap();
}

Использование кастомных ошибок существенно повышает качество кода и удобство работы с ним. Создание собственных типов ошибок позволяет:

  • Стандартизировать обработку ошибок в приложении
  • Сделать API более предсказуемым
  • Улучшить опыт разработки благодаря более информативным сообщениям об ошибках
  • Разделить бизнес-логику и представление ошибок