//! 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 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"); } }