Кастомные ошибки в Axum
Создание собственных типов ошибок позволяет повысить читаемость и поддерживаемость кода. В Axum легко интегрировать кастомные ошибки благодаря типажу IntoResponse
. В этом разделе рассмотрим подходы к созданию и использованию собственных типов ошибок.
Содержание
- Основной подход
- Создание кастомного типа ошибок
- Интеграция с библиотеками thiserror и anyhow
- Преобразование ошибок
- Паттерны работы с ошибками
- Примеры использования
Основной подход
Для создания кастомных ошибок в Axum нужно:
- Определить собственный тип ошибки
- Реализовать для него типаж
IntoResponse
- Использовать его в обработчиках
Создание кастомного типа ошибок
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 более предсказуемым
- Улучшить опыт разработки благодаря более информативным сообщениям об ошибках
- Разделить бизнес-логику и представление ошибок