use axum::http::StatusCode; use axum::response::{IntoResponse, Response}; use axum::Json; use serde_json::json; use crate::storage::StorageError; #[derive(thiserror::Error, Debug)] pub enum AppError { #[error("not found")] NotFound, #[error("invalid input: {0}")] InvalidInput(String), #[error("unauthenticated")] Unauthenticated, #[error("forbidden")] Forbidden, #[error("conflict: {0}")] Conflict(String), #[error("payload too large: {0}")] PayloadTooLarge(String), #[error("unsupported media type: {0}")] UnsupportedMediaType(String), /// 503 โ€” a feature is currently unavailable, distinct from a 5xx /// internal error. Used when admin actions require the crawler /// daemon but it's been disabled (`CRAWLER_DAEMON=false`). #[error("service unavailable: {0}")] ServiceUnavailable(String), /// 429 with an optional `Retry-After` header value (in seconds). #[error("too many requests")] TooManyRequests { retry_after_secs: Option, }, /// Semantic per-field validation failure. `details` is rendered into the /// envelope so the client can highlight the bad field(s). #[error("validation failed")] ValidationFailed { message: String, details: serde_json::Value, }, #[error(transparent)] Database(#[from] sqlx::Error), #[error(transparent)] Storage(#[from] StorageError), #[error(transparent)] Other(#[from] anyhow::Error), } pub type AppResult = Result; impl AppError { /// Stable, snake_case code that clients pattern-match on. Every top-level /// variant is matched explicitly โ€” adding a new variant without giving it /// a code is a compile error, on purpose. pub fn code(&self) -> &'static str { match self { AppError::NotFound => "not_found", AppError::InvalidInput(_) => "invalid_input", AppError::Unauthenticated => "unauthenticated", AppError::Forbidden => "forbidden", AppError::Conflict(_) => "conflict", AppError::PayloadTooLarge(_) => "payload_too_large", AppError::UnsupportedMediaType(_) => "unsupported_media_type", AppError::ServiceUnavailable(_) => "service_unavailable", AppError::TooManyRequests { .. } => "too_many_requests", AppError::ValidationFailed { .. } => "validation_failed", AppError::Database(sqlx::Error::RowNotFound) => "not_found", AppError::Database(_) => "internal_error", AppError::Storage(StorageError::NotFound) => "not_found", AppError::Storage(StorageError::BadKey) => "bad_file_key", AppError::Storage(StorageError::Io(_)) => "internal_error", AppError::Other(_) => "internal_error", } } } impl IntoResponse for AppError { fn into_response(self) -> Response { let code = self.code(); let (status, message, details) = match &self { AppError::NotFound => (StatusCode::NOT_FOUND, "not found".to_string(), None), AppError::InvalidInput(msg) => (StatusCode::BAD_REQUEST, msg.clone(), None), AppError::Unauthenticated => { (StatusCode::UNAUTHORIZED, "unauthenticated".to_string(), None) } AppError::Forbidden => (StatusCode::FORBIDDEN, "forbidden".to_string(), None), AppError::Conflict(msg) => (StatusCode::CONFLICT, msg.clone(), None), AppError::PayloadTooLarge(msg) => { (StatusCode::PAYLOAD_TOO_LARGE, msg.clone(), None) } AppError::UnsupportedMediaType(msg) => { (StatusCode::UNSUPPORTED_MEDIA_TYPE, msg.clone(), None) } AppError::ServiceUnavailable(msg) => { (StatusCode::SERVICE_UNAVAILABLE, msg.clone(), None) } AppError::TooManyRequests { retry_after_secs } => { // Emit `Retry-After: N` (RFC 6585 ยง4) so a well-behaved // client can back off correctly. Done by building the // response by hand below โ€” the `(status, headers, // body)` tuple shape doesn't fit the standard // `(status, body)` IntoResponse path for the other // variants. let body = json!({ "error": { "code": code, "message": "too many requests; slow down", } }); let mut resp = (StatusCode::TOO_MANY_REQUESTS, Json(body)).into_response(); if let Some(secs) = retry_after_secs { // `HeaderValue: From` skips both the // intermediate `String` allocation and the // fallible-by-shape `from_str` path. resp.headers_mut().insert( axum::http::header::RETRY_AFTER, axum::http::HeaderValue::from(*secs), ); } return resp; } AppError::ValidationFailed { message, details } => ( StatusCode::UNPROCESSABLE_ENTITY, message.clone(), Some(details.clone()), ), AppError::Database(sqlx::Error::RowNotFound) => { (StatusCode::NOT_FOUND, "not found".to_string(), None) } AppError::Storage(StorageError::NotFound) => { (StatusCode::NOT_FOUND, "not found".to_string(), None) } AppError::Storage(StorageError::BadKey) => ( StatusCode::BAD_REQUEST, "invalid file key".to_string(), None, ), AppError::Database(_) | AppError::Storage(_) | AppError::Other(_) => { tracing::error!(error = ?self, "internal error"); ( StatusCode::INTERNAL_SERVER_ERROR, "internal error".to_string(), None, ) } }; let body = match details { Some(d) => json!({ "error": { "code": code, "message": message, "details": d } }), None => json!({ "error": { "code": code, "message": message } }), }; (status, Json(body)).into_response() } } #[cfg(test)] mod tests { use super::*; #[test] fn codes_are_stable() { assert_eq!(AppError::NotFound.code(), "not_found"); assert_eq!(AppError::InvalidInput("x".into()).code(), "invalid_input"); assert_eq!(AppError::Unauthenticated.code(), "unauthenticated"); assert_eq!(AppError::Forbidden.code(), "forbidden"); assert_eq!(AppError::Conflict("x".into()).code(), "conflict"); assert_eq!(AppError::PayloadTooLarge("x".into()).code(), "payload_too_large"); assert_eq!( AppError::UnsupportedMediaType("x".into()).code(), "unsupported_media_type" ); assert_eq!( AppError::ValidationFailed { message: "x".into(), details: json!({}), } .code(), "validation_failed" ); assert_eq!(AppError::Storage(StorageError::BadKey).code(), "bad_file_key"); assert_eq!(AppError::Storage(StorageError::NotFound).code(), "not_found"); assert_eq!(AppError::Database(sqlx::Error::RowNotFound).code(), "not_found"); assert_eq!(AppError::Other(anyhow::anyhow!("oops")).code(), "internal_error"); } }