Skip to content

معالجة الأخطاء

يُولّد FORGE نظام معالجة أخطاء مركزي يُحوّل كل خطأ في تطبيقك -- من فشل قاعدة البيانات لمشاكل التحقق -- لاستجابة JSON متسقة. النظام مبني على crate الـ thiserror لتعريف متغيرات الأخطاء و trait الـ IntoResponse في Axum للتحويل التلقائي لاستجابات HTTP.

صيغة استجابة الخطأ

كل استجابة خطأ تتبع نفس بنية JSON:

json
{
  "code": "VALIDATION_ERROR",
  "message": "خطأ تحقق: الاسم مطلوب",
  "details": null
}
الحقلالنوعالوصف
codestringرمز خطأ قابل للقراءة آلياً للمعالجة البرمجية
messagestringوصف خطأ قابل للقراءة بشرياً
detailsobject | nullسياق إضافي اختياري (أخطاء حقول التحقق، إلخ.)

struct الاستجابة:

rust
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:

rust
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رمز الخطأسلوك الرسالة
InvalidCredentials401INVALID_CREDENTIALSرسالة ثابتة
TokenExpired401TOKEN_EXPIREDرسالة ثابتة
InvalidToken401INVALID_TOKENرسالة ثابتة
Unauthorized(msg)401UNAUTHORIZEDرسالة مخصصة
Forbidden(msg)403FORBIDDENرسالة مخصصة
PermissionDenied(msg)403PERMISSION_DENIEDرسالة مخصصة
Validation(msg)400VALIDATION_ERRORرسالة مخصصة
InvalidInput(msg)400INVALID_INPUTرسالة مخصصة
BadRequest(msg)400BAD_REQUESTرسالة مخصصة
NotFound(msg)404NOT_FOUNDرسالة مخصصة
Conflict(msg)409CONFLICTرسالة مخصصة
Database(err)500DATABASE_ERRORعامة (التفاصيل تُسجّل)
Internal(msg)500INTERNAL_ERRORعامة (التفاصيل تُسجّل)
InternalAnyhow(err)500INTERNAL_ERRORعامة (التفاصيل تُسجّل)
Configuration(msg)500CONFIGURATION_ERRORعامة (التفاصيل تُسجّل)

تحذير

الأخطاء الداخلية (500) لا تكشف أبداً تفاصيل التنفيذ للعميل. رسالة الخطأ الفعلية تُسجّل على جانب الخادم عبر tracing::error!، لكن استجابة API تُرجع دائماً رسالة عامة مثل "حدث خطأ في قاعدة البيانات". هذا يمنع تسريب معلومات حساسة عن بنيتك التحتية.

تنفيذ IntoResponse

enum الـ AppError ينفّذ trait الـ IntoResponse في Axum، مما يسمح للمعالجات بإرجاع Result<T, AppError> مباشرة:

rust
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() صريح:

rust
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:

rust
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:

rust
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
}

هذا يُنتج استجابة مثل:

json
{
  "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: "حدث خطأ          │                           │
    في قاعدة البيانات" }     │                           │
rust
// 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! قبل إرسال الاستجابة العامة للعميل:

rust
// 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:

rust
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())))
}

أنماط أخطاء شائعة

المورد غير موجود

rust
let user = UserService::find_by_id(&state.db, id)
    .await?
    .ok_or(AppError::NotFound(format!("User {} not found", id)))?;

انتهاك التفرد

rust
if UserService::email_exists(&state.db, &email).await? {
    return Err(AppError::Conflict("Email already in use".into()));
}

فحص التفويض

rust
if content.author_id != auth_user.user_id {
    return Err(AppError::Forbidden(
        "You can only edit your own content".into()
    ));
}

فحص الصلاحية في المعالج

rust
if !perms.has_permission("users.delete") {
    return Err(AppError::PermissionDenied(
        "Missing permission: users.delete".into()
    ));
}

فشل خدمة خارجية

rust
let result = email_service.send(to, subject, body)
    .await
    .map_err(|e| AppError::Internal(
        format!("Failed to send email: {}", e)
    ))?;

إضافة متغيرات خطأ جديدة

لإضافة متغير خطأ جديد لتطبيقك:

  1. أضف المتغير لـ AppError:
rust
#[derive(Error, Debug)]
pub enum AppError {
    // ... existing variants

    #[error("Rate limited: {0}")]
    RateLimited(String),

    #[error("Payment required: {0}")]
    PaymentRequired(String),
}
  1. أضف التعيين في IntoResponse:
rust
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

Released under the MIT License.