You cannot select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

173 lines
6.0 KiB
Rust

//! Unified error handling for the application.
//!
//! `AppError` is the single error type returned by all handlers and services.
//! It implements `IntoResponse` to produce consistent JSON error bodies
//! with appropriate HTTP status codes. Internal details are never exposed.
use axum::http::StatusCode;
use axum::response::{IntoResponse, Response};
use serde::Serialize;
/// Application error type.
///
/// Each variant maps to a specific HTTP status code and produces
/// a JSON body with a `message` field safe for client consumption.
#[derive(Debug, thiserror::Error)]
pub enum AppError {
/// 404 Not Found
#[error("Not found: {0}")]
NotFound(String),
/// 401 Unauthorized — missing or invalid authentication
#[error("Unauthorized: {0}")]
Unauthorized(String),
/// 403 Forbidden — authenticated but insufficient permissions
#[error("Forbidden: {0}")]
Forbidden(String),
/// 400 Bad Request — malformed input
#[error("Bad request: {0}")]
BadRequest(String),
/// 422 Unprocessable Entity — validation failure
#[error("Validation error: {0}")]
Validation(String),
/// 500 Internal Server Error — unexpected failure
#[error("Internal error: {0}")]
Internal(#[from] anyhow::Error),
/// 429 Too Many Requests — rate limit exceeded
#[error("Rate limited: {0}")]
RateLimited(String),
}
/// JSON body returned for all error responses.
#[derive(Serialize)]
struct ErrorBody {
error: String,
message: String,
}
impl IntoResponse for AppError {
fn into_response(self) -> Response {
let (status, error_type, message) = match &self {
AppError::NotFound(msg) => (StatusCode::NOT_FOUND, "not_found", msg.clone()),
AppError::Unauthorized(msg) => {
(StatusCode::UNAUTHORIZED, "unauthorized", msg.clone())
}
AppError::Forbidden(msg) => (StatusCode::FORBIDDEN, "forbidden", msg.clone()),
AppError::BadRequest(msg) => (StatusCode::BAD_REQUEST, "bad_request", msg.clone()),
AppError::Validation(msg) => {
(StatusCode::UNPROCESSABLE_ENTITY, "validation_error", msg.clone())
}
AppError::Internal(err) => {
// Log the full internal error but return a generic message
tracing::error!("Internal server error: {:?}", err);
(
StatusCode::INTERNAL_SERVER_ERROR,
"internal_error",
"An internal error occurred".to_string(),
)
}
AppError::RateLimited(msg) => {
(StatusCode::TOO_MANY_REQUESTS, "rate_limited", msg.clone())
}
};
let body = ErrorBody {
error: error_type.to_string(),
message,
};
(status, axum::Json(body)).into_response()
}
}
/// Allow `sqlx::Error` to be converted into `AppError`.
impl From<sqlx::Error> for AppError {
fn from(err: sqlx::Error) -> Self {
tracing::error!("Database error: {:?}", err);
AppError::Internal(anyhow::anyhow!("Database error"))
}
}
#[cfg(test)]
mod tests {
use super::*;
use http_body_util::BodyExt;
async fn response_body(resp: Response) -> serde_json::Value {
let bytes = resp.into_body().collect().await.unwrap().to_bytes();
serde_json::from_slice(&bytes).unwrap()
}
#[tokio::test]
async fn test_not_found_response() {
let err = AppError::NotFound("Resource not found".into());
let resp = err.into_response();
assert_eq!(resp.status(), StatusCode::NOT_FOUND);
let body = response_body(resp).await;
assert_eq!(body["error"], "not_found");
assert_eq!(body["message"], "Resource not found");
}
#[tokio::test]
async fn test_unauthorized_response() {
let err = AppError::Unauthorized("Invalid session".into());
let resp = err.into_response();
assert_eq!(resp.status(), StatusCode::UNAUTHORIZED);
let body = response_body(resp).await;
assert_eq!(body["error"], "unauthorized");
}
#[tokio::test]
async fn test_forbidden_response() {
let err = AppError::Forbidden("Admin access required".into());
let resp = err.into_response();
assert_eq!(resp.status(), StatusCode::FORBIDDEN);
let body = response_body(resp).await;
assert_eq!(body["error"], "forbidden");
}
#[tokio::test]
async fn test_bad_request_response() {
let err = AppError::BadRequest("Invalid input".into());
let resp = err.into_response();
assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
let body = response_body(resp).await;
assert_eq!(body["error"], "bad_request");
}
#[tokio::test]
async fn test_validation_response() {
let err = AppError::Validation("Theme cannot be empty".into());
let resp = err.into_response();
assert_eq!(resp.status(), StatusCode::UNPROCESSABLE_ENTITY);
let body = response_body(resp).await;
assert_eq!(body["error"], "validation_error");
}
#[tokio::test]
async fn test_internal_error_hides_details() {
let err = AppError::Internal(anyhow::anyhow!("secret DB password leaked"));
let resp = err.into_response();
assert_eq!(resp.status(), StatusCode::INTERNAL_SERVER_ERROR);
let body = response_body(resp).await;
assert_eq!(body["error"], "internal_error");
assert_eq!(body["message"], "An internal error occurred");
// The secret detail must NOT be in the response
assert!(!body["message"].as_str().unwrap().contains("secret"));
}
#[tokio::test]
async fn test_rate_limited_response() {
let err = AppError::RateLimited("Too many requests, try again later".into());
let resp = err.into_response();
assert_eq!(resp.status(), StatusCode::TOO_MANY_REQUESTS);
let body = response_body(resp).await;
assert_eq!(body["error"], "rate_limited");
}
}