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::Errorderive требует, что#[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.
- Называет реальные риски, диагностику и критерий корректности.
- Связывает ответ с текущей документацией и миграционными ограничениями.