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}