Skip to main content

heldar_kernel/
error.rs

1use axum::http::StatusCode;
2use axum::response::{IntoResponse, Response};
3use axum::Json;
4use serde_json::json;
5
6/// Application error type, convertible into an HTTP response.
7#[derive(Debug, thiserror::Error)]
8pub enum AppError {
9    #[error("{0}")]
10    NotFound(String),
11    #[error("{0}")]
12    BadRequest(String),
13    #[error("{0}")]
14    Conflict(String),
15    #[error("{0}")]
16    Unauthorized(String),
17    #[error("{0}")]
18    Forbidden(String),
19    #[error(transparent)]
20    Db(#[from] sqlx::Error),
21    #[error(transparent)]
22    Other(#[from] anyhow::Error),
23}
24
25pub type AppResult<T> = Result<T, AppError>;
26
27impl IntoResponse for AppError {
28    fn into_response(self) -> Response {
29        let (status, msg) = match self {
30            AppError::NotFound(m) => (StatusCode::NOT_FOUND, m),
31            AppError::BadRequest(m) => (StatusCode::BAD_REQUEST, m),
32            AppError::Conflict(m) => (StatusCode::CONFLICT, m),
33            AppError::Unauthorized(m) => (StatusCode::UNAUTHORIZED, m),
34            AppError::Forbidden(m) => (StatusCode::FORBIDDEN, m),
35            AppError::Db(sqlx::Error::RowNotFound) => {
36                (StatusCode::NOT_FOUND, "resource not found".to_string())
37            }
38            // Pool exhausted: all connections were busy past the acquire timeout. Transient
39            // saturation, not a server fault — ask the client to retry.
40            AppError::Db(sqlx::Error::PoolTimedOut) => (
41                StatusCode::SERVICE_UNAVAILABLE,
42                "database busy; retry shortly".to_string(),
43            ),
44            // Map common constraint violations to 4xx instead of 500 (e.g. duplicate id,
45            // or a site_id/foreign key that does not exist).
46            AppError::Db(sqlx::Error::Database(ref dbe)) => {
47                use sqlx::error::ErrorKind;
48                // SQLite busy/locked under write contention is transient: the pool's busy_timeout
49                // waits, but if it is ever exceeded the correct answer is 503 + Retry-After, not a
50                // 500. (SQLITE_BUSY=5 and its extended codes 261/517/773.)
51                let busy = matches!(
52                    dbe.code().as_deref(),
53                    Some("5") | Some("261") | Some("517") | Some("773")
54                ) || {
55                    let m = dbe.message().to_ascii_lowercase();
56                    m.contains("database is locked") || m.contains("database is busy")
57                };
58                if busy {
59                    (
60                        StatusCode::SERVICE_UNAVAILABLE,
61                        "database busy; retry shortly".to_string(),
62                    )
63                } else {
64                    match dbe.kind() {
65                        ErrorKind::UniqueViolation => {
66                            (StatusCode::CONFLICT, "resource already exists".to_string())
67                        }
68                        ErrorKind::ForeignKeyViolation => (
69                            StatusCode::BAD_REQUEST,
70                            "referenced resource does not exist (check site_id)".to_string(),
71                        ),
72                        _ => {
73                            tracing::error!(error = %dbe, "database error");
74                            (
75                                StatusCode::INTERNAL_SERVER_ERROR,
76                                "database error".to_string(),
77                            )
78                        }
79                    }
80                }
81            }
82            AppError::Db(e) => {
83                tracing::error!(error = %e, "database error");
84                (
85                    StatusCode::INTERNAL_SERVER_ERROR,
86                    "database error".to_string(),
87                )
88            }
89            AppError::Other(e) => {
90                tracing::error!(error = ?e, "internal error");
91                (
92                    StatusCode::INTERNAL_SERVER_ERROR,
93                    "internal error".to_string(),
94                )
95            }
96        };
97        let mut resp = (status, Json(json!({ "error": msg }))).into_response();
98        // A retryable transient (busy/saturated) gets a Retry-After hint.
99        if status == StatusCode::SERVICE_UNAVAILABLE {
100            resp.headers_mut().insert(
101                axum::http::header::RETRY_AFTER,
102                axum::http::HeaderValue::from_static("1"),
103            );
104        }
105        resp
106    }
107}