AxumMiddleTechnical
Как реализовать собственный тип ошибки в Axum, возвращающий подходящие HTTP status codes?
Создайте enum ApiError с вариантами для каждого класса ошибок, реализуйте IntoResponse с маппингом на HTTP-статусы и безопасным client_message, добавьте From-конверсии для sqlx::Error и anyhow::Error. Оператор ? в обработчиках автоматически конвертирует ошибки.
Кастомный тип ошибки в Axum с правильными HTTP-статусами
Цель — создать единый тип ошибки для всего приложения, который автоматически преобразуется в корректный HTTP-ответ. Стандартный подход: enum + реализация IntoResponse + интеграция с thiserror для удобного определения ошибок.
Полная реализация с thiserror
use axum::{
http::StatusCode,
response::{IntoResponse, Json, Response},
};
use serde_json::{json, Value};
use thiserror::Error;
#[derive(Debug, Error)]
pub enum ApiError {
#[error("Resource not found: {0}")]
NotFound(String),
#[error("Unauthorized: {0}")]
Unauthorized(String),
#[error("Forbidden")]
Forbidden,
#[error("Validation error: {0}")]
Validation(String),
#[error("Conflict: {0}")]
Conflict(String),
#[error("Too many requests")]
RateLimited,
#[error("Database error: {0}")]
Database(#[from] sqlx::Error),
#[error("Internal error")]
Internal(#[from] anyhow::Error),
}
impl ApiError {
/// Маппинг на HTTP status code
fn status_code(&self) -> StatusCode {
match self {
ApiError::NotFound(_) => StatusCode::NOT_FOUND,
ApiError::Unauthorized(_) => StatusCode::UNAUTHORIZED,
ApiError::Forbidden => StatusCode::FORBIDDEN,
ApiError::Validation(_) => StatusCode::UNPROCESSABLE_ENTITY,
ApiError::Conflict(_) => StatusCode::CONFLICT,
ApiError::RateLimited => StatusCode::TOO_MANY_REQUESTS,
ApiError::Database(e) => match e {
sqlx::Error::RowNotFound => StatusCode::NOT_FOUND,
_ => StatusCode::INTERNAL_SERVER_ERROR,
},
ApiError::Internal(_) => StatusCode::INTERNAL_SERVER_ERROR,
}
}
/// Безопасное сообщение для клиента (без деталей БД)
fn client_message(&self) -> String {
match self {
ApiError::Database(sqlx::Error::RowNotFound) => "Resource not found".to_string(),
ApiError::Database(_) | ApiError::Internal(_) => "Internal server error".to_string(),
other => other.to_string(),
}
}
}
impl IntoResponse for ApiError {
fn into_response(self) -> Response {
// Внутренние ошибки логируем полностью
match &self {
ApiError::Database(e) => tracing::error!(error = ?e, "Database error"),
ApiError::Internal(e) => tracing::error!(error = ?e, "Internal error"),
_ => tracing::warn!(error = %self, "Client error"),
}
let status = self.status_code();
let body: Value = json!({
"error": {
"code": status.as_u16(),
"message": self.client_message()
}
});
(status, Json(body)).into_response()
}
}
Использование в обработчиках
use axum::{
extract::{Path, State},
Json,
};
// ? автоматически конвертирует sqlx::Error в ApiError через From
async fn get_post(
State(pool): State<PgPool>,
Path(post_id): Path<i64>,
) -> Result<Json<Post>, ApiError> {
let post = sqlx::query_as!(Post, "SELECT * FROM posts WHERE id = $1", post_id)
.fetch_one(&pool)
.await?; // sqlx::Error::RowNotFound -> ApiError::Database -> 404
Ok(Json(post))
}
async fn create_post(
State(pool): State<PgPool>,
auth: AuthUser,
Json(payload): Json<CreatePostRequest>,
) -> Result<(StatusCode, Json<Post>), ApiError> {
if payload.title.is_empty() {
return Err(ApiError::Validation("Title cannot be empty".to_string()));
}
let post = sqlx::query_as!(
Post,
"INSERT INTO posts (title, body, author_id) VALUES ($1, $2, $3) RETURNING *",
payload.title,
payload.body,
auth.user_id
)
.fetch_one(&pool)
.await
.map_err(|e| match &e {
sqlx::Error::Database(db_err) if db_err.is_unique_violation() => {
ApiError::Conflict("Post with this title already exists".to_string())
}
_ => ApiError::Database(e),
})?;
Ok((StatusCode::CREATED, Json(post)))
}
Интеграция с валидацией через validator
use validator::Validate;
async fn validated_create(
Json(payload): Json<CreatePostRequest>,
) -> Result<Json<Post>, ApiError> {
payload.validate().map_err(|e| {
ApiError::Validation(e.to_string())
})?;
// ...
}
Подводные камни
- Цикличность From-конверсий: Если несколько внешних ошибок конвертируются в одну ветку через
#[from], возможны конфликты. Чаще всего sqlx::Error нужно обрабатывать вручную (не через #[from]) из-за специфичного маппинга RowNotFound. - Утечка информации в prod: Не передавайте
e.to_string()от sqlx или IO-ошибок клиенту. SQL-запросы, имена таблиц и системные пути могут раскрыть архитектуру. - Ошибки extractor-ов не попадают в ApiError: Если
Json<T>не может распарсить тело, он возвращает JsonRejection напрямую, минуя вашу систему ошибок. Оберните входные данные черезResult<Json<T>, JsonRejection>и конвертируйте вручную. - Дублирование логики статус-кодов: Маппинг статуса должен быть в одном месте (метод status_code). Не разбрасывайте StatusCode по всему коду.
- Несовместимость с ? в main:
anyhow::ErrorиApiError— разные типы. В функциях инициализации (setup DB, load config) используйтеanyhow::Result, а ApiError только в обработчиках. - Отсутствие request ID в ошибке: Логируйте request_id вместе с ошибкой. Без него поддержка не может связать лог с конкретным запросом клиента.
- Паника вместо ошибки:
unwrap()иexpect()в обработчиках приводят к 500 без логирования через вашу систему. Axum перехватывает паники через CatchPanic слой, но это не замена корректной обработке.
Common mistakes
- Отвечать определением без production-сценария.
- Не называть runtime boundary, security boundary или failure mode.
- Игнорировать версию API, observability и тестовую проверку.
What the interviewer is testing
- Объясняет механизм своими словами и без выдуманных API.
- Называет реальные риски, диагностику и критерий корректности.
- Связывает ответ с текущей документацией и миграционными ограничениями.