1use 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 #[error("{0}")]
26 Conflict(String),
27 #[error("{0}")]
29 Limit(String),
30 #[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
68pub 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 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#[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
138pub 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
167pub async fn not_found() -> AppError {
169 AppError::NotFound
170}