1use axum::response::{IntoResponse, Response};
4use axum::http::StatusCode as ReqwestStatusCode;
9use sl_map_apis::map_tiles::{MapError, MapTileCacheError};
10use sl_map_apis::region::{CacheError as RegionCacheError, USBNotecardToGridRectangleError};
11use sl_types::map::{LocationParseError, USBNotecardLoadError};
12
13#[derive(Debug, thiserror::Error)]
21pub enum Error {
22 #[error("invalid request: {0}")]
24 BadRequest(String),
25 #[error("job not found")]
27 JobNotFound,
28 #[error("requested resource not found: {0}")]
31 NotFound(String),
32 #[error("job not finished yet")]
34 JobNotFinished,
35 #[error("render failed: {0}")]
37 RenderFailed(String),
38 #[error("multipart error: {0}")]
40 Multipart(#[from] axum::extract::multipart::MultipartError),
41 #[error("could not parse USB notecard: {0}")]
43 USBNotecardParse(#[from] LocationParseError),
44 #[error("could not load USB notecard: {0}")]
47 USBNotecardLoad(#[from] USBNotecardLoadError),
48 #[error("region cache error: {0}")]
50 RegionCache(Box<RegionCacheError>),
51 #[error("USB notecard rectangle resolution error: {0}")]
53 USBNotecardToGridRectangle(Box<USBNotecardToGridRectangleError>),
54 #[error("map render error: {0}")]
56 Map(Box<MapError>),
57 #[error("map tile cache error: {0}")]
59 MapTileCache(Box<MapTileCacheError>),
60 #[error("image encoding error: {0}")]
62 Image(Box<image::ImageError>),
63 #[error("I/O error: {0}")]
65 Io(#[from] std::io::Error),
66 #[error("JSON error: {0}")]
68 Json(#[from] serde_json::Error),
69 #[error("unauthenticated")]
71 Unauthenticated,
72 #[error("forbidden: {0}")]
74 Forbidden(String),
75 #[error("invalid credentials")]
77 InvalidCredentials,
78 #[error("invalid or expired token")]
81 InvalidOrExpiredToken,
82 #[error("database error")]
85 Database,
86 #[error("password hash error: {0}")]
88 PasswordHash(String),
89 #[error("rate limit exceeded")]
94 TooManyRequests {
95 retry_after_secs: u64,
97 },
98 #[error("GLW event cache error: {0}")]
100 GlwEventCache(#[from] sl_glw::GlwEventCacheError),
101 #[error("font parse error: {0}")]
103 FontParse(#[from] ab_glyph::InvalidFont),
104}
105
106impl From<MapError> for Error {
107 fn from(value: MapError) -> Self {
108 Self::Map(Box::new(value))
109 }
110}
111
112impl From<sl_map_apis::text::FontError> for Error {
113 fn from(value: sl_map_apis::text::FontError) -> Self {
114 match value {
115 sl_map_apis::text::FontError::Read { source, .. } => Self::Io(source),
116 sl_map_apis::text::FontError::Parse(invalid) => Self::FontParse(invalid),
117 }
118 }
119}
120
121impl From<MapTileCacheError> for Error {
122 fn from(value: MapTileCacheError) -> Self {
123 Self::MapTileCache(Box::new(value))
124 }
125}
126
127impl From<RegionCacheError> for Error {
128 fn from(value: RegionCacheError) -> Self {
129 Self::RegionCache(Box::new(value))
130 }
131}
132
133impl From<USBNotecardToGridRectangleError> for Error {
134 fn from(value: USBNotecardToGridRectangleError) -> Self {
135 Self::USBNotecardToGridRectangle(Box::new(value))
136 }
137}
138
139impl From<image::ImageError> for Error {
140 fn from(value: image::ImageError) -> Self {
141 Self::Image(Box::new(value))
142 }
143}
144
145#[must_use]
151pub fn is_fk_violation(err: &sqlx::Error) -> bool {
152 let sqlx::Error::Database(db) = err else {
153 return false;
154 };
155 if matches!(db.code().as_deref(), Some("787" | "1811")) {
156 return true;
157 }
158 db.message().contains("FOREIGN KEY")
159}
160
161#[must_use]
167pub fn is_unique_violation(err: &sqlx::Error) -> bool {
168 let sqlx::Error::Database(db) = err else {
169 return false;
170 };
171 if matches!(db.code().as_deref(), Some("2067" | "1555")) {
172 return true;
173 }
174 db.message().contains("UNIQUE constraint failed")
175}
176
177impl IntoResponse for Error {
178 fn into_response(self) -> Response {
179 let status = match &self {
180 Self::BadRequest(_)
181 | Self::Multipart(_)
182 | Self::USBNotecardParse(_)
183 | Self::USBNotecardLoad(_)
184 | Self::InvalidOrExpiredToken
185 | Self::FontParse(_) => ReqwestStatusCode::BAD_REQUEST,
186 Self::Unauthenticated | Self::InvalidCredentials => ReqwestStatusCode::UNAUTHORIZED,
187 Self::Forbidden(_) => ReqwestStatusCode::FORBIDDEN,
188 Self::JobNotFound | Self::NotFound(_) => ReqwestStatusCode::NOT_FOUND,
189 Self::JobNotFinished => ReqwestStatusCode::ACCEPTED,
190 Self::RenderFailed(_) => ReqwestStatusCode::CONFLICT,
191 Self::TooManyRequests { .. } => ReqwestStatusCode::TOO_MANY_REQUESTS,
192 Self::USBNotecardToGridRectangle(_)
193 | Self::RegionCache(_)
194 | Self::Map(_)
195 | Self::MapTileCache(_)
196 | Self::Image(_)
197 | Self::Io(_)
198 | Self::Json(_)
199 | Self::Database
200 | Self::PasswordHash(_)
201 | Self::GlwEventCache(_) => ReqwestStatusCode::INTERNAL_SERVER_ERROR,
202 };
203 let body_text = match &self {
209 Self::Io(_)
210 | Self::Image(_)
211 | Self::Json(_)
212 | Self::Map(_)
213 | Self::MapTileCache(_)
214 | Self::RegionCache(_)
215 | Self::USBNotecardToGridRectangle(_)
216 | Self::PasswordHash(_) => "internal error".to_owned(),
217 _ => format!("{self}"),
218 };
219 let retry_after = match &self {
220 Self::TooManyRequests { retry_after_secs } => Some(*retry_after_secs),
221 _ => None,
222 };
223 let display = self.to_string();
233 let safe: String = display.chars().flat_map(char::escape_debug).collect();
234 if status.is_server_error() {
235 tracing::warn!("request failed: {safe}");
236 } else {
237 tracing::debug!("request failed: {safe}");
238 }
239 let body = serde_json::json!({ "error": body_text }).to_string();
240 let mut response = (status, body).into_response();
241 if let Ok(value) = axum::http::HeaderValue::from_str("application/json; charset=utf-8") {
242 drop(
243 response
244 .headers_mut()
245 .insert(axum::http::header::CONTENT_TYPE, value),
246 );
247 }
248 if let Some(secs) = retry_after
249 && let Ok(value) = axum::http::HeaderValue::from_str(&secs.to_string())
250 {
251 drop(
252 response
253 .headers_mut()
254 .insert(axum::http::header::RETRY_AFTER, value),
255 );
256 }
257 response
258 }
259}