Skip to main content

sl_map_web/
error.rs

1//! Application error type and HTTP mapping.
2
3use axum::response::{IntoResponse, Response};
4// the workspace clippy.toml enforces a project-wide rename for the http
5// `StatusCode` type. `axum::http::StatusCode` is the same underlying type
6// as `reqwest::StatusCode` so clippy demands the `ReqwestStatusCode` alias
7// even though we are not pulling it in from reqwest.
8use 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/// Top-level error for the web service. Wraps the various library and IO
14/// errors so that handlers can use `?` and the central `IntoResponse` impl
15/// turns them into HTTP responses.
16///
17/// The bigger inner errors (`MapError`, `MapTileCacheError`,
18/// `USBNotecardToGridRectangleError`) are boxed so the discriminant stays
19/// small and `Result<T, Error>` doesn't blow up in size.
20#[derive(Debug, thiserror::Error)]
21pub enum Error {
22    /// the request body could not be parsed.
23    #[error("invalid request: {0}")]
24    BadRequest(String),
25    /// the requested job id is unknown or has been evicted.
26    #[error("job not found")]
27    JobNotFound,
28    /// the request asked for a part of a job that does not exist (e.g.
29    /// `image-without-route` when no without-route image was saved).
30    #[error("requested resource not found: {0}")]
31    NotFound(String),
32    /// the job is still running; the requested artifact is not ready yet.
33    #[error("job not finished yet")]
34    JobNotFinished,
35    /// the underlying render job failed with the given message.
36    #[error("render failed: {0}")]
37    RenderFailed(String),
38    /// multipart upload parsing failed.
39    #[error("multipart error: {0}")]
40    Multipart(#[from] axum::extract::multipart::MultipartError),
41    /// a USB notecard line could not be parsed.
42    #[error("could not parse USB notecard: {0}")]
43    USBNotecardParse(#[from] LocationParseError),
44    /// loading a USB notecard from disk failed (only used by the loader
45    /// helper).
46    #[error("could not load USB notecard: {0}")]
47    USBNotecardLoad(#[from] USBNotecardLoadError),
48    /// the region name to grid coordinates cache failed.
49    #[error("region cache error: {0}")]
50    RegionCache(Box<RegionCacheError>),
51    /// converting a USB notecard to a grid rectangle failed.
52    #[error("USB notecard rectangle resolution error: {0}")]
53    USBNotecardToGridRectangle(Box<USBNotecardToGridRectangleError>),
54    /// the map renderer returned an error.
55    #[error("map render error: {0}")]
56    Map(Box<MapError>),
57    /// the map tile cache returned an error.
58    #[error("map tile cache error: {0}")]
59    MapTileCache(Box<MapTileCacheError>),
60    /// image encoding failed.
61    #[error("image encoding error: {0}")]
62    Image(Box<image::ImageError>),
63    /// generic I/O error.
64    #[error("I/O error: {0}")]
65    Io(#[from] std::io::Error),
66    /// JSON serialization failed.
67    #[error("JSON error: {0}")]
68    Json(#[from] serde_json::Error),
69    /// the request lacks a valid session cookie or bearer token.
70    #[error("unauthenticated")]
71    Unauthenticated,
72    /// the authenticated user is not permitted to perform this action.
73    #[error("forbidden: {0}")]
74    Forbidden(String),
75    /// the credentials presented at login do not match a known user.
76    #[error("invalid credentials")]
77    InvalidCredentials,
78    /// the set-password / reset token is missing, malformed, used, or
79    /// expired.
80    #[error("invalid or expired token")]
81    InvalidOrExpiredToken,
82    /// a database operation failed. Detail is logged via `tracing` to avoid
83    /// leaking internals over HTTP.
84    #[error("database error")]
85    Database,
86    /// argon2 hashing or verification failed.
87    #[error("password hash error: {0}")]
88    PasswordHash(String),
89    /// the caller exhausted their per-user token bucket for a
90    /// rate-limited create endpoint. The wrapped value is the number of
91    /// seconds the client should wait before retrying; it is also
92    /// emitted as a `Retry-After` HTTP header.
93    #[error("rate limit exceeded")]
94    TooManyRequests {
95        /// recommended wait in whole seconds.
96        retry_after_secs: u64,
97    },
98    /// error in the GLW event cache (HTTP fetch / on-disk redb / JSON).
99    #[error("GLW event cache error: {0}")]
100    GlwEventCache(#[from] sl_glw::GlwEventCacheError),
101    /// error parsing a TrueType font file.
102    #[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/// Returns `true` when the given sqlx error wraps a SQLite foreign-key
146/// constraint violation. SQLite reports these as either extended code 787
147/// (`SQLITE_CONSTRAINT_FOREIGNKEY`) or, for deferred / trigger-mediated
148/// failures, 1811 (`SQLITE_CONSTRAINT_TRIGGER`). To be robust across both
149/// we also fall back to a substring check of the error message.
150#[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/// Returns `true` when the given sqlx error wraps a SQLite uniqueness
162/// constraint violation. SQLite reports these as extended code 2067
163/// (`SQLITE_CONSTRAINT_UNIQUE`) or, for an `INTEGER PRIMARY KEY` clash,
164/// 1555 (`SQLITE_CONSTRAINT_PRIMARYKEY`). As with [`is_fk_violation`] we
165/// also fall back to a substring check of the error message.
166#[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        // The 500-class variants may carry filesystem paths (`Io`),
204        // argon2 parameter detail (`PasswordHash`), decoder internals
205        // (`Image`), or deserializer state (`Json`). Collapse them to a
206        // generic body — the full Display is still recorded via the
207        // `warn!` below so the operator gets the detail in logs.
208        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        // Several variants embed attacker-supplied bytes (request bodies,
224        // notecard contents, multipart field names, …). Escape control
225        // chars via `char::escape_debug` before logging so a payload like
226        // `\n[ERROR] system breach` or an ANSI escape can't inject a fake
227        // log line or hijack a tailing terminal. Demote 4xx (and the 202
228        // JobNotFinished) to `debug!` to avoid a volume-amplification
229        // DoS: only 5xx variants — which indicate genuine server bugs —
230        // stay at `warn!`. Flip `RUST_LOG=sl_map_web=debug` to recover
231        // client-error visibility for an investigation.
232        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}