Skip to main content

modo/error/
core.rs

1//! Core [`Error`] type and [`Result`] alias.
2
3use axum::response::{IntoResponse, Response};
4use http::StatusCode;
5use std::fmt;
6
7/// A type alias for `std::result::Result<T, Error>`.
8pub type Result<T> = std::result::Result<T, Error>;
9
10/// The primary error type for the modo framework.
11///
12/// `Error` carries:
13/// - an HTTP [`StatusCode`] that will be used as the response status,
14/// - a human-readable `message` string,
15/// - an optional structured `details` payload (arbitrary JSON),
16/// - an optional boxed `source` error for causal chaining,
17/// - an optional static `error_code` string that survives the response pipeline,
18/// - an optional static `locale_key` that lets the default error handler translate
19///   the message at response-build time,
20/// - a `lagged` flag used by the SSE broadcaster to signal that a subscriber dropped messages.
21///
22/// # Conversion to HTTP response
23///
24/// Calling `into_response()` produces a JSON body:
25///
26/// ```json
27/// { "error": { "status": 404, "message": "user not found" } }
28/// ```
29///
30/// If [`with_details`](Error::with_details) was called, a `"details"` field is included.
31/// A copy of the error (without `source`) is also stored in response extensions so middleware
32/// can inspect it after the fact.
33///
34/// # Clone behaviour
35///
36/// Cloning an `Error` drops the `source` field because `Box<dyn Error>` is not `Clone`.
37/// The `error_code`, `locale_key`, `details`, and all other fields are preserved.
38pub struct Error {
39    status: StatusCode,
40    message: String,
41    source: Option<Box<dyn std::error::Error + Send + Sync>>,
42    error_code: Option<&'static str>,
43    locale_key: Option<&'static str>,
44    details: Option<serde_json::Value>,
45    lagged: bool,
46}
47
48impl Error {
49    /// Create a new error with the given HTTP status code and message.
50    ///
51    /// Prefer one of the named status-code constructors
52    /// ([`Error::not_found`], [`Error::bad_request`], [`Error::internal`], …)
53    /// when they match. Use `new` only for statuses without a dedicated
54    /// constructor.
55    ///
56    /// # Example
57    ///
58    /// ```rust
59    /// use modo::error::Error;
60    /// use modo::axum::http::StatusCode;
61    ///
62    /// let err = Error::new(StatusCode::IM_A_TEAPOT, "no coffee here");
63    /// assert_eq!(err.status(), StatusCode::IM_A_TEAPOT);
64    /// ```
65    pub fn new(status: StatusCode, message: impl Into<String>) -> Self {
66        Self {
67            status,
68            message: message.into(),
69            source: None,
70            error_code: None,
71            locale_key: None,
72            details: None,
73            lagged: false,
74        }
75    }
76
77    /// Create a new error with a status code, message, and a boxed source error.
78    ///
79    /// `with_source` is a **constructor**, not a builder method — it wraps an
80    /// underlying error at construction time. When you already have an
81    /// [`Error`] and want to attach a cause, use the [`chain`](Error::chain)
82    /// builder instead.
83    ///
84    /// # Example
85    ///
86    /// ```rust
87    /// use modo::error::Error;
88    /// use modo::axum::http::StatusCode;
89    /// use std::io;
90    ///
91    /// let io_err = io::Error::new(io::ErrorKind::NotFound, "missing");
92    /// let err = Error::with_source(StatusCode::INTERNAL_SERVER_ERROR, "read failed", io_err);
93    /// assert!(err.source_as::<io::Error>().is_some());
94    /// ```
95    pub fn with_source(
96        status: StatusCode,
97        message: impl Into<String>,
98        source: impl std::error::Error + Send + Sync + 'static,
99    ) -> Self {
100        Self {
101            status,
102            message: message.into(),
103            source: Some(Box::new(source)),
104            error_code: None,
105            locale_key: None,
106            details: None,
107            lagged: false,
108        }
109    }
110
111    /// Create an error whose message is a translation key.
112    ///
113    /// The `key` is stored in the `locale_key` slot and is also used as the raw
114    /// `message`. When the
115    /// [`default_error_handler`](crate::middleware::default_error_handler) runs
116    /// and a [`Translator`](crate::i18n::Translator) is present in the request
117    /// extensions (installed by
118    /// [`I18nLayer`](crate::i18n::I18nLayer)), it resolves `key` into the
119    /// user-facing string at response-build time. Without that middleware (or
120    /// without a `Translator`), the response falls back to the raw key — making
121    /// the behaviour predictable and easy to spot in logs.
122    ///
123    /// This constructor leaves `error_code`, `details`, and `source` unset;
124    /// chain [`with_code`](Error::with_code),
125    /// [`with_details`](Error::with_details), or [`chain`](Error::chain)
126    /// afterwards if needed.
127    ///
128    /// # Kwargs and logging
129    ///
130    /// Translation kwargs (`{count}`, `{name}`, etc.) are not yet supported at
131    /// the `Error` level — the default handler calls `tr.t(key, &[])` with no
132    /// arguments. When you need interpolation, attach a descriptive fallback
133    /// message via [`Error::with_locale_key`] and run translation (with kwargs)
134    /// inside a custom handler passed to
135    /// [`error_handler`](crate::middleware::error_handler).
136    ///
137    /// Also note that [`Debug`] and [`Display`](std::fmt::Display) print the raw key (because the
138    /// fallback message _is_ the key), which makes structured logs look like
139    /// `errors.user.not_found` rather than human text. Prefer
140    /// [`Error::with_locale_key`] when you want log-friendly output alongside
141    /// the translation tag.
142    pub fn localized(status: StatusCode, key: &'static str) -> Self {
143        Self {
144            status,
145            message: key.to_string(),
146            source: None,
147            error_code: None,
148            locale_key: Some(key),
149            details: None,
150            lagged: false,
151        }
152    }
153
154    /// Returns the HTTP status code of this error.
155    pub fn status(&self) -> StatusCode {
156        self.status
157    }
158
159    /// Returns the human-readable error message.
160    pub fn message(&self) -> &str {
161        &self.message
162    }
163
164    /// Returns the optional structured details payload.
165    pub fn details(&self) -> Option<&serde_json::Value> {
166        self.details.as_ref()
167    }
168
169    /// Attach a structured JSON details payload (builder-style).
170    ///
171    /// The payload is rendered under the `"error.details"` key in the JSON
172    /// response body produced by [`Error::into_response`].
173    ///
174    /// # Example
175    ///
176    /// ```rust
177    /// use modo::error::Error;
178    /// use modo::serde_json::json;
179    ///
180    /// let err = Error::unprocessable_entity("validation failed")
181    ///     .with_details(json!({ "field": "email", "reason": "invalid format" }));
182    /// assert!(err.details().is_some());
183    /// ```
184    pub fn with_details(mut self, details: serde_json::Value) -> Self {
185        self.details = Some(details);
186        self
187    }
188
189    /// Attach a source error (builder-style).
190    ///
191    /// The source is stored in a `Box<dyn std::error::Error + Send + Sync>`
192    /// and can be downcast with [`Error::source_as`] while you still own the
193    /// [`Error`]. Note: the source is **dropped on [`Clone`] and on
194    /// [`IntoResponse::into_response`]** — pair `.chain(src)` with
195    /// [`.with_code(code)`](Error::with_code) when you need identity that
196    /// survives the response boundary.
197    ///
198    /// # Example
199    ///
200    /// ```rust
201    /// use modo::error::Error;
202    /// use std::io;
203    ///
204    /// let err = Error::internal("disk write failed")
205    ///     .chain(io::Error::other("no space"));
206    /// assert!(err.source_as::<io::Error>().is_some());
207    /// ```
208    pub fn chain(mut self, source: impl std::error::Error + Send + Sync + 'static) -> Self {
209        self.source = Some(Box::new(source));
210        self
211    }
212
213    /// Attach a static error code to preserve error identity through the response pipeline.
214    ///
215    /// The `source` field is dropped on [`Clone`] and on
216    /// [`Error::into_response`], so downstream middleware reading the error
217    /// copy from response extensions cannot recover the original cause. A
218    /// static `error_code` survives both boundaries and is the canonical way
219    /// to identify an error post-response.
220    ///
221    /// This is a builder method: the existing `message`, `status`, `locale_key`,
222    /// `details`, and `source` fields are preserved — only `error_code` is
223    /// replaced.
224    ///
225    /// # Example
226    ///
227    /// ```rust
228    /// use modo::error::Error;
229    /// use axum::response::IntoResponse;
230    ///
231    /// let err = Error::unauthorized("token expired").with_code("jwt:expired");
232    /// let resp = err.into_response();
233    /// let ext = resp.extensions().get::<Error>().unwrap();
234    /// assert_eq!(ext.error_code(), Some("jwt:expired"));
235    /// ```
236    pub fn with_code(mut self, code: &'static str) -> Self {
237        self.error_code = Some(code);
238        self
239    }
240
241    /// Returns the error code, if one was set.
242    pub fn error_code(&self) -> Option<&'static str> {
243        self.error_code
244    }
245
246    /// Tag an existing error with a translation key (builder-style).
247    ///
248    /// Unlike [`Error::localized`], this preserves the current `message` — use
249    /// it when you already have a descriptive fallback string and want to add a
250    /// translation key alongside it. The
251    /// [`default_error_handler`](crate::middleware::default_error_handler) will
252    /// prefer the translated value whenever a
253    /// [`Translator`](crate::i18n::Translator) is available in the request
254    /// extensions, and otherwise keep the stored `message` untouched.
255    ///
256    /// This is a builder method: the existing `message`, `status`, `error_code`,
257    /// `details`, and `source` fields are preserved — only `locale_key` is
258    /// replaced.
259    pub fn with_locale_key(mut self, key: &'static str) -> Self {
260        self.locale_key = Some(key);
261        self
262    }
263
264    /// Returns the translation key, if one was set via [`Error::localized`] or
265    /// [`Error::with_locale_key`].
266    pub fn locale_key(&self) -> Option<&'static str> {
267        self.locale_key
268    }
269
270    /// Downcast the source error to a concrete type.
271    ///
272    /// Returns `None` if no source is set or if the source is not of type `T`.
273    pub fn source_as<T: std::error::Error + 'static>(&self) -> Option<&T> {
274        self.source.as_ref()?.downcast_ref::<T>()
275    }
276
277    /// Create a `400 Bad Request` error.
278    pub fn bad_request(msg: impl Into<String>) -> Self {
279        Self::new(StatusCode::BAD_REQUEST, msg)
280    }
281
282    /// Create a `401 Unauthorized` error.
283    pub fn unauthorized(msg: impl Into<String>) -> Self {
284        Self::new(StatusCode::UNAUTHORIZED, msg)
285    }
286
287    /// Create a `403 Forbidden` error.
288    pub fn forbidden(msg: impl Into<String>) -> Self {
289        Self::new(StatusCode::FORBIDDEN, msg)
290    }
291
292    /// Create a `404 Not Found` error.
293    pub fn not_found(msg: impl Into<String>) -> Self {
294        Self::new(StatusCode::NOT_FOUND, msg)
295    }
296
297    /// Create a `409 Conflict` error.
298    pub fn conflict(msg: impl Into<String>) -> Self {
299        Self::new(StatusCode::CONFLICT, msg)
300    }
301
302    /// Create a `413 Payload Too Large` error.
303    pub fn payload_too_large(msg: impl Into<String>) -> Self {
304        Self::new(StatusCode::PAYLOAD_TOO_LARGE, msg)
305    }
306
307    /// Create a `422 Unprocessable Entity` error.
308    pub fn unprocessable_entity(msg: impl Into<String>) -> Self {
309        Self::new(StatusCode::UNPROCESSABLE_ENTITY, msg)
310    }
311
312    /// Create a `429 Too Many Requests` error.
313    pub fn too_many_requests(msg: impl Into<String>) -> Self {
314        Self::new(StatusCode::TOO_MANY_REQUESTS, msg)
315    }
316
317    /// Create a `500 Internal Server Error`.
318    pub fn internal(msg: impl Into<String>) -> Self {
319        Self::new(StatusCode::INTERNAL_SERVER_ERROR, msg)
320    }
321
322    /// Create a `502 Bad Gateway` error.
323    pub fn bad_gateway(msg: impl Into<String>) -> Self {
324        Self::new(StatusCode::BAD_GATEWAY, msg)
325    }
326
327    /// Create a `504 Gateway Timeout` error.
328    pub fn gateway_timeout(msg: impl Into<String>) -> Self {
329        Self::new(StatusCode::GATEWAY_TIMEOUT, msg)
330    }
331
332    /// Create an error indicating a broadcast subscriber lagged behind.
333    ///
334    /// The resulting error has a `500 Internal Server Error` status and [`is_lagged`](Error::is_lagged)
335    /// returns `true`. `skipped` is the number of messages that were dropped.
336    pub fn lagged(skipped: u64) -> Self {
337        Self {
338            status: StatusCode::INTERNAL_SERVER_ERROR,
339            message: format!("SSE subscriber lagged, skipped {skipped} messages"),
340            source: None,
341            error_code: None,
342            locale_key: None,
343            details: None,
344            lagged: true,
345        }
346    }
347
348    /// Returns `true` if this error represents a broadcast lag.
349    pub fn is_lagged(&self) -> bool {
350        self.lagged
351    }
352}
353
354/// Clones the error, dropping the `source` field (which is not `Clone`).
355///
356/// All other fields — `status`, `message`, `error_code`, `locale_key`, `details`, and
357/// `lagged` — are preserved.
358impl Clone for Error {
359    fn clone(&self) -> Self {
360        Self {
361            status: self.status,
362            message: self.message.clone(),
363            source: None, // source (Box<dyn Error>) can't be cloned
364            error_code: self.error_code,
365            locale_key: self.locale_key,
366            details: self.details.clone(),
367            lagged: self.lagged,
368        }
369    }
370}
371
372impl fmt::Display for Error {
373    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
374        write!(f, "{}", self.message)
375    }
376}
377
378impl fmt::Debug for Error {
379    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
380        f.debug_struct("Error")
381            .field("status", &self.status)
382            .field("message", &self.message)
383            .field("source", &self.source)
384            .field("error_code", &self.error_code)
385            .field("locale_key", &self.locale_key)
386            .field("details", &self.details)
387            .field("lagged", &self.lagged)
388            .finish()
389    }
390}
391
392impl std::error::Error for Error {
393    fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
394        self.source
395            .as_ref()
396            .map(|e| e.as_ref() as &(dyn std::error::Error + 'static))
397    }
398}
399
400/// Builds the JSON body shared by [`Error::into_response`] and
401/// [`default_error_handler`](crate::middleware::default_error_handler).
402///
403/// Produces `{"error": {"status", "message"}}`, with a nested
404/// `"details"` key only when `details` is `Some`. Keeping this in one place
405/// ensures the two code paths stay byte-identical.
406pub(crate) fn render_error_body(
407    status: StatusCode,
408    message: &str,
409    details: Option<&serde_json::Value>,
410) -> serde_json::Value {
411    let mut body = serde_json::json!({
412        "error": {
413            "status": status.as_u16(),
414            "message": message,
415        }
416    });
417    if let Some(d) = details {
418        body["error"]["details"] = d.clone();
419    }
420    body
421}
422
423/// Converts `Error` into an axum [`Response`].
424///
425/// Produces a JSON body of the form:
426///
427/// ```json
428/// { "error": { "status": 422, "message": "validation failed" } }
429/// ```
430///
431/// If [`with_details`](Error::with_details) was called, a `"details"` key is added under `"error"`.
432///
433/// A copy of the error (without the `source` field) is stored in response extensions under
434/// the type `Error` so that downstream middleware can inspect it.
435impl IntoResponse for Error {
436    fn into_response(self) -> Response {
437        let status = self.status;
438        let message = self.message.clone();
439        let details = self.details.clone();
440
441        let body = render_error_body(status, &message, details.as_ref());
442
443        // Store a copy in extensions so error_handler middleware can read it
444        let ext_error = Error {
445            status,
446            message,
447            source: None, // source can't be cloned
448            error_code: self.error_code,
449            locale_key: self.locale_key,
450            details,
451            lagged: self.lagged,
452        };
453
454        let mut response = (status, axum::Json(body)).into_response();
455        response.extensions_mut().insert(ext_error);
456        response
457    }
458}
459
460#[cfg(test)]
461mod tests {
462    use super::*;
463
464    #[test]
465    fn lagged_error_has_internal_status() {
466        let err = Error::lagged(5);
467        assert_eq!(err.status(), StatusCode::INTERNAL_SERVER_ERROR);
468        assert!(err.message().contains('5'));
469    }
470
471    #[test]
472    fn is_lagged_returns_true_for_lagged_error() {
473        let err = Error::lagged(10);
474        assert!(err.is_lagged());
475    }
476
477    #[test]
478    fn is_lagged_returns_false_for_other_errors() {
479        let err = Error::internal("something else");
480        assert!(!err.is_lagged());
481    }
482
483    #[test]
484    fn payload_too_large_error_has_413_status() {
485        let err = Error::payload_too_large("file too big");
486        assert_eq!(err.status(), StatusCode::PAYLOAD_TOO_LARGE);
487        assert_eq!(err.message(), "file too big");
488    }
489
490    #[test]
491    fn chain_sets_source() {
492        use std::error::Error as _;
493        use std::io;
494        let err = super::Error::internal("failed").chain(io::Error::other("disk"));
495        assert!(err.source().is_some());
496    }
497
498    #[test]
499    fn source_as_downcasts_correctly() {
500        use std::io;
501        let io_err = io::Error::new(io::ErrorKind::NotFound, "missing");
502        let err = Error::internal("failed").chain(io_err);
503        let downcasted = err.source_as::<io::Error>();
504        assert!(downcasted.is_some());
505        assert_eq!(downcasted.unwrap().kind(), io::ErrorKind::NotFound);
506    }
507
508    #[test]
509    fn source_as_returns_none_for_wrong_type() {
510        use std::io;
511        let err = Error::internal("failed").chain(io::Error::other("x"));
512        let downcasted = err.source_as::<std::num::ParseIntError>();
513        assert!(downcasted.is_none());
514    }
515
516    #[test]
517    fn source_as_returns_none_when_no_source() {
518        let err = Error::internal("no source");
519        let downcasted = err.source_as::<std::io::Error>();
520        assert!(downcasted.is_none());
521    }
522
523    #[test]
524    fn with_code_sets_error_code() {
525        let err = Error::unauthorized("denied").with_code("jwt:expired");
526        assert_eq!(err.error_code(), Some("jwt:expired"));
527    }
528
529    #[test]
530    fn error_code_is_none_by_default() {
531        let err = Error::internal("plain");
532        assert!(err.error_code().is_none());
533    }
534
535    #[test]
536    fn error_code_survives_clone() {
537        let err = Error::unauthorized("denied").with_code("jwt:expired");
538        let cloned = err.clone();
539        assert_eq!(cloned.error_code(), Some("jwt:expired"));
540    }
541
542    #[test]
543    fn error_code_survives_into_response() {
544        use axum::response::IntoResponse;
545        let err = Error::unauthorized("denied").with_code("jwt:expired");
546        let response = err.into_response();
547        let ext_err = response.extensions().get::<Error>().unwrap();
548        assert_eq!(ext_err.error_code(), Some("jwt:expired"));
549    }
550
551    #[test]
552    fn bad_gateway_error_has_502_status() {
553        let err = Error::bad_gateway("upstream failed");
554        assert_eq!(err.status(), StatusCode::BAD_GATEWAY);
555        assert_eq!(err.message(), "upstream failed");
556    }
557
558    #[test]
559    fn gateway_timeout_error_has_504_status() {
560        let err = Error::gateway_timeout("timed out");
561        assert_eq!(err.status(), StatusCode::GATEWAY_TIMEOUT);
562        assert_eq!(err.message(), "timed out");
563    }
564
565    #[test]
566    fn localized_sets_key_and_falls_back_to_key_as_message() {
567        let err = Error::localized(StatusCode::NOT_FOUND, "errors.user.not_found");
568        assert_eq!(err.status(), StatusCode::NOT_FOUND);
569        assert_eq!(err.locale_key(), Some("errors.user.not_found"));
570        // Fallback message equals the key itself so responses remain predictable
571        // when no error-handler middleware / Translator is installed.
572        assert_eq!(err.message(), "errors.user.not_found");
573        assert!(err.error_code().is_none());
574        assert!(err.details().is_none());
575    }
576
577    #[test]
578    fn with_locale_key_tags_existing_error() {
579        let err = Error::bad_request("boom").with_locale_key("errors.validation.generic");
580        // Builder must preserve the existing message, only attach the key.
581        assert_eq!(err.message(), "boom");
582        assert_eq!(err.locale_key(), Some("errors.validation.generic"));
583        assert_eq!(err.status(), StatusCode::BAD_REQUEST);
584    }
585
586    #[test]
587    fn clone_preserves_locale_key() {
588        let err = Error::localized(StatusCode::CONFLICT, "errors.email.in_use");
589        let cloned = err.clone();
590        assert_eq!(cloned.locale_key(), Some("errors.email.in_use"));
591        assert_eq!(cloned.status(), StatusCode::CONFLICT);
592        assert_eq!(cloned.message(), "errors.email.in_use");
593    }
594
595    #[test]
596    fn response_extensions_clone_preserves_locale_key() {
597        use axum::response::IntoResponse;
598        let err = Error::localized(StatusCode::UNAUTHORIZED, "errors.auth.expired");
599        let response = err.into_response();
600        let ext_err = response.extensions().get::<Error>().unwrap();
601        assert_eq!(ext_err.locale_key(), Some("errors.auth.expired"));
602        assert_eq!(ext_err.status(), StatusCode::UNAUTHORIZED);
603    }
604
605    #[test]
606    fn render_error_body_without_details() {
607        let body = render_error_body(StatusCode::NOT_FOUND, "user not found", None);
608        assert_eq!(
609            body,
610            serde_json::json!({
611                "error": {
612                    "status": 404,
613                    "message": "user not found",
614                }
615            })
616        );
617    }
618
619    #[test]
620    fn render_error_body_with_details() {
621        let details = serde_json::json!({"field": "email"});
622        let body = render_error_body(StatusCode::UNPROCESSABLE_ENTITY, "invalid", Some(&details));
623        assert_eq!(
624            body,
625            serde_json::json!({
626                "error": {
627                    "status": 422,
628                    "message": "invalid",
629                    "details": {"field": "email"},
630                }
631            })
632        );
633    }
634}