Skip to main content

codoseo_web/
error.rs

1//! One error type for every handler. Spec section 12: the web side shows friendly error pages,
2//! errors inside htmx partials show inline, and a Postgres outage is a 503 page.
3//!
4//! `AppError::into_response` only records *what* went wrong (an [`ErrorInfo`] extension); the
5//! [`error_pages`] middleware, which can see whether the request came from htmx, turns that
6//! into either a full page or an inline fragment.
7
8use askama::Template;
9use axum::extract::Request;
10use axum::http::{HeaderValue, StatusCode, header};
11use axum::middleware::Next;
12use axum::response::{Html, IntoResponse, Response};
13
14use crate::render::is_htmx;
15
16#[derive(Debug, thiserror::Error)]
17pub enum AppError {
18    #[error("not found")]
19    NotFound,
20    #[error("{0}")]
21    BadRequest(String),
22    #[error("{0}")]
23    Forbidden(String),
24    /// The request conflicts with current state, e.g. a crawl is already queued.
25    #[error("{0}")]
26    Conflict(String),
27    /// A plan limit was reached (sites, manual crawls).
28    #[error("{0}")]
29    Limit(String),
30    /// Postgres is unreachable.
31    #[error("database unavailable")]
32    Unavailable,
33    #[error("internal error: {0}")]
34    Internal(String),
35}
36
37impl AppError {
38    pub fn internal(e: impl std::fmt::Display) -> AppError {
39        AppError::Internal(e.to_string())
40    }
41
42    fn parts(&self) -> (StatusCode, &'static str, String) {
43        match self {
44            AppError::NotFound => (
45                StatusCode::NOT_FOUND,
46                "Page not found",
47                "That page doesn't exist, or it belongs to another account.".to_owned(),
48            ),
49            AppError::BadRequest(m) => (StatusCode::BAD_REQUEST, "Check that again", m.clone()),
50            AppError::Forbidden(m) => (StatusCode::FORBIDDEN, "Not allowed", m.clone()),
51            AppError::Conflict(m) => (StatusCode::CONFLICT, "Already in progress", m.clone()),
52            AppError::Limit(m) => (StatusCode::FORBIDDEN, "Plan limit reached", m.clone()),
53            AppError::Unavailable => (
54                StatusCode::SERVICE_UNAVAILABLE,
55                "We'll be right back",
56                "CodoSEO can't reach its database right now. This page retries on its own."
57                    .to_owned(),
58            ),
59            AppError::Internal(_) => (
60                StatusCode::INTERNAL_SERVER_ERROR,
61                "Something went wrong",
62                "An unexpected error happened on our side. Try again in a moment.".to_owned(),
63            ),
64        }
65    }
66}
67
68/// Connection-level failures mean Postgres is down or unreachable: a 503, not a 500.
69pub fn is_unavailable(e: &sqlx::Error) -> bool {
70    match e {
71        sqlx::Error::PoolTimedOut
72        | sqlx::Error::PoolClosed
73        | sqlx::Error::Io(_)
74        | sqlx::Error::Tls(_) => true,
75        // SQLSTATE class 08 is "connection exception"; 57P0x is an admin/crash shutdown.
76        sqlx::Error::Database(db) => db
77            .code()
78            .is_some_and(|c| c.starts_with("08") || c.starts_with("57P0")),
79        _ => false,
80    }
81}
82
83impl From<sqlx::Error> for AppError {
84    fn from(e: sqlx::Error) -> AppError {
85        if matches!(e, sqlx::Error::RowNotFound) {
86            AppError::NotFound
87        } else if is_unavailable(&e) {
88            AppError::Unavailable
89        } else {
90            AppError::Internal(e.to_string())
91        }
92    }
93}
94
95impl From<askama::Error> for AppError {
96    fn from(e: askama::Error) -> AppError {
97        AppError::Internal(format!("template: {e}"))
98    }
99}
100
101/// What went wrong, attached to an error response for [`error_pages`] to render.
102#[derive(Debug, Clone)]
103pub struct ErrorInfo {
104    pub status: StatusCode,
105    pub title: &'static str,
106    pub message: String,
107}
108
109impl IntoResponse for AppError {
110    fn into_response(self) -> Response {
111        if let AppError::Internal(detail) = &self {
112            tracing::error!(%detail, "request failed");
113        }
114        let (status, title, message) = self.parts();
115        let mut res = (status, message.clone()).into_response();
116        res.extensions_mut().insert(ErrorInfo {
117            status,
118            title,
119            message,
120        });
121        res
122    }
123}
124
125#[derive(Template)]
126#[template(path = "error.html")]
127struct ErrorPage<'a> {
128    info: &'a ErrorInfo,
129    retry: bool,
130}
131
132#[derive(Template)]
133#[template(path = "partials/error_inline.html")]
134struct ErrorInline<'a> {
135    info: &'a ErrorInfo,
136}
137
138/// Renders [`ErrorInfo`] responses as a full page, or as an inline fragment for htmx.
139pub async fn error_pages(req: Request, next: Next) -> Response {
140    let htmx = is_htmx(req.headers());
141    let res = next.run(req).await;
142    let Some(info) = res.extensions().get::<ErrorInfo>().cloned() else {
143        return res;
144    };
145    let body = if htmx {
146        ErrorInline { info: &info }.render()
147    } else {
148        ErrorPage {
149            info: &info,
150            retry: info.status == StatusCode::SERVICE_UNAVAILABLE,
151        }
152        .render()
153    };
154    let mut out = match body {
155        Ok(html) => (info.status, Html(html)).into_response(),
156        Err(_) => (info.status, info.message.clone()).into_response(),
157    };
158    if info.status == StatusCode::SERVICE_UNAVAILABLE {
159        out.headers_mut()
160            .insert(header::RETRY_AFTER, HeaderValue::from_static("10"));
161    }
162    out.headers_mut()
163        .insert(header::CACHE_CONTROL, HeaderValue::from_static("no-store"));
164    out
165}
166
167/// The router's fallback: unknown paths get the friendly 404.
168pub async fn not_found() -> AppError {
169    AppError::NotFound
170}