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:
{
"code": "VALIDATION_ERROR",
"message": "Validation error: Name is required",
"details": null
}| Field | Type | Description |
|---|---|---|
code | string | Machine-readable error code for programmatic handling |
message | string | Human-readable error description |
details | object | null | Optional additional context (validation field errors, etc.) |
The response 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>,
}The AppError Enum
All application errors are represented by the AppError enum, which uses thiserror to derive Display and Error implementations:
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:
| Variant | HTTP Status | Error Code | Message Behavior |
|---|---|---|---|
InvalidCredentials | 401 | INVALID_CREDENTIALS | Fixed message |
TokenExpired | 401 | TOKEN_EXPIRED | Fixed message |
InvalidToken | 401 | INVALID_TOKEN | Fixed message |
Unauthorized(msg) | 401 | UNAUTHORIZED | Custom message |
Forbidden(msg) | 403 | FORBIDDEN | Custom message |
PermissionDenied(msg) | 403 | PERMISSION_DENIED | Custom message |
Validation(msg) | 400 | VALIDATION_ERROR | Custom message |
InvalidInput(msg) | 400 | INVALID_INPUT | Custom message |
BadRequest(msg) | 400 | BAD_REQUEST | Custom message |
NotFound(msg) | 404 | NOT_FOUND | Custom message |
Conflict(msg) | 409 | CONFLICT | Custom message |
Database(err) | 500 | DATABASE_ERROR | Generic (details logged) |
Internal(msg) | 500 | INTERNAL_ERROR | Generic (details logged) |
InternalAnyhow(err) | 500 | INTERNAL_ERROR | Generic (details logged) |
Configuration(msg) | 500 | CONFIGURATION_ERROR | Generic (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:
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():
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:
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:
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:
{
"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" } │ │// 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:
// 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:
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
let user = UserService::find_by_id(&state.db, id)
.await?
.ok_or(AppError::NotFound(format!("User {} not found", id)))?;Uniqueness Violation
if UserService::email_exists(&state.db, &email).await? {
return Err(AppError::Conflict("Email already in use".into()));
}Authorization Check
if content.author_id != auth_user.user_id {
return Err(AppError::Forbidden(
"You can only edit your own content".into()
));
}Permission Check in Handler
if !perms.has_permission("users.delete") {
return Err(AppError::PermissionDenied(
"Missing permission: users.delete".into()
));
}External Service Failure
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:
- Add the variant to
AppError:
#[derive(Error, Debug)]
pub enum AppError {
// ... existing variants
#[error("Rate limited: {0}")]
RateLimited(String),
#[error("Payment required: {0}")]
PaymentRequired(String),
}- Add the mapping in
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
}
}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
ApiResponseandErrorResponse