ActixMiddleTechnical

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

Реализуем единый AppError enum с impl ResponseError — он конвертирует внутренние ошибки в HTTP-статусы и JSON-тело. Все хендлеры возвращают Result<_, AppError>.

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

Actix Web имеет трейт ResponseError, который позволяет превратить любой тип ошибки в HTTP-ответ. Цель — один enum для всех доменных ошибок с детерминированным маппингом в HTTP-статусы.

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

use actix_web::{HttpResponse, ResponseError};
use serde::Serialize;
use std::fmt;

#[derive(Debug)]
pub enum AppError {
    NotFound(String),
    Unauthorized(String),
    Forbidden(String),
    Validation(String),
    Database(sqlx::Error),
    Internal(String),
}

#[derive(Serialize)]
struct ErrorResponse {
    error: &'static str,
    message: String,
    #[serde(skip_serializing_if = "Option::is_none")]
    details: Option<serde_json::Value>,
}

impl fmt::Display for AppError {
    fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
        match self {
            AppError::NotFound(msg) => write!(f, "Not found: {}", msg),
            AppError::Unauthorized(msg) => write!(f, "Unauthorized: {}", msg),
            AppError::Forbidden(msg) => write!(f, "Forbidden: {}", msg),
            AppError::Validation(msg) => write!(f, "Validation: {}", msg),
            AppError::Database(e) => write!(f, "Database error: {}", e),
            AppError::Internal(msg) => write!(f, "Internal: {}", msg),
        }
    }
}

impl ResponseError for AppError {
    fn error_response(&self) -> HttpResponse {
        let (status, error_code, message) = match self {
            AppError::NotFound(msg) => (
                actix_web::http::StatusCode::NOT_FOUND,
                "NOT_FOUND",
                msg.clone(),
            ),
            AppError::Unauthorized(msg) => (
                actix_web::http::StatusCode::UNAUTHORIZED,
                "UNAUTHORIZED",
                msg.clone(),
            ),
            AppError::Forbidden(msg) => (
                actix_web::http::StatusCode::FORBIDDEN,
                "FORBIDDEN",
                msg.clone(),
            ),
            AppError::Validation(msg) => (
                actix_web::http::StatusCode::UNPROCESSABLE_ENTITY,
                "VALIDATION_ERROR",
                msg.clone(),
            ),
            AppError::Database(e) => {
                tracing::error!("Database error: {:?}", e);
                (
                    actix_web::http::StatusCode::INTERNAL_SERVER_ERROR,
                    "DATABASE_ERROR",
                    "A database error occurred".to_string(),
                )
            }
            AppError::Internal(msg) => {
                tracing::error!("Internal error: {}", msg);
                (
                    actix_web::http::StatusCode::INTERNAL_SERVER_ERROR,
                    "INTERNAL_ERROR",
                    "An internal error occurred".to_string(),
                )
            }
        };

        HttpResponse::build(status).json(ErrorResponse {
            error: error_code,
            message,
            details: None,
        })
    }
}

Конвертация из sqlx и других ошибок

impl From<sqlx::Error> for AppError {
    fn from(e: sqlx::Error) -> Self {
        match e {
            sqlx::Error::RowNotFound => AppError::NotFound("Record not found".to_string()),
            _ => AppError::Database(e),
        }
    }
}

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

async fn get_user(
    pool: web::Data<PgPool>,
    path: web::Path<Uuid>,
) -> Result<HttpResponse, AppError> {
    let user = sqlx::query_as!(User, "SELECT * FROM users WHERE id = $1", *path)
        .fetch_optional(pool.get_ref())
        .await?
        .ok_or_else(|| AppError::NotFound(format!("User {} not found", *path)))?;

    Ok(HttpResponse::Ok().json(user))
}

Оператор ? автоматически конвертирует sqlx::Error в AppError через From-реализацию.

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

  • Не возвращайте детали внутренних ошибок клиенту — логируйте server-side, отправляйте generic message.
  • actix_web::Error (встроенный) и ваш AppError — разные типы; при оборачивании сторонних middleware ошибки могут обходить ваш ResponseError.
  • Валидация через web::Json<T> возвращает JsonPayloadError до вызова хендлера — нужен кастомный .app_data(web::JsonConfig::default().error_handler(...)).
  • При использовании ? в цепочке важно, чтобы все типы ошибок реализовывали From<X> for AppError, иначе компилятор откажет.
  • Одинаковые HTTP-статусы для разных ошибок (оба 500) затрудняют мониторинг — используйте error_code поле в JSON для различения.
  • Паника в хендлере не проходит через ResponseError — добавьте .wrap(middleware::Logger::default()) для логирования 500 от паник.

Common mistakes

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

What the interviewer is testing

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

Sources

Related topics