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