Skip to main content

teamcraft/
error.rs

1//! Application error type shared by services, the JSON API and the web UI.
2//!
3//! [`AppError`] renders as a JSON error body. The web UI router installs a
4//! middleware that picks up the [`ErrorInfo`] response extension and replaces
5//! the body with an HTML error page (or a redirect to the login page).
6
7use axum::{
8    http::StatusCode,
9    response::{IntoResponse, Response},
10    Json,
11};
12use serde_json::json;
13
14#[derive(Debug, thiserror::Error)]
15pub enum AppError {
16    #[error("{0}")]
17    NotFound(String),
18    #[error("{0}")]
19    BadRequest(String),
20    #[error("{0}")]
21    Conflict(String),
22    #[error("{0}")]
23    Forbidden(String),
24    #[error("Authentication required.")]
25    Unauthorized,
26    #[error("{0}")]
27    PayloadTooLarge(String),
28    /// An external service the request depends on failed or is busy
29    /// (`503`); the message is shown to the user, nothing is reported.
30    #[error("{0}")]
31    Unavailable(String),
32    /// Too many failed attempts (`429` with `Retry-After`); the message is
33    /// shown to the user, `retry_after` says when the next attempt counts.
34    #[error("{message}")]
35    TooManyRequests {
36        message: String,
37        retry_after: std::time::Duration,
38    },
39    #[error("database error: {0}")]
40    Database(#[from] sqlx::Error),
41    #[error("I/O error: {0}")]
42    Io(#[from] std::io::Error),
43    #[error("template error: {0}")]
44    Template(#[from] minijinja::Error),
45    #[error(transparent)]
46    Other(#[from] anyhow::Error),
47}
48
49pub type AppResult<T> = Result<T, AppError>;
50
51/// Error details attached to error responses as a response extension.
52#[derive(Clone, Debug)]
53pub struct ErrorInfo {
54    pub status: StatusCode,
55    pub code: &'static str,
56    pub message: String,
57}
58
59impl AppError {
60    pub fn not_found(what: impl Into<String>) -> Self {
61        AppError::NotFound(what.into())
62    }
63
64    pub fn status(&self) -> StatusCode {
65        match self {
66            AppError::NotFound(_) => StatusCode::NOT_FOUND,
67            AppError::BadRequest(_) => StatusCode::BAD_REQUEST,
68            AppError::Conflict(_) => StatusCode::CONFLICT,
69            AppError::Forbidden(_) => StatusCode::FORBIDDEN,
70            AppError::Unauthorized => StatusCode::UNAUTHORIZED,
71            AppError::PayloadTooLarge(_) => StatusCode::PAYLOAD_TOO_LARGE,
72            AppError::Unavailable(_) => StatusCode::SERVICE_UNAVAILABLE,
73            AppError::TooManyRequests { .. } => StatusCode::TOO_MANY_REQUESTS,
74            AppError::Database(sqlx::Error::RowNotFound) => StatusCode::NOT_FOUND,
75            AppError::Database(_)
76            | AppError::Io(_)
77            | AppError::Template(_)
78            | AppError::Other(_) => StatusCode::INTERNAL_SERVER_ERROR,
79        }
80    }
81
82    pub fn code(&self) -> &'static str {
83        match self {
84            AppError::NotFound(_) => "not_found",
85            AppError::BadRequest(_) => "bad_request",
86            AppError::Conflict(_) => "conflict",
87            AppError::Forbidden(_) => "forbidden",
88            AppError::Unauthorized => "unauthorized",
89            AppError::PayloadTooLarge(_) => "payload_too_large",
90            AppError::Unavailable(_) => "unavailable",
91            AppError::TooManyRequests { .. } => "too_many_requests",
92            AppError::Database(sqlx::Error::RowNotFound) => "not_found",
93            AppError::Database(_)
94            | AppError::Io(_)
95            | AppError::Template(_)
96            | AppError::Other(_) => "internal",
97        }
98    }
99
100    /// Message that is safe to show to end users (internal details are hidden).
101    pub fn public_message(&self) -> String {
102        match self {
103            AppError::Database(sqlx::Error::RowNotFound) => "Not found.".to_string(),
104            AppError::Database(_)
105            | AppError::Io(_)
106            | AppError::Template(_)
107            | AppError::Other(_) => {
108                "Internal server error. The incident has been logged.".to_string()
109            }
110            other => other.to_string(),
111        }
112    }
113
114    /// Maps unique-constraint violations to a friendly conflict message.
115    pub fn unique_violation(err: sqlx::Error, message: impl Into<String>) -> Self {
116        if let sqlx::Error::Database(db) = &err {
117            if db.is_unique_violation() {
118                return AppError::Conflict(message.into());
119            }
120        }
121        AppError::Database(err)
122    }
123}
124
125impl IntoResponse for AppError {
126    fn into_response(self) -> Response {
127        let status = self.status();
128        if status.is_server_error() && !matches!(self, AppError::Unavailable(_)) {
129            tracing::error!(error = %self, "request failed");
130            crate::notify::report_error(&self.to_string());
131        }
132        let info = ErrorInfo {
133            status,
134            code: self.code(),
135            message: self.public_message(),
136        };
137        let body = json!({ "error": { "code": info.code, "message": info.message } });
138        let mut response = (status, Json(body)).into_response();
139        if let AppError::TooManyRequests { retry_after, .. } = &self {
140            response.headers_mut().insert(
141                axum::http::header::RETRY_AFTER,
142                axum::http::HeaderValue::from(retry_after.as_secs().max(1)),
143            );
144        }
145        response.extensions_mut().insert(info);
146        response
147    }
148}
149
150#[cfg(test)]
151mod tests {
152    use super::*;
153    use std::time::Duration;
154
155    #[test]
156    fn too_many_requests_is_429_with_retry_after() {
157        let e = AppError::TooManyRequests {
158            message: "Too many failed attempts.".into(),
159            retry_after: Duration::from_millis(1500),
160        };
161        assert_eq!(e.code(), "too_many_requests");
162        assert_eq!(e.public_message(), "Too many failed attempts.");
163        let r = e.into_response();
164        assert_eq!(r.status(), StatusCode::TOO_MANY_REQUESTS);
165        assert_eq!(r.headers()[axum::http::header::RETRY_AFTER], "1");
166        // never 0 seconds: a client would retry at once
167        let r = AppError::TooManyRequests {
168            message: String::new(),
169            retry_after: Duration::ZERO,
170        }
171        .into_response();
172        assert_eq!(r.headers()[axum::http::header::RETRY_AFTER], "1");
173    }
174}