Skip to main content

renox_core/
error.rs

1use axum::http::StatusCode;
2use axum::response::{Html, IntoResponse, Response};
3
4/// The result type handlers return; `Error` by default.
5pub type Result<T = (), E = Error> = std::result::Result<T, E>;
6
7/// The error type handlers return. Any `anyhow`-compatible error converts into
8/// `Error::Internal` with `?`.
9///
10/// For a status without its own variant (402, 409, 410, …) use
11/// `Error::Status`, or [`abort`] / [`abort_if`]:
12///
13/// ```
14/// # use renox::prelude::*;
15/// # struct Order { paid: bool }
16/// fn download(order: &Order) -> Result<&'static str> {
17///     renox::abort_if(!order.paid, StatusCode::PAYMENT_REQUIRED, "Pay for the order first.")?;
18///     Ok("the file")
19/// }
20/// ```
21#[non_exhaustive]
22pub enum Error {
23    /// 400, with a message that is safe to show visitors.
24    BadRequest(String),
25    /// 401: the request needs a logged-in user.
26    Unauthorized,
27    /// 403: the user may not do this.
28    Forbidden,
29    /// 404.
30    NotFound,
31    /// The CSRF token was missing or wrong, usually because the session expired.
32    PageExpired,
33    /// A rate limit was hit; see `Routes::throttle`.
34    TooManyRequests,
35    /// The app is in maintenance mode.
36    ServiceUnavailable,
37    /// Invalid input; see `ValidationError`.
38    Validation(crate::validation::ValidationError),
39    /// Any status, with a message that is safe to show visitors (on the
40    /// error page, or as `message` in JSON).
41    Status(StatusCode, String),
42    /// 500 (409 for a unique-constraint violation); details are shown only in debug.
43    Internal(anyhow::Error),
44}
45
46/// An error with `status` and a message shown to the visitor, for
47/// `return Err(abort(…))`. See [`abort_if`] for a condition.
48pub fn abort(status: StatusCode, message: impl Into<String>) -> Error {
49    Error::Status(status, message.into())
50}
51
52/// `Err(abort(status, message))` when `condition` holds, for `?`.
53pub 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
61/// `Err(abort(status, message))` unless `condition` holds, for `?`.
62pub fn abort_unless(condition: bool, status: StatusCode, message: impl Into<String>) -> Result {
63    abort_if(!condition, status, message)
64}
65
66/// An error that retrying can't fix; see [`Error::permanent`]. It shows as
67/// the error it wraps, and stays findable in the chain under more context.
68#[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    /// An error that retrying can't fix, e.g. an invalid email address. A job
85    /// that returns one goes to `failed_jobs` at once instead of being retried.
86    pub fn permanent(err: impl Into<anyhow::Error>) -> Self {
87        Self::Internal(anyhow::Error::new(Permanent(err.into())))
88    }
89
90    /// [`Error::permanent`] with just a message:
91    /// `Err(Error::permanent_message("the card was declined"))`.
92    pub fn permanent_message(message: impl std::fmt::Display) -> Self {
93        Self::permanent(anyhow::anyhow!("{message}"))
94    }
95
96    /// Whether the error was made with [`Error::permanent`].
97    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    /// Whether the error is a database conflict that retrying may clear (see
105    /// `DbError::is_retryable`).
106    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    /// Whether the error is a database unique-constraint violation, e.g. a
117    /// second sign-up with the same email racing past the `unique` rule.
118    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    /// The HTTP status this error responds with.
133    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            // A duplicate that got past validation (e.g. two requests at once).
145            Self::Internal(_) if self.is_unique_violation() => StatusCode::CONFLICT,
146            Self::Internal(_) => StatusCode::INTERNAL_SERVER_ERROR,
147        }
148    }
149}
150
151/// Readable when `main` returns an error: the message and its causes.
152impl 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/// Marks an error response so the view middleware can render it with
171/// `errors/{status}.html` or the built-in `renox/error.html`.
172#[derive(Debug, Clone)]
173pub(crate) struct ErrorPage {
174    pub status: StatusCode,
175    /// Safe to show anyone, e.g. a bad request's message.
176    pub detail: Option<String>,
177    /// The internal error's chain, shown only when the app's `APP_DEBUG` is on.
178    pub debug_detail: Option<String>,
179    /// For a template error, where it happened with the lines around it;
180    /// shown only with `APP_DEBUG`.
181    pub template: Option<String>,
182}
183
184impl ErrorPage {
185    /// What to show on the page for an app with this debug setting.
186    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    /// The error as `{"message": …}` for API clients, with the same status.
197    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
209/// Whether the client asked for JSON: `Accept: application/json`, or a JSON body.
210pub(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
217/// The text of a caught panic.
218pub(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        // The view middleware renders the page, adding the internal detail
268        // only for apps in debug mode; this plain body never includes it.
269        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
280/// Plain page used without the view middleware or when the error template fails.
281pub(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('&', "&amp;")
297        .replace('<', "&lt;")
298        .replace('>', "&gt;")
299        .replace('"', "&quot;")
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    /// A unique violation from sqlx itself (an app querying its pool
318    /// directly, then `?`) is a 409 like one from `renox::db`.
319    #[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; // the PostgreSQL run: the same check, another driver
324        };
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}