معالجة الأخطاء
يُولّد FORGE نظام معالجة أخطاء مركزي يُحوّل كل خطأ في تطبيقك -- من فشل قاعدة البيانات لمشاكل التحقق -- لاستجابة JSON متسقة. النظام مبني على crate الـ thiserror لتعريف متغيرات الأخطاء و trait الـ IntoResponse في Axum للتحويل التلقائي لاستجابات HTTP.
صيغة استجابة الخطأ
كل استجابة خطأ تتبع نفس بنية JSON:
{
"code": "VALIDATION_ERROR",
"message": "خطأ تحقق: الاسم مطلوب",
"details": null
}| الحقل | النوع | الوصف |
|---|---|---|
code | string | رمز خطأ قابل للقراءة آلياً للمعالجة البرمجية |
message | string | وصف خطأ قابل للقراءة بشرياً |
details | object | null | سياق إضافي اختياري (أخطاء حقول التحقق، إلخ.) |
struct الاستجابة:
use serde::Serialize;
#[derive(Debug, Serialize)]
pub struct ErrorResponse {
pub code: String,
pub message: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub details: Option<serde_json::Value>,
}enum الـ AppError
جميع أخطاء التطبيق تُمثّل بـ enum الـ AppError، الذي يستخدم thiserror لاشتقاق تنفيذات Display و Error:
use thiserror::Error;
#[derive(Error, Debug)]
pub enum AppError {
// --- Authentication Errors (401) ---
#[error("Invalid credentials")]
InvalidCredentials,
#[error("Token expired")]
TokenExpired,
#[error("Invalid token")]
InvalidToken,
#[error("Unauthorized: {0}")]
Unauthorized(String),
// --- Authorization Errors (403) ---
#[error("Forbidden: {0}")]
Forbidden(String),
#[error("Permission denied: {0}")]
PermissionDenied(String),
// --- Validation Errors (400) ---
#[error("Validation error: {0}")]
Validation(String),
#[error("Invalid input: {0}")]
InvalidInput(String),
#[error("Bad request: {0}")]
BadRequest(String),
// --- Resource Errors ---
#[error("Resource not found: {0}")]
NotFound(String), // 404
#[error("Resource already exists: {0}")]
Conflict(String), // 409
// --- Internal Errors (500) ---
#[error("Database error: {0}")]
Database(#[from] sqlx::Error),
#[error("Internal server error: {0}")]
Internal(String),
#[error("Internal server error")]
InternalAnyhow(#[from] anyhow::Error),
#[error("Configuration error: {0}")]
Configuration(String),
}نصيحة
attribute الـ #[from] على Database و InternalAnyhow يُمكّن التحويل التلقائي باستخدام عامل ?. عندما يحدث sqlx::Error في كودك، يتحول تلقائياً لـ AppError::Database بدون تعيين صريح.
تعيين الخطأ لحالة HTTP
كل متغير خطأ يُعيّن لرمز حالة HTTP ورمز خطأ محدد:
| المتغير | حالة HTTP | رمز الخطأ | سلوك الرسالة |
|---|---|---|---|
InvalidCredentials | 401 | INVALID_CREDENTIALS | رسالة ثابتة |
TokenExpired | 401 | TOKEN_EXPIRED | رسالة ثابتة |
InvalidToken | 401 | INVALID_TOKEN | رسالة ثابتة |
Unauthorized(msg) | 401 | UNAUTHORIZED | رسالة مخصصة |
Forbidden(msg) | 403 | FORBIDDEN | رسالة مخصصة |
PermissionDenied(msg) | 403 | PERMISSION_DENIED | رسالة مخصصة |
Validation(msg) | 400 | VALIDATION_ERROR | رسالة مخصصة |
InvalidInput(msg) | 400 | INVALID_INPUT | رسالة مخصصة |
BadRequest(msg) | 400 | BAD_REQUEST | رسالة مخصصة |
NotFound(msg) | 404 | NOT_FOUND | رسالة مخصصة |
Conflict(msg) | 409 | CONFLICT | رسالة مخصصة |
Database(err) | 500 | DATABASE_ERROR | عامة (التفاصيل تُسجّل) |
Internal(msg) | 500 | INTERNAL_ERROR | عامة (التفاصيل تُسجّل) |
InternalAnyhow(err) | 500 | INTERNAL_ERROR | عامة (التفاصيل تُسجّل) |
Configuration(msg) | 500 | CONFIGURATION_ERROR | عامة (التفاصيل تُسجّل) |
تحذير
الأخطاء الداخلية (500) لا تكشف أبداً تفاصيل التنفيذ للعميل. رسالة الخطأ الفعلية تُسجّل على جانب الخادم عبر tracing::error!، لكن استجابة API تُرجع دائماً رسالة عامة مثل "حدث خطأ في قاعدة البيانات". هذا يمنع تسريب معلومات حساسة عن بنيتك التحتية.
تنفيذ IntoResponse
enum الـ AppError ينفّذ trait الـ IntoResponse في Axum، مما يسمح للمعالجات بإرجاع Result<T, AppError> مباشرة:
impl IntoResponse for AppError {
fn into_response(self) -> Response {
let (status, code, message) = match &self {
// 401 Unauthorized
AppError::InvalidCredentials => {
(StatusCode::UNAUTHORIZED, "INVALID_CREDENTIALS", self.to_string())
}
AppError::Unauthorized(_) => {
(StatusCode::UNAUTHORIZED, "UNAUTHORIZED", self.to_string())
}
// 403 Forbidden
AppError::PermissionDenied(_) => {
(StatusCode::FORBIDDEN, "PERMISSION_DENIED", self.to_string())
}
// 500 Internal -- log real error, return generic message
AppError::Database(e) => {
tracing::error!("Database error: {:?}", e);
(StatusCode::INTERNAL_SERVER_ERROR, "DATABASE_ERROR",
"A database error occurred".to_string())
}
AppError::Internal(msg) => {
tracing::error!("Internal error: {}", msg);
(StatusCode::INTERNAL_SERVER_ERROR, "INTERNAL_ERROR",
"An internal error occurred".to_string())
}
// ... other variants
};
let body = ErrorResponse {
code: code.to_string(),
message,
details: None,
};
(status, Json(body)).into_response()
}
}استخدام الأخطاء في المعالجات
إرجاع خطأ أساسي
أرجع الأخطاء من المعالجات باستخدام عامل ? أو Err() صريح:
pub async fn get_user(
Path(id): Path<Uuid>,
State(state): State<AppState>,
) -> Result<Json<ApiResponse<UserResponse>>, AppError> {
let user = UserService::find_by_id(&state.db, id)
.await? // sqlx::Error converts automatically to AppError::Database
.ok_or(AppError::NotFound( // Explicit error for missing resource
format!("User with ID {} not found", id)
))?;
Ok(Json(ApiResponse::success(user.into())))
}أخطاء التحقق
لفشل تحقق الطلبات، أرجع خطأ Validation أو InvalidInput:
pub async fn create_user(
State(state): State<AppState>,
Json(payload): Json<CreateUserRequest>,
) -> Result<Json<ApiResponse<UserResponse>>, AppError> {
// Check email uniqueness
if UserService::email_exists(&state.db, &payload.email).await? {
return Err(AppError::Conflict(
"A user with this email already exists".to_string()
));
}
// Check password strength
if payload.password.len() < 8 {
return Err(AppError::Validation(
"Password must be at least 8 characters".to_string()
));
}
let user = UserService::create(&state.db, payload).await?;
Ok(Json(ApiResponse::created(user.into())))
}أخطاء مع تفاصيل
لأخطاء التحقق على مستوى الحقل، استخدم ErrorResponse::with_details:
use serde_json::json;
pub async fn create_user(
State(state): State<AppState>,
Json(payload): Json<CreateUserRequest>,
) -> Result<impl IntoResponse, AppError> {
let mut errors = serde_json::Map::new();
if payload.name.is_empty() {
errors.insert(
"name".to_string(),
json!(["Name is required"]),
);
}
if !payload.email.contains('@') {
errors.insert(
"email".to_string(),
json!(["Please enter a valid email"]),
);
}
if !errors.is_empty() {
let body = ErrorResponse::with_details(
"VALIDATION_ERROR",
"The given data is invalid",
json!(errors),
);
return Err((StatusCode::UNPROCESSABLE_ENTITY, Json(body)).into());
}
// ... create user
}هذا يُنتج استجابة مثل:
{
"code": "VALIDATION_ERROR",
"message": "The given data is invalid",
"details": {
"name": ["Name is required"],
"email": ["Please enter a valid email"]
}
}انتشار الأخطاء مع ?
عامل ? يعمل بسلاسة مع AppError بفضل attributes الـ #[from] و trait الـ From في Rust. إليك كيف تتدفق الأخطاء عبر الطبقات:
Handler Service Database
│ │ │
│ service.create()? │ sqlx::query()? │
│ ─────────────────────▶ │ ──────────────────────▶ │
│ │ │
│ AppError::Database ◀──│── sqlx::Error ◀──│
│ (تحويل تلقائي) │ (تحويل تلقائي) │
│ │ │
▼ │ │
IntoResponse │ │
│ │ │
▼ │ │
{ status: 500, │ │
code: "DATABASE_ERROR", │ │
message: "حدث خطأ │ │
في قاعدة البيانات" } │ │// In service -- sqlx::Error converts automatically
pub async fn find_by_id(pool: &PgPool, id: Uuid) -> Result<Option<User>, AppError> {
let user = sqlx::query_as!(User, "SELECT * FROM users WHERE id = $1", id)
.fetch_optional(pool)
.await?; // sqlx::Error -> AppError::Database
Ok(user)
}
// In handler -- AppError propagates automatically
pub async fn get_user(
Path(id): Path<Uuid>,
State(state): State<AppState>,
) -> Result<Json<ApiResponse<UserResponse>>, AppError> {
let user = UserService::find_by_id(&state.db, id)
.await? // AppError propagates
.ok_or(AppError::NotFound("المستخدم غير موجود".into()))?;
Ok(Json(ApiResponse::success(user.into())))
}تسجيل الأخطاء مع Tracing
الأخطاء الداخلية تُسجّل تلقائياً باستخدام tracing::error! قبل إرسال الاستجابة العامة للعميل:
// Database errors -- logged with full debug info
AppError::Database(e) => {
tracing::error!("Database error: {:?}", e);
// Client receives: "A database error occurred"
}
// Internal errors -- logged with actual message
AppError::Internal(msg) => {
tracing::error!("Internal error: {}", msg);
// Client receives: "An internal error occurred"
}يمكنك إضافة سياق مُنظّم لسجلات الأخطاء مع tracing spans:
pub async fn create_user(
auth_user: AuthUser,
Json(payload): Json<CreateUserRequest>,
) -> Result<Json<ApiResponse<UserResponse>>, AppError> {
let span = tracing::info_span!(
"create_user",
user.id = %auth_user.user_id,
user.email = %payload.email,
);
let _guard = span.enter();
// Any error in this scope will include the span context in logs
let user = UserService::create(&state.db, payload).await?;
Ok(Json(ApiResponse::created(user.into())))
}أنماط أخطاء شائعة
المورد غير موجود
let user = UserService::find_by_id(&state.db, id)
.await?
.ok_or(AppError::NotFound(format!("User {} not found", id)))?;انتهاك التفرد
if UserService::email_exists(&state.db, &email).await? {
return Err(AppError::Conflict("Email already in use".into()));
}فحص التفويض
if content.author_id != auth_user.user_id {
return Err(AppError::Forbidden(
"You can only edit your own content".into()
));
}فحص الصلاحية في المعالج
if !perms.has_permission("users.delete") {
return Err(AppError::PermissionDenied(
"Missing permission: users.delete".into()
));
}فشل خدمة خارجية
let result = email_service.send(to, subject, body)
.await
.map_err(|e| AppError::Internal(
format!("Failed to send email: {}", e)
))?;إضافة متغيرات خطأ جديدة
لإضافة متغير خطأ جديد لتطبيقك:
- أضف المتغير لـ
AppError:
#[derive(Error, Debug)]
pub enum AppError {
// ... existing variants
#[error("Rate limited: {0}")]
RateLimited(String),
#[error("Payment required: {0}")]
PaymentRequired(String),
}- أضف التعيين في
IntoResponse:
impl IntoResponse for AppError {
fn into_response(self) -> Response {
let (status, code, message) = match &self {
// ... existing matches
AppError::RateLimited(_) => {
(StatusCode::TOO_MANY_REQUESTS, "RATE_LIMITED", self.to_string())
}
AppError::PaymentRequired(_) => {
(StatusCode::PAYMENT_REQUIRED, "PAYMENT_REQUIRED", self.to_string())
}
};
// ... build response
}
}انظر أيضاً
- الـ Middleware — كيف يُرجع middleware أخطاء لفشل المصادقة و RBAC
- الـ Handlers — أنماط المعالجات التي تستخدم
Result<T, AppError> - الـ Services — انتشار الأخطاء من طبقة منطق الأعمال
- الـ DTOs — أنواع الاستجابات بما فيها
ApiResponseوErrorResponse