1use axum::http::StatusCode;
2use axum::response::{Html, IntoResponse, Response};
3
4pub type Result<T = (), E = Error> = std::result::Result<T, E>;
6
7#[non_exhaustive]
22pub enum Error {
23 BadRequest(String),
25 Unauthorized,
27 Forbidden,
29 NotFound,
31 PageExpired,
33 TooManyRequests,
35 ServiceUnavailable,
37 Validation(crate::validation::ValidationError),
39 Status(StatusCode, String),
42 Internal(anyhow::Error),
44}
45
46pub fn abort(status: StatusCode, message: impl Into<String>) -> Error {
49 Error::Status(status, message.into())
50}
51
52pub fn abort_if(condition: bool, status: StatusCode, message: impl Into<String>) -> Result {
54 if condition {
55 Err(abort(status, message))
56 } else {
57 Ok(())
58 }
59}
60
61pub fn abort_unless(condition: bool, status: StatusCode, message: impl Into<String>) -> Result {
63 abort_if(!condition, status, message)
64}
65
66#[derive(Debug)]
69struct Permanent(anyhow::Error);
70
71impl std::fmt::Display for Permanent {
72 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
73 std::fmt::Display::fmt(&self.0, f)
74 }
75}
76
77impl std::error::Error for Permanent {
78 fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
79 self.0.chain().nth(1)
80 }
81}
82
83impl Error {
84 pub fn permanent(err: impl Into<anyhow::Error>) -> Self {
87 Self::Internal(anyhow::Error::new(Permanent(err.into())))
88 }
89
90 pub fn permanent_message(message: impl std::fmt::Display) -> Self {
93 Self::permanent(anyhow::anyhow!("{message}"))
94 }
95
96 pub fn is_permanent(&self) -> bool {
98 match self {
99 Self::Internal(err) => err.chain().any(|e| e.is::<Permanent>()),
100 _ => false,
101 }
102 }
103
104 pub fn is_retryable(&self) -> bool {
107 match self {
108 Self::Internal(err) => err.chain().any(|e| {
109 e.downcast_ref::<crate::db::DbError>()
110 .is_some_and(crate::db::DbError::is_retryable)
111 }),
112 _ => false,
113 }
114 }
115
116 pub fn is_unique_violation(&self) -> bool {
119 match self {
120 Self::Internal(err) => err.chain().any(|e| {
121 e.downcast_ref::<crate::db::DbError>()
122 .is_some_and(crate::db::DbError::is_unique_violation)
123 || matches!(
124 e.downcast_ref::<sqlx::Error>(),
125 Some(sqlx::Error::Database(db)) if db.is_unique_violation()
126 )
127 }),
128 _ => false,
129 }
130 }
131
132 pub fn status(&self) -> StatusCode {
134 match self {
135 Self::BadRequest(_) => StatusCode::BAD_REQUEST,
136 Self::Unauthorized => StatusCode::UNAUTHORIZED,
137 Self::Forbidden => StatusCode::FORBIDDEN,
138 Self::NotFound => StatusCode::NOT_FOUND,
139 Self::PageExpired => StatusCode::from_u16(419).expect("valid status code"),
140 Self::TooManyRequests => StatusCode::TOO_MANY_REQUESTS,
141 Self::ServiceUnavailable => StatusCode::SERVICE_UNAVAILABLE,
142 Self::Validation(_) => StatusCode::UNPROCESSABLE_ENTITY,
143 Self::Status(status, _) => *status,
144 Self::Internal(_) if self.is_unique_violation() => StatusCode::CONFLICT,
146 Self::Internal(_) => StatusCode::INTERNAL_SERVER_ERROR,
147 }
148 }
149}
150
151impl std::fmt::Debug for Error {
153 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
154 match self {
155 Self::Internal(err) => write!(f, "{err:?}"),
156 Self::BadRequest(msg) => write!(f, "bad request: {msg}"),
157 Self::Status(status, msg) => write!(f, "{}: {msg}", status.as_u16()),
158 Self::Validation(err) => write!(f, "validation failed: {:?}", err.errors),
159 other => write!(f, "{}", reason(other.status())),
160 }
161 }
162}
163
164impl<E: Into<anyhow::Error>> From<E> for Error {
165 fn from(err: E) -> Self {
166 Self::Internal(err.into())
167 }
168}
169
170#[derive(Debug, Clone)]
173pub(crate) struct ErrorPage {
174 pub status: StatusCode,
175 pub detail: Option<String>,
177 pub debug_detail: Option<String>,
179 pub template: Option<String>,
182}
183
184impl ErrorPage {
185 pub fn shown_detail(&self, debug: bool) -> Option<&str> {
187 self.detail.as_deref().or(if debug {
188 self.debug_detail.as_deref()
189 } else {
190 None
191 })
192 }
193}
194
195impl ErrorPage {
196 pub fn json(&self, debug: bool) -> Response {
198 let message = self
199 .shown_detail(debug)
200 .unwrap_or_else(|| reason(self.status));
201 (
202 self.status,
203 axum::Json(serde_json::json!({ "message": message })),
204 )
205 .into_response()
206 }
207}
208
209pub(crate) fn wants_json(headers: &axum::http::HeaderMap) -> bool {
211 let header = |name| headers.get(name).and_then(|v| v.to_str().ok());
212 header(axum::http::header::ACCEPT).is_some_and(|v| v.contains("application/json"))
213 || header(axum::http::header::CONTENT_TYPE)
214 .is_some_and(|v| v.starts_with("application/json"))
215}
216
217pub(crate) fn panic_message(panic: &(dyn std::any::Any + Send)) -> String {
219 panic
220 .downcast_ref::<String>()
221 .cloned()
222 .or_else(|| panic.downcast_ref::<&str>().map(|s| (*s).to_owned()))
223 .unwrap_or_else(|| "(no message)".into())
224}
225
226pub(crate) fn reason(status: StatusCode) -> &'static str {
227 match status.as_u16() {
228 419 => "Page Expired",
229 _ => status.canonical_reason().unwrap_or("Error"),
230 }
231}
232
233impl From<crate::validation::ValidationError> for Error {
234 fn from(err: crate::validation::ValidationError) -> Self {
235 Self::Validation(err)
236 }
237}
238
239impl From<crate::validation::Errors> for Error {
240 fn from(errors: crate::validation::Errors) -> Self {
241 Self::Validation(errors.into())
242 }
243}
244
245impl IntoResponse for Error {
246 fn into_response(self) -> Response {
247 if let Self::Validation(err) = self {
248 return err.into_response();
249 }
250 let status = self.status();
251 let mut template = None;
252 let (detail, debug_detail) = match &self {
253 Self::Internal(err) => {
254 tracing::error!(error = ?err, "internal server error");
255 crate::report::request_error(err);
256 template = err
257 .chain()
258 .find_map(|e| e.downcast_ref::<minijinja::Error>())
259 .map(|e| e.display_debug_info().to_string())
260 .filter(|info| !info.trim().is_empty());
261 (None, Some(format!("{err:?}")))
262 }
263 Self::BadRequest(msg) => (Some(msg.clone()), None),
264 Self::Status(_, msg) if !msg.is_empty() => (Some(msg.clone()), None),
265 _ => (None, None),
266 };
267 let mut res = (status, Html(error_page(status, detail.as_deref()))).into_response();
270 res.extensions_mut().insert(ErrorPage {
271 status,
272 detail,
273 debug_detail,
274 template,
275 });
276 res
277 }
278}
279
280pub(crate) fn error_page(status: StatusCode, detail: Option<&str>) -> String {
282 let code = status.as_u16();
283 let reason = reason(status);
284 let detail = detail
285 .map(|d| format!("<pre>{}</pre>", escape(d)))
286 .unwrap_or_default();
287 format!(
288 "<!doctype html><html><head><meta charset=\"utf-8\">\
289 <meta name=\"viewport\" content=\"width=device-width, initial-scale=1\">\
290 <title>{code} {reason}</title></head>\
291 <body><h1>{code} · {reason}</h1>{detail}</body></html>"
292 )
293}
294
295fn escape(s: &str) -> String {
296 s.replace('&', "&")
297 .replace('<', "<")
298 .replace('>', ">")
299 .replace('"', """)
300}
301
302#[cfg(test)]
303mod tests {
304 use super::*;
305
306 #[test]
307 fn validation_errors_answer_422_and_other_errors_500() {
308 let mut errors = crate::validation::Errors::new();
309 errors.add("name", "The name field is required.");
310 let err = Error::Validation(crate::validation::ValidationError::new(errors));
311 assert_eq!(err.status(), StatusCode::UNPROCESSABLE_ENTITY);
312 let plain = Error::from(anyhow::anyhow!("no database involved"));
313 assert!(!plain.is_unique_violation());
314 assert_eq!(plain.status(), StatusCode::INTERNAL_SERVER_ERROR);
315 }
316
317 #[tokio::test]
320 async fn a_raw_sqlx_unique_violation_is_a_conflict() {
321 let db = crate::db::connect(&crate::Config::default()).await.unwrap();
322 let Some(pool) = db.sqlite() else {
323 return; };
325 sqlx::raw_sql("CREATE TABLE tags (name TEXT UNIQUE); INSERT INTO tags VALUES ('a')")
326 .execute(pool)
327 .await
328 .unwrap();
329 let raw = sqlx::raw_sql("INSERT INTO tags VALUES ('a')")
330 .execute(pool)
331 .await
332 .unwrap_err();
333 let err = Error::from(anyhow::Error::new(raw));
334 assert!(err.is_unique_violation());
335 assert_eq!(err.status(), StatusCode::CONFLICT);
336 }
337}