AxumMiddleTechnical

Как стандартизировать error responses и map internal errors to HTTP?

В Axum стандартизация ошибок — через impl IntoResponse для кастомного AppError enum с thiserror. Все хендлеры возвращают Result<impl IntoResponse, AppError>, operator ? конвертирует через From.

Стандартизация error responses в Axum

Axum использует трейт IntoResponse для конвертации любого типа в HTTP-ответ. Это позволяет создать единый error type с детерминированным маппингом в HTTP-статусы.

Определение AppError с thiserror

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

#[derive(Debug, Error)]
pub enum AppError {
    #[error("resource not found: {0}")]
    NotFound(String),

    #[error("unauthorized: {0}")]
    Unauthorized(String),

    #[error("forbidden")]
    Forbidden,

    #[error("validation failed: {0}")]
    Validation(String),

    #[error("database error")]
    Database(#[from] sqlx::Error),

    #[error("external service error: {0}")]
    External(String),
}

#[derive(Serialize)]
struct ErrorBody {
    error: String,
    message: String,
    #[serde(skip_serializing_if = "Option::is_none")]
    field: Option<String>,
}

impl IntoResponse for AppError {
    fn into_response(self) -> Response {
        let (status, code, message) = match &self {
            AppError::NotFound(msg) => (
                StatusCode::NOT_FOUND,
                "NOT_FOUND",
                msg.clone(),
            ),
            AppError::Unauthorized(msg) => (
                StatusCode::UNAUTHORIZED,
                "UNAUTHORIZED",
                msg.clone(),
            ),
            AppError::Forbidden => (
                StatusCode::FORBIDDEN,
                "FORBIDDEN",
                "Access denied".to_string(),
            ),
            AppError::Validation(msg) => (
                StatusCode::UNPROCESSABLE_ENTITY,
                "VALIDATION_ERROR",
                msg.clone(),
            ),
            AppError::Database(e) => {
                tracing::error!("Database error: {:?}", e);
                (
                    StatusCode::INTERNAL_SERVER_ERROR,
                    "DATABASE_ERROR",
                    "A database error occurred".to_string(),
                )
            }
            AppError::External(msg) => {
                tracing::error!("External service error: {}", msg);
                (
                    StatusCode::BAD_GATEWAY,
                    "EXTERNAL_ERROR",
                    "External service unavailable".to_string(),
                )
            }
        };

        (
            status,
            Json(ErrorBody {
                error: code.to_string(),
                message,
                field: None,
            }),
        ).into_response()
    }
}

From-реализации для автоматической конвертации

// sqlx::Error уже покрыт через #[from] в derive
// Добавляем кастомные конвертации
impl From<uuid::Error> for AppError {
    fn from(e: uuid::Error) -> Self {
        AppError::Validation(format!("Invalid UUID: {}", e))
    }
}

impl From<reqwest::Error> for AppError {
    fn from(e: reqwest::Error) -> Self {
        AppError::External(e.to_string())
    }
}

Использование в хендлерах

use axum::{
    extract::{Path, State},
    response::IntoResponse,
    Json,
};

async fn get_user(
    State(state): State<AppState>,
    Path(user_id): Path<uuid::Uuid>,
) -> Result<impl IntoResponse, AppError> {
    let user = sqlx::query_as!(
        User,
        "SELECT * FROM users WHERE id = $1",
        user_id
    )
    .fetch_optional(&state.db)
    .await?  // sqlx::Error -> AppError::Database via From
    .ok_or_else(|| AppError::NotFound(format!("User {} not found", user_id)))?;

    Ok(Json(user))
}

async fn create_user(
    State(state): State<AppState>,
    Json(dto): Json<CreateUserDto>,
) -> Result<impl IntoResponse, AppError> {
    if dto.email.is_empty() {
        return Err(AppError::Validation("email cannot be empty".to_string()));
    }

    let user = sqlx::query_as!(
        User,
        "INSERT INTO users (email) VALUES ($1) RETURNING *",
        dto.email
    )
    .fetch_one(&state.db)
    .await?;

    Ok((StatusCode::CREATED, Json(user)))
}

Обработка ошибок десериализации JSON

use axum::extract::rejection::JsonRejection;

// Кастомная обработка ошибок парсинга JSON body
async fn create_user_safe(
    State(state): State<AppState>,
    payload: Result<Json<CreateUserDto>, JsonRejection>,
) -> Result<impl IntoResponse, AppError> {
    let Json(dto) = payload.map_err(|e| {
        AppError::Validation(format!("Invalid request body: {}", e))
    })?;
    // ...
    Ok(Json(serde_json::json!({"ok": true})))
}

Подводные камни

  • Если хендлер возвращает (StatusCode, Json(...)) напрямую без AppError, этот путь обходит централизованное логирование.
  • thiserror::Error derive требует, что #[from] применяется только к типам, которые реализуют std::error::Error.
  • При нескольких From<X> for AppError с одним типом X компилятор откажет — нужны newtype wrappers.
  • Тело ошибки с деталями об ошибке БД (имена таблиц, SQL) никогда не должно отправляться клиенту — логируйте server-side.
  • JsonRejection возникает до вызова хендлера, поэтому стандартный Result в сигнатуре не поможет — нужен паттерн с явным Result<Json<T>, JsonRejection>.
  • axum не имеет глобального exception handler как в Actix — каждый route должен явно возвращать совместимый тип.
  • При panics внутри хендлера ответ клиенту — connection close без тела; добавьте tower::ServiceBuilder::new().layer(tower::util::CatchPanicLayer::new()).

Common mistakes

  • Отвечать определением без production-сценария.
  • Не называть runtime boundary, security boundary или failure mode.
  • Игнорировать версию API, observability и тестовую проверку.

What the interviewer is testing

  • Объясняет механизм своими словами и без выдуманных API.
  • Называет реальные риски, диагностику и критерий корректности.
  • Связывает ответ с текущей документацией и миграционными ограничениями.

Sources

Related topics