Skip to content

Error Handling

FORGE generates a centralized error handling system that converts every error in your application -- from database failures to validation issues -- into a consistent JSON response. The system is built on the thiserror crate for defining error variants and Axum's IntoResponse trait for automatic HTTP response conversion.

Error Response Format

Every error response follows the same JSON structure:

json
{
  "code": "VALIDATION_ERROR",
  "message": "Validation error: Name is required",
  "details": null
}
FieldTypeDescription
codestringMachine-readable error code for programmatic handling
messagestringHuman-readable error description
detailsobject | nullOptional additional context (validation field errors, etc.)

The response 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>,
}

The AppError Enum

All application errors are represented by the AppError enum, which uses thiserror to derive Display and Error implementations:

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),
}

TIP

The #[from] attribute on Database and InternalAnyhow enables automatic conversion using the ? operator. When a sqlx::Error occurs in your code, it automatically converts to AppError::Database without explicit mapping.

Error-to-HTTP Status Mapping

Each error variant maps to a specific HTTP status code and error code:

VariantHTTP StatusError CodeMessage Behavior
InvalidCredentials401INVALID_CREDENTIALSFixed message
TokenExpired401TOKEN_EXPIREDFixed message
InvalidToken401INVALID_TOKENFixed message
Unauthorized(msg)401UNAUTHORIZEDCustom message
Forbidden(msg)403FORBIDDENCustom message
PermissionDenied(msg)403PERMISSION_DENIEDCustom message
Validation(msg)400VALIDATION_ERRORCustom message
InvalidInput(msg)400INVALID_INPUTCustom message
BadRequest(msg)400BAD_REQUESTCustom message
NotFound(msg)404NOT_FOUNDCustom message
Conflict(msg)409CONFLICTCustom message
Database(err)500DATABASE_ERRORGeneric (details logged)
Internal(msg)500INTERNAL_ERRORGeneric (details logged)
InternalAnyhow(err)500INTERNAL_ERRORGeneric (details logged)
Configuration(msg)500CONFIGURATION_ERRORGeneric (details logged)

WARNING

Internal errors (500) never expose implementation details to the client. The actual error message is logged server-side via tracing::error!, but the API response always returns a generic message like "A database error occurred". This prevents leaking sensitive information about your infrastructure.

IntoResponse Implementation

The AppError enum implements Axum's IntoResponse trait, which allows handlers to return Result<T, AppError> directly:

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

Using Errors in Handlers

Basic Error Return

Return errors from handlers using the ? operator or explicit 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 auto-converts 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 Errors

For request validation failures, return a Validation or InvalidInput error:

rust
pub async fn create_user(
    State(state): State<AppState>,
    Json(payload): Json<CreateUserRequest>,
) -> Result<Json<ApiResponse<UserResponse>>, AppError> {
    // Validate email uniqueness
    if UserService::email_exists(&state.db, &payload.email).await? {
        return Err(AppError::Conflict(
            "A user with this email already exists".to_string()
        ));
    }

    // Validate 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())))
}

Errors with Details

For field-level validation errors, use 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 address"]),
        );
    }

    if !errors.is_empty() {
        let body = ErrorResponse::with_details(
            "VALIDATION_ERROR",
            "The given data was invalid",
            json!(errors),
        );
        return Err((StatusCode::UNPROCESSABLE_ENTITY, Json(body)).into());
    }

    // ... create user
}

This produces a response like:

json
{
  "code": "VALIDATION_ERROR",
  "message": "The given data was invalid",
  "details": {
    "name": ["Name is required"],
    "email": ["Please enter a valid email address"]
  }
}

Error Propagation with ?

The ? operator works seamlessly with AppError thanks to the #[from] attributes and Rust's From trait. Here is how errors flow through the layers:

Handler                    Service                     Database
  │                          │                           │
  │  service.create()?       │  sqlx::query()?           │
  │ ─────────────────────▶   │ ──────────────────────▶   │
  │                          │                           │
  │   AppError::Database  ◀──│──  sqlx::Error         ◀──│
  │   (auto-converted)      │   (auto-converted)        │
  │                          │                           │
  ▼                          │                           │
IntoResponse                 │                           │
  │                          │                           │
  ▼                          │                           │
{ status: 500,               │                           │
  code: "DATABASE_ERROR",    │                           │
  message: "A database       │                           │
    error occurred" }        │                           │
rust
// In a 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 a 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("User not found".into()))?;

    Ok(Json(ApiResponse::success(user.into())))
}

Error Logging with Tracing

Internal errors are automatically logged using tracing::error! before the generic response is sent to the client:

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 the actual message
AppError::Internal(msg) => {
    tracing::error!("Internal error: {}", msg);
    // Client receives: "An internal error occurred"
}

You can add structured context to error logs with 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())))
}

Common Error Patterns

Resource Not Found

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

Uniqueness Violation

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

Authorization Check

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

Permission Check in Handler

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

External Service Failure

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

Adding New Error Variants

To add a new error variant for your application:

  1. Add the variant to AppError:
rust
#[derive(Error, Debug)]
pub enum AppError {
    // ... existing variants

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

    #[error("Payment required: {0}")]
    PaymentRequired(String),
}
  1. Add the mapping in 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
    }
}

See Also

  • Middleware -- How middleware returns errors for auth and RBAC failures
  • Handlers -- Handler patterns that use Result<T, AppError>
  • Services -- Error propagation from the business logic layer
  • DTOs -- Response types including ApiResponse and ErrorResponse

Released under the MIT License.