1use 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 #[error("{0}")]
31 Unavailable(String),
32 #[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#[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 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 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 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}