Skip to main content

autumn_web/
error.rs

1//! Framework error type and result alias.
2//!
3//! [`AutumnError`] wraps any `Error + Send + Sync` with an HTTP status code.
4//! The blanket [`From`] impl maps all errors to `500 Internal Server Error`,
5//! so the `?` operator works in handlers with zero ceremony.
6//!
7//! For non-500 cases, use the status refinement constructors:
8//!
9//! - [`AutumnError::not_found`] -- 404
10//! - [`AutumnError::bad_request`] -- 400
11//! - [`AutumnError::unprocessable`] -- 422
12//! - [`AutumnError::service_unavailable`] -- 503
13//! - [`AutumnError::with_status`] -- arbitrary status code
14//!
15//! For simple string messages without wrapping an error type:
16//!
17//! - [`AutumnError::not_found_msg`] -- 404 with a message
18//! - [`AutumnError::bad_request_msg`] -- 400 with a message
19//! - [`AutumnError::unprocessable_msg`] -- 422 with a message
20//! - [`AutumnError::service_unavailable_msg`] -- 503 with a message
21//!
22//! # Response format
23//!
24//! When an `AutumnError` is returned from a handler, it renders as JSON:
25//!
26//! ```json
27//! { "error": { "status": 404, "message": "user not found" } }
28//! ```
29//!
30//! # Examples
31//!
32//! ```rust
33//! use autumn_web::error::AutumnError;
34//! use http::StatusCode;
35//!
36//! // Blanket From impl: any Error becomes 500
37//! let err: AutumnError = std::io::Error::other("disk full").into();
38//! assert_eq!(err.status(), StatusCode::INTERNAL_SERVER_ERROR);
39//!
40//! // Explicit status constructors
41//! let err = AutumnError::not_found(std::io::Error::other("no such user"));
42//! assert_eq!(err.status(), StatusCode::NOT_FOUND);
43//! ```
44
45use axum::http::{HeaderValue, StatusCode, header};
46use axum::response::{IntoResponse, Response};
47use serde::Serialize;
48
49/// Simple error type wrapping a string message.
50///
51/// Used by the `_msg` convenience constructors on [`AutumnError`] so callers
52/// don't need to wrap strings in `std::io::Error`.
53#[derive(Debug)]
54struct StringError(String);
55
56impl std::fmt::Display for StringError {
57    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
58        f.write_str(&self.0)
59    }
60}
61
62impl std::error::Error for StringError {}
63
64/// JSON body for RFC 7807 Problem Details responses.
65#[derive(Clone, Debug, Serialize)]
66pub struct ProblemDetails {
67    /// Problem type URI. Autumn uses stable `https://autumn.dev/problems/...`
68    /// URIs for framework-generated errors.
69    #[serde(rename = "type")]
70    pub type_uri: String,
71    /// Short human-readable title for the status/problem class.
72    pub title: String,
73    /// HTTP status code.
74    pub status: u16,
75    /// Client-safe human-readable explanation.
76    pub detail: String,
77    /// Request path or URI reference for the specific occurrence.
78    pub instance: Option<String>,
79    /// Stable machine-readable Autumn error code.
80    pub code: String,
81    /// Request ID for log correlation, when the request pipeline assigned one.
82    pub request_id: Option<String>,
83    /// Field-level validation failures. Empty for non-validation errors.
84    pub errors: Vec<ProblemFieldError>,
85}
86
87/// Field-level validation detail in the Problem Details `errors` extension.
88#[derive(Clone, Debug, Serialize, PartialEq, Eq)]
89pub struct ProblemFieldError {
90    /// Field name as seen by the request payload or form.
91    pub field: String,
92    /// Stable list of validation messages for this field.
93    pub messages: Vec<String>,
94}
95
96/// Framework error type wrapping any error with an HTTP status code.
97///
98/// # Usage
99///
100/// The `?` operator converts any `std::error::Error` into an `AutumnError`
101/// with status `500 Internal Server Error`:
102///
103/// ```rust,no_run
104/// use autumn_web::prelude::*;
105///
106/// #[get("/")]
107/// async fn handler() -> AutumnResult<&'static str> {
108///     autumn_web::reexports::tokio::fs::read_to_string("missing.txt").await?; // becomes 500 on error
109///     Ok("ok")
110/// }
111/// ```
112///
113/// For expected errors, use a status refinement constructor:
114///
115/// ```rust,no_run
116/// use autumn_web::prelude::*;
117///
118/// #[get("/users/{id}")]
119/// async fn get_user(axum::extract::Path(id): axum::extract::Path<i32>) -> AutumnResult<String> {
120///     if id < 0 {
121///         return Err(AutumnError::bad_request(
122///             std::io::Error::other("id must be positive"),
123///         ));
124///     }
125///     Ok(format!("user {id}"))
126/// }
127/// ```
128///
129/// # Why no `Error` impl
130///
131/// `AutumnError` intentionally does **not** implement [`std::error::Error`].
132/// Doing so would conflict with the blanket `From<E: Error>` impl (the
133/// reflexive `From<T> for T` would overlap). This type is a *response*
134/// wrapper, not a propagatable error.
135pub struct AutumnError {
136    inner: Box<dyn std::error::Error + Send + Sync>,
137    status: StatusCode,
138    details: Option<std::collections::HashMap<String, Vec<String>>>,
139    problem_type: Option<&'static str>,
140    cache_idempotency_response: bool,
141    /// Backtrace captured at error creation time in debug builds.
142    /// Transferred to `AutumnErrorInfo` for the dev overlay.
143    #[cfg(debug_assertions)]
144    pub(crate) backtrace_string: Option<String>,
145}
146
147/// Convenience alias -- the standard return type for Autumn handlers.
148///
149/// Equivalent to `Result<T, AutumnError>`. Use this as the return type
150/// for any handler that might fail.
151///
152/// # Examples
153///
154/// ```rust,no_run
155/// use autumn_web::prelude::*;
156///
157/// #[get("/")]
158/// async fn index() -> AutumnResult<&'static str> {
159///     Ok("hello")
160/// }
161/// ```
162pub type AutumnResult<T> = Result<T, AutumnError>;
163
164impl<E> From<E> for AutumnError
165where
166    E: std::error::Error + Send + Sync + 'static,
167{
168    fn from(err: E) -> Self {
169        let mut status = StatusCode::INTERNAL_SERVER_ERROR;
170        let any_err: &dyn std::any::Any = &err;
171
172        if std::any::type_name::<E>().contains("CircuitBreakerError")
173            && err.to_string() == "circuit breaker is open"
174        {
175            status = StatusCode::SERVICE_UNAVAILABLE;
176        }
177
178        #[cfg(feature = "http-client")]
179        {
180            if matches!(
181                any_err.downcast_ref::<crate::http_client::ClientError>(),
182                Some(crate::http_client::ClientError::CircuitBreakerOpen)
183            ) {
184                status = StatusCode::SERVICE_UNAVAILABLE;
185            }
186        }
187
188        #[cfg(feature = "mail")]
189        {
190            if let Some(crate::mail::MailError::RuntimeUnavailable(msg)) =
191                any_err.downcast_ref::<crate::mail::MailError>()
192                && msg.contains("circuit breaker is open")
193            {
194                status = StatusCode::SERVICE_UNAVAILABLE;
195            }
196        }
197
198        Self {
199            inner: Box::new(err),
200            status,
201            details: None,
202            problem_type: None,
203            cache_idempotency_response: false,
204            #[cfg(debug_assertions)]
205            backtrace_string: Some(format!("{}", std::backtrace::Backtrace::force_capture())),
206        }
207    }
208}
209
210impl AutumnError {
211    /// Override the HTTP status code.
212    ///
213    /// # Examples
214    ///
215    /// ```rust
216    /// use autumn_web::error::AutumnError;
217    /// use http::StatusCode;
218    ///
219    /// let err: AutumnError = std::io::Error::other("forbidden").into();
220    /// let err = err.with_status(StatusCode::FORBIDDEN);
221    /// assert_eq!(err.status(), StatusCode::FORBIDDEN);
222    /// ```
223    #[must_use]
224    pub const fn with_status(mut self, status: StatusCode) -> Self {
225        self.status = status;
226        self
227    }
228
229    /// Create a `500 Internal Server Error`.
230    ///
231    /// # Examples
232    ///
233    /// ```rust
234    /// use autumn_web::error::AutumnError;
235    /// use http::StatusCode;
236    ///
237    /// let err = AutumnError::internal_server_error(std::io::Error::other("boom"));
238    /// assert_eq!(err.status(), StatusCode::INTERNAL_SERVER_ERROR);
239    /// ```
240    pub fn internal_server_error(err: impl std::error::Error + Send + Sync + 'static) -> Self {
241        Self {
242            inner: Box::new(err),
243            status: StatusCode::INTERNAL_SERVER_ERROR,
244            details: None,
245            problem_type: None,
246            cache_idempotency_response: false,
247            #[cfg(debug_assertions)]
248            backtrace_string: Some(format!("{}", std::backtrace::Backtrace::force_capture())),
249        }
250    }
251
252    /// Create a `404 Not Found` error.
253    ///
254    /// # Examples
255    ///
256    /// ```rust
257    /// use autumn_web::error::AutumnError;
258    /// use http::StatusCode;
259    ///
260    /// let err = AutumnError::not_found(std::io::Error::other("no such user"));
261    /// assert_eq!(err.status(), StatusCode::NOT_FOUND);
262    /// ```
263    pub fn not_found(err: impl std::error::Error + Send + Sync + 'static) -> Self {
264        Self {
265            inner: Box::new(err),
266            status: StatusCode::NOT_FOUND,
267            details: None,
268            problem_type: None,
269            cache_idempotency_response: false,
270            #[cfg(debug_assertions)]
271            backtrace_string: Some(format!("{}", std::backtrace::Backtrace::force_capture())),
272        }
273    }
274
275    /// Create a `400 Bad Request` error.
276    ///
277    /// # Examples
278    ///
279    /// ```rust
280    /// use autumn_web::error::AutumnError;
281    /// use http::StatusCode;
282    ///
283    /// let err = AutumnError::bad_request(std::io::Error::other("invalid input"));
284    /// assert_eq!(err.status(), StatusCode::BAD_REQUEST);
285    /// ```
286    pub fn bad_request(err: impl std::error::Error + Send + Sync + 'static) -> Self {
287        Self {
288            inner: Box::new(err),
289            status: StatusCode::BAD_REQUEST,
290            details: None,
291            problem_type: None,
292            cache_idempotency_response: false,
293            #[cfg(debug_assertions)]
294            backtrace_string: Some(format!("{}", std::backtrace::Backtrace::force_capture())),
295        }
296    }
297
298    /// Create a `422 Unprocessable Entity` error.
299    ///
300    /// Use this for validation failures where the request is syntactically
301    /// valid but semantically incorrect.
302    ///
303    /// # Examples
304    ///
305    /// ```rust
306    /// use autumn_web::error::AutumnError;
307    /// use http::StatusCode;
308    ///
309    /// let err = AutumnError::unprocessable(std::io::Error::other("age must be positive"));
310    /// assert_eq!(err.status(), StatusCode::UNPROCESSABLE_ENTITY);
311    /// ```
312    pub fn unprocessable(err: impl std::error::Error + Send + Sync + 'static) -> Self {
313        Self {
314            inner: Box::new(err),
315            status: StatusCode::UNPROCESSABLE_ENTITY,
316            details: None,
317            problem_type: None,
318            cache_idempotency_response: false,
319            #[cfg(debug_assertions)]
320            backtrace_string: Some(format!("{}", std::backtrace::Backtrace::force_capture())),
321        }
322    }
323
324    /// Create a `503 Service Unavailable` error.
325    ///
326    /// # Examples
327    ///
328    /// ```rust
329    /// use autumn_web::error::AutumnError;
330    /// use http::StatusCode;
331    ///
332    /// let err = AutumnError::service_unavailable(std::io::Error::other("pool exhausted"));
333    /// assert_eq!(err.status(), StatusCode::SERVICE_UNAVAILABLE);
334    /// ```
335    pub fn service_unavailable(err: impl std::error::Error + Send + Sync + 'static) -> Self {
336        Self {
337            inner: Box::new(err),
338            status: StatusCode::SERVICE_UNAVAILABLE,
339            details: None,
340            problem_type: None,
341            cache_idempotency_response: false,
342            #[cfg(debug_assertions)]
343            backtrace_string: Some(format!("{}", std::backtrace::Backtrace::force_capture())),
344        }
345    }
346
347    /// Create a `401 Unauthorized` error.
348    ///
349    /// # Examples
350    ///
351    /// ```rust
352    /// use autumn_web::error::AutumnError;
353    /// use http::StatusCode;
354    ///
355    /// let err = AutumnError::unauthorized(std::io::Error::other("not logged in"));
356    /// assert_eq!(err.status(), StatusCode::UNAUTHORIZED);
357    /// ```
358    pub fn unauthorized(err: impl std::error::Error + Send + Sync + 'static) -> Self {
359        Self {
360            inner: Box::new(err),
361            status: StatusCode::UNAUTHORIZED,
362            details: None,
363            problem_type: None,
364            cache_idempotency_response: false,
365            #[cfg(debug_assertions)]
366            backtrace_string: Some(format!("{}", std::backtrace::Backtrace::force_capture())),
367        }
368    }
369
370    /// Create a `403 Forbidden` error.
371    ///
372    /// # Examples
373    ///
374    /// ```rust
375    /// use autumn_web::error::AutumnError;
376    /// use http::StatusCode;
377    ///
378    /// let err = AutumnError::forbidden(std::io::Error::other("not allowed"));
379    /// assert_eq!(err.status(), StatusCode::FORBIDDEN);
380    /// ```
381    pub fn forbidden(err: impl std::error::Error + Send + Sync + 'static) -> Self {
382        Self {
383            inner: Box::new(err),
384            status: StatusCode::FORBIDDEN,
385            details: None,
386            problem_type: None,
387            cache_idempotency_response: false,
388            #[cfg(debug_assertions)]
389            backtrace_string: Some(format!("{}", std::backtrace::Backtrace::force_capture())),
390        }
391    }
392
393    /// Create a `422 Unprocessable Entity` error with field-level
394    /// validation details.
395    ///
396    /// Use this when a request fails multiple field-specific validation rules
397    /// (e.g., in a form submission). It attaches the `details` parameter, a mapping
398    /// of field names to their respective error messages, so the client can display
399    /// errors next to the relevant inputs.
400    ///
401    /// # Examples
402    ///
403    /// ```rust
404    /// use autumn_web::error::AutumnError;
405    /// use http::StatusCode;
406    /// use std::collections::HashMap;
407    ///
408    /// let mut errors = HashMap::new();
409    /// errors.insert("username".to_string(), vec!["Username is taken".to_string()]);
410    ///
411    /// let err = AutumnError::validation(errors);
412    /// assert_eq!(err.status(), StatusCode::UNPROCESSABLE_ENTITY);
413    /// ```
414    #[must_use]
415    pub fn validation(details: std::collections::HashMap<String, Vec<String>>) -> Self {
416        Self {
417            inner: Box::new(StringError("Validation failed".into())),
418            status: StatusCode::UNPROCESSABLE_ENTITY,
419            details: Some(details),
420            problem_type: None,
421            cache_idempotency_response: false,
422            #[cfg(debug_assertions)]
423            backtrace_string: Some(format!("{}", std::backtrace::Backtrace::force_capture())),
424        }
425    }
426
427    // ── String-message convenience constructors ────────────────
428
429    /// Create a `500 Internal Server Error` from a plain string message.
430    ///
431    /// # Examples
432    ///
433    /// ```rust
434    /// use autumn_web::error::AutumnError;
435    /// use http::StatusCode;
436    ///
437    /// let err = AutumnError::internal_server_error_msg("Database explosion");
438    /// assert_eq!(err.status(), StatusCode::INTERNAL_SERVER_ERROR);
439    /// ```
440    pub fn internal_server_error_msg(msg: impl Into<String>) -> Self {
441        Self::internal_server_error(StringError(msg.into()))
442    }
443
444    /// Create a `404 Not Found` error from a plain string message.
445    ///
446    /// # Examples
447    ///
448    /// ```rust
449    /// use autumn_web::error::AutumnError;
450    /// use http::StatusCode;
451    ///
452    /// let err = AutumnError::not_found_msg("No such user");
453    /// assert_eq!(err.status(), StatusCode::NOT_FOUND);
454    /// assert_eq!(err.to_string(), "No such user");
455    /// ```
456    pub fn not_found_msg(msg: impl Into<String>) -> Self {
457        Self::not_found(StringError(msg.into()))
458    }
459
460    /// Create a `400 Bad Request` error from a plain string message.
461    ///
462    /// # Examples
463    ///
464    /// ```rust
465    /// use autumn_web::error::AutumnError;
466    /// use http::StatusCode;
467    ///
468    /// let err = AutumnError::bad_request_msg("Invalid input parameter");
469    /// assert_eq!(err.status(), StatusCode::BAD_REQUEST);
470    /// ```
471    pub fn bad_request_msg(msg: impl Into<String>) -> Self {
472        Self::bad_request(StringError(msg.into()))
473    }
474
475    /// Create a `422 Unprocessable Entity` error from a plain string message.
476    ///
477    /// # Examples
478    ///
479    /// ```rust
480    /// use autumn_web::error::AutumnError;
481    /// use http::StatusCode;
482    ///
483    /// let err = AutumnError::unprocessable_msg("Title is required");
484    /// assert_eq!(err.status(), StatusCode::UNPROCESSABLE_ENTITY);
485    /// ```
486    pub fn unprocessable_msg(msg: impl Into<String>) -> Self {
487        Self::unprocessable(StringError(msg.into()))
488    }
489
490    /// Create a `401 Unauthorized` error from a plain string message.
491    ///
492    /// # Examples
493    ///
494    /// ```rust
495    /// use autumn_web::error::AutumnError;
496    /// use http::StatusCode;
497    ///
498    /// let err = AutumnError::unauthorized_msg("Please log in to continue");
499    /// assert_eq!(err.status(), StatusCode::UNAUTHORIZED);
500    /// ```
501    pub fn unauthorized_msg(msg: impl Into<String>) -> Self {
502        Self::unauthorized(StringError(msg.into()))
503    }
504
505    /// Create a `403 Forbidden` error from a plain string message.
506    ///
507    /// # Examples
508    ///
509    /// ```rust
510    /// use autumn_web::error::AutumnError;
511    /// use http::StatusCode;
512    ///
513    /// let err = AutumnError::forbidden_msg("You lack admin privileges");
514    /// assert_eq!(err.status(), StatusCode::FORBIDDEN);
515    /// ```
516    pub fn forbidden_msg(msg: impl Into<String>) -> Self {
517        Self::forbidden(StringError(msg.into()))
518    }
519
520    /// Create a `503 Service Unavailable` error from a plain string message.
521    ///
522    /// # Examples
523    ///
524    /// ```rust
525    /// use autumn_web::error::AutumnError;
526    /// use http::StatusCode;
527    ///
528    /// let err = AutumnError::service_unavailable_msg("Database connection pool exhausted");
529    /// assert_eq!(err.status(), StatusCode::SERVICE_UNAVAILABLE);
530    /// ```
531    pub fn service_unavailable_msg(msg: impl Into<String>) -> Self {
532        Self::service_unavailable(StringError(msg.into()))
533    }
534
535    /// Create a `409 Conflict` error.
536    ///
537    /// Use this for optimistic-lock conflicts surfaced by repository `update`
538    /// calls when the client's expected version is stale.
539    ///
540    /// # Examples
541    ///
542    /// ```rust
543    /// use autumn_web::error::AutumnError;
544    /// use http::StatusCode;
545    ///
546    /// let err = AutumnError::conflict(std::io::Error::other("stale version"));
547    /// assert_eq!(err.status(), StatusCode::CONFLICT);
548    /// ```
549    pub fn conflict(err: impl std::error::Error + Send + Sync + 'static) -> Self {
550        Self {
551            inner: Box::new(err),
552            status: StatusCode::CONFLICT,
553            details: None,
554            problem_type: Some("https://autumn.dev/problems/conflict"),
555            cache_idempotency_response: false,
556            #[cfg(debug_assertions)]
557            backtrace_string: Some(format!("{}", std::backtrace::Backtrace::force_capture())),
558        }
559    }
560
561    /// Create a `409 Conflict` error from a plain string message.
562    ///
563    /// # Examples
564    ///
565    /// ```rust
566    /// use autumn_web::error::AutumnError;
567    /// use http::StatusCode;
568    ///
569    /// let err = AutumnError::conflict_msg("Concurrent edit: please reload and retry");
570    /// assert_eq!(err.status(), StatusCode::CONFLICT);
571    /// ```
572    pub fn conflict_msg(msg: impl Into<String>) -> Self {
573        Self::conflict(StringError(msg.into()))
574    }
575
576    /// Create a `410 Gone` error.
577    ///
578    /// # Examples
579    ///
580    /// ```rust
581    /// use autumn_web::error::AutumnError;
582    /// use http::StatusCode;
583    ///
584    /// let err = AutumnError::gone(std::io::Error::other("sunsetted"));
585    /// assert_eq!(err.status(), StatusCode::GONE);
586    /// ```
587    pub fn gone(err: impl std::error::Error + Send + Sync + 'static) -> Self {
588        Self {
589            inner: Box::new(err),
590            status: StatusCode::GONE,
591            details: None,
592            problem_type: Some("https://autumn.dev/problems/gone"),
593            cache_idempotency_response: false,
594            #[cfg(debug_assertions)]
595            backtrace_string: Some(format!("{}", std::backtrace::Backtrace::force_capture())),
596        }
597    }
598
599    /// Create a `410 Gone` error from a plain string message.
600    ///
601    /// # Examples
602    ///
603    /// ```rust
604    /// use autumn_web::error::AutumnError;
605    /// use http::StatusCode;
606    ///
607    /// let err = AutumnError::gone_msg("API version has been sunsetted");
608    /// assert_eq!(err.status(), StatusCode::GONE);
609    /// ```
610    pub fn gone_msg(msg: impl Into<String>) -> Self {
611        Self::gone(StringError(msg.into()))
612    }
613
614    /// Create a `503 Service Unavailable` error indicating that a database
615    /// query was cancelled due to a statement timeout (Postgres `57014`).
616    ///
617    /// The problem details payload carries `"autumn.query_timeout"` as the
618    /// machine-readable code, which allows clients to distinguish a transient
619    /// timeout from other 503 conditions and apply appropriate retry logic.
620    ///
621    /// # Examples
622    ///
623    /// ```rust
624    /// use autumn_web::error::AutumnError;
625    /// use http::StatusCode;
626    ///
627    /// let err = AutumnError::query_timeout("query exceeded statement_timeout");
628    /// assert_eq!(err.status(), StatusCode::SERVICE_UNAVAILABLE);
629    /// ```
630    pub fn query_timeout(msg: impl Into<String>) -> Self {
631        Self {
632            inner: Box::new(StringError(msg.into())),
633            status: StatusCode::SERVICE_UNAVAILABLE,
634            details: None,
635            problem_type: Some("https://autumn.dev/problems/query-timeout"),
636            cache_idempotency_response: false,
637            #[cfg(debug_assertions)]
638            backtrace_string: Some(format!("{}", std::backtrace::Backtrace::force_capture())),
639        }
640    }
641
642    /// Returns the HTTP status code associated with this error.
643    ///
644    /// # Examples
645    ///
646    /// ```rust
647    /// use autumn_web::error::AutumnError;
648    /// use http::StatusCode;
649    ///
650    /// let err: AutumnError = std::io::Error::other("boom").into();
651    /// assert_eq!(err.status(), StatusCode::INTERNAL_SERVER_ERROR);
652    /// ```
653    #[must_use]
654    pub const fn status(&self) -> StatusCode {
655        self.status
656    }
657
658    #[doc(hidden)]
659    #[must_use]
660    pub(crate) const fn cache_idempotency_response(mut self) -> Self {
661        self.cache_idempotency_response = true;
662        self
663    }
664
665    /// Return the wrapped error's source chain as displayable messages.
666    ///
667    /// The top-level [`AutumnError`] display already prints the wrapped error
668    /// message, so this list starts at that wrapped error's first source.
669    #[must_use]
670    pub fn source_chain(&self) -> Vec<String> {
671        let mut chain = Vec::new();
672        let mut source = self.inner.source();
673        while let Some(error) = source {
674            chain.push(error.to_string());
675            source = error.source();
676        }
677        chain
678    }
679
680    /// Try to downcast the inner error to a specific type.
681    #[must_use]
682    pub fn downcast_ref<T: std::error::Error + 'static>(&self) -> Option<&T> {
683        let err: &(dyn std::error::Error + 'static) = self.inner.as_ref();
684        err.downcast_ref::<T>()
685    }
686}
687
688impl std::fmt::Display for AutumnError {
689    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
690        write!(f, "{}", self.inner)
691    }
692}
693
694impl std::fmt::Debug for AutumnError {
695    #[allow(clippy::missing_fields_in_debug)]
696    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
697        f.debug_struct("AutumnError")
698            .field("status", &self.status)
699            .field("inner", &self.inner)
700            .field("details", &self.details)
701            .field("problem_type", &self.problem_type)
702            .field(
703                "cache_idempotency_response",
704                &self.cache_idempotency_response,
705            )
706            .finish_non_exhaustive()
707    }
708}
709
710impl ProblemDetails {
711    /// Build a Problem Details payload from framework error metadata.
712    #[must_use]
713    pub fn new(
714        status: StatusCode,
715        detail: impl Into<String>,
716        details: Option<&std::collections::HashMap<String, Vec<String>>>,
717    ) -> Self {
718        problem_details(status, detail.into(), details, None, None, None, true)
719    }
720}
721
722/// Build the canonical Problem Details payload.
723#[must_use]
724pub(crate) fn problem_details(
725    status: StatusCode,
726    detail: String,
727    details: Option<&std::collections::HashMap<String, Vec<String>>>,
728    explicit_type: Option<&'static str>,
729    request_id: Option<String>,
730    instance: Option<String>,
731    expose_internal_detail: bool,
732) -> ProblemDetails {
733    let has_validation_errors = details.is_some_and(|map| !map.is_empty());
734    let safe_detail = if status.is_server_error() && !expose_internal_detail {
735        server_error_detail(status)
736    } else {
737        detail
738    };
739
740    // When an explicit problem type URI is provided, derive the machine-readable
741    // code from its path segment (last path component, hyphens → underscores,
742    // prefixed with "autumn."). This avoids having to enumerate every error type
743    // in a separate match table.
744    //
745    // Example: "https://autumn.dev/problems/query-timeout" → "autumn.query_timeout"
746    let code = explicit_type.map_or_else(
747        || problem_code_for(status, has_validation_errors).to_owned(),
748        |etype| {
749            let slug = etype.rsplit('/').next().unwrap_or(etype);
750            format!("autumn.{}", slug.replace('-', "_"))
751        },
752    );
753
754    ProblemDetails {
755        type_uri: explicit_type
756            .unwrap_or_else(|| problem_type_for(status, has_validation_errors))
757            .to_owned(),
758        title: problem_title_for(status, has_validation_errors).to_owned(),
759        status: status.as_u16(),
760        detail: safe_detail,
761        instance,
762        code,
763        request_id,
764        errors: validation_errors(details),
765    }
766}
767
768/// Serialize a Problem Details payload for middleware that cannot return
769/// `axum::Json` directly because its response body type is generic.
770#[must_use]
771pub(crate) fn problem_details_json_string(
772    status: StatusCode,
773    detail: impl Into<String>,
774    details: Option<&std::collections::HashMap<String, Vec<String>>>,
775    explicit_type: Option<&'static str>,
776    request_id: Option<String>,
777    instance: Option<String>,
778    expose_internal_detail: bool,
779) -> String {
780    let problem = problem_details(
781        status,
782        detail.into(),
783        details,
784        explicit_type,
785        request_id,
786        instance,
787        expose_internal_detail,
788    );
789    problem_details_to_json_string(&problem)
790}
791
792/// Serialize an already-built Problem Details payload.
793#[must_use]
794pub(crate) fn problem_details_to_json_string(problem: &ProblemDetails) -> String {
795    serde_json::to_string(&problem).unwrap_or_else(|_| {
796        r#"{"type":"https://autumn.dev/problems/internal-server-error","title":"Internal Server Error","status":500,"detail":"Internal server error","instance":null,"code":"autumn.internal_server_error","request_id":null,"errors":[]}"#.to_owned()
797    })
798}
799
800fn validation_errors(
801    details: Option<&std::collections::HashMap<String, Vec<String>>>,
802) -> Vec<ProblemFieldError> {
803    let mut errors: Vec<_> = details
804        .into_iter()
805        .flat_map(std::collections::HashMap::iter)
806        .map(|(field, messages)| ProblemFieldError {
807            field: field.clone(),
808            messages: messages.clone(),
809        })
810        .collect();
811    errors.sort_by(|left, right| left.field.cmp(&right.field));
812    errors
813}
814
815const fn problem_type_for(status: StatusCode, has_validation_errors: bool) -> &'static str {
816    if has_validation_errors {
817        return "https://autumn.dev/problems/validation-failed";
818    }
819
820    match status {
821        StatusCode::BAD_REQUEST => "https://autumn.dev/problems/bad-request",
822        StatusCode::UNAUTHORIZED => "https://autumn.dev/problems/unauthorized",
823        StatusCode::FORBIDDEN => "https://autumn.dev/problems/forbidden",
824        StatusCode::NOT_FOUND => "https://autumn.dev/problems/not-found",
825        StatusCode::GONE => "https://autumn.dev/problems/gone",
826        StatusCode::CONFLICT => "https://autumn.dev/problems/conflict",
827        StatusCode::PAYLOAD_TOO_LARGE => "https://autumn.dev/problems/payload-too-large",
828        StatusCode::UNPROCESSABLE_ENTITY => "https://autumn.dev/problems/unprocessable-entity",
829        StatusCode::INTERNAL_SERVER_ERROR => "https://autumn.dev/problems/internal-server-error",
830        StatusCode::NOT_IMPLEMENTED => "https://autumn.dev/problems/not-implemented",
831        StatusCode::SERVICE_UNAVAILABLE => "https://autumn.dev/problems/service-unavailable",
832        _ => "about:blank",
833    }
834}
835
836fn problem_title_for(status: StatusCode, has_validation_errors: bool) -> &'static str {
837    if has_validation_errors {
838        return "Validation Failed";
839    }
840
841    match status {
842        StatusCode::BAD_REQUEST => "Bad Request",
843        StatusCode::UNAUTHORIZED => "Unauthorized",
844        StatusCode::FORBIDDEN => "Forbidden",
845        StatusCode::NOT_FOUND => "Not Found",
846        StatusCode::GONE => "Gone",
847        StatusCode::CONFLICT => "Conflict",
848        StatusCode::PAYLOAD_TOO_LARGE => "Payload Too Large",
849        StatusCode::UNPROCESSABLE_ENTITY => "Unprocessable Entity",
850        StatusCode::INTERNAL_SERVER_ERROR => "Internal Server Error",
851        StatusCode::NOT_IMPLEMENTED => "Not Implemented",
852        StatusCode::SERVICE_UNAVAILABLE => "Service Unavailable",
853        _ => status.canonical_reason().unwrap_or("Error"),
854    }
855}
856
857fn problem_code_for(status: StatusCode, has_validation_errors: bool) -> &'static str {
858    if has_validation_errors {
859        return "autumn.validation_failed";
860    }
861
862    match status {
863        StatusCode::BAD_REQUEST => "autumn.bad_request",
864        StatusCode::UNAUTHORIZED => "autumn.unauthorized",
865        StatusCode::FORBIDDEN => "autumn.forbidden",
866        StatusCode::NOT_FOUND => "autumn.not_found",
867        StatusCode::GONE => "autumn.gone",
868        StatusCode::CONFLICT => "autumn.conflict",
869        StatusCode::PAYLOAD_TOO_LARGE => "autumn.payload_too_large",
870        StatusCode::UNPROCESSABLE_ENTITY => "autumn.unprocessable_entity",
871        StatusCode::INTERNAL_SERVER_ERROR => "autumn.internal_server_error",
872        StatusCode::NOT_IMPLEMENTED => "autumn.not_implemented",
873        StatusCode::SERVICE_UNAVAILABLE => "autumn.service_unavailable",
874        _ if status.is_client_error() => "autumn.client_error",
875        _ if status.is_server_error() => "autumn.server_error",
876        _ => "autumn.error",
877    }
878}
879
880fn server_error_detail(status: StatusCode) -> String {
881    match status {
882        StatusCode::SERVICE_UNAVAILABLE => "Service unavailable".to_owned(),
883        StatusCode::NOT_IMPLEMENTED => "Not implemented".to_owned(),
884        _ => "Internal server error".to_owned(),
885    }
886}
887
888impl IntoResponse for AutumnError {
889    fn into_response(self) -> Response {
890        let mut status = self.status;
891        let message = self.inner.to_string();
892        let mut problem_type = self.problem_type;
893
894        // Automatically map database query cancellation (statement timeout) to 503 Service Unavailable
895        let err_str = message.to_lowercase();
896        if err_str.contains("57014")
897            || err_str.contains("query_canceled")
898            || err_str.contains("canceling statement due to statement timeout")
899            || err_str.contains("statement timeout")
900            || err_str.contains("query canceled")
901        {
902            status = StatusCode::SERVICE_UNAVAILABLE;
903            problem_type = Some("https://autumn.dev/problems/query-timeout");
904        }
905
906        let details = self.details.clone();
907        let cache_idempotency_response = self.cache_idempotency_response;
908
909        // Stash error metadata for exception filters to inspect without
910        // parsing the response body.
911        let error_info = crate::middleware::AutumnErrorInfo {
912            status,
913            message: message.clone(),
914            details: details.clone(),
915            problem_type,
916            #[cfg(debug_assertions)]
917            backtrace_string: self.backtrace_string.clone(),
918            #[cfg(not(debug_assertions))]
919            backtrace_string: None,
920        };
921
922        let body = problem_details(
923            status,
924            message,
925            details.as_ref(),
926            problem_type,
927            None,
928            None,
929            true,
930        );
931        let mut response = (status, axum::Json(body)).into_response();
932        response.headers_mut().insert(
933            header::CONTENT_TYPE,
934            HeaderValue::from_static("application/problem+json"),
935        );
936        if status == StatusCode::CONFLICT {
937            response.headers_mut().insert(
938                "HX-Trigger",
939                HeaderValue::from_static(r#"{"autumn:conflict":true}"#),
940            );
941        }
942        if cache_idempotency_response {
943            response
944                .extensions_mut()
945                .insert(crate::idempotency::IdempotencyCacheCommittedErrorResponse);
946        }
947        response.extensions_mut().insert(error_info);
948        response
949    }
950}
951
952#[cfg(test)]
953mod tests {
954    use super::*;
955    use axum::http::StatusCode;
956
957    #[derive(Debug)]
958    struct TestError(String);
959
960    impl std::fmt::Display for TestError {
961        fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
962            write!(f, "{}", self.0)
963        }
964    }
965
966    impl std::error::Error for TestError {}
967
968    #[derive(Debug)]
969    struct WrappedError {
970        message: String,
971        source: TestError,
972    }
973
974    impl std::fmt::Display for WrappedError {
975        fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
976            write!(f, "{}", self.message)
977        }
978    }
979
980    impl std::error::Error for WrappedError {
981        fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
982            Some(&self.source)
983        }
984    }
985
986    #[test]
987    fn blanket_from_defaults_to_500() {
988        let err: AutumnError = TestError("boom".into()).into();
989        assert_eq!(err.status(), StatusCode::INTERNAL_SERVER_ERROR);
990    }
991
992    #[test]
993    fn internal_server_error_is_500() {
994        let err = AutumnError::internal_server_error(TestError("boom".into()));
995        assert_eq!(err.status(), StatusCode::INTERNAL_SERVER_ERROR);
996    }
997
998    #[test]
999    fn test_not_found_error() {
1000        let err = AutumnError::not_found(std::io::Error::other("no such user"));
1001        assert_eq!(err.status(), StatusCode::NOT_FOUND);
1002    }
1003
1004    #[test]
1005    fn not_found_is_404() {
1006        let err = AutumnError::not_found(TestError("missing".into()));
1007        assert_eq!(err.status(), StatusCode::NOT_FOUND);
1008    }
1009
1010    #[test]
1011    fn bad_request_is_400() {
1012        let err = AutumnError::bad_request(TestError("invalid input".into()));
1013        assert_eq!(err.status(), StatusCode::BAD_REQUEST);
1014    }
1015
1016    #[test]
1017    fn unprocessable_is_422() {
1018        let err = AutumnError::unprocessable(TestError("bad entity".into()));
1019        assert_eq!(err.status(), StatusCode::UNPROCESSABLE_ENTITY);
1020    }
1021
1022    #[test]
1023    fn unauthorized_is_401() {
1024        let err = AutumnError::unauthorized(TestError("unauthorized".into()));
1025        assert_eq!(err.status(), StatusCode::UNAUTHORIZED);
1026    }
1027
1028    #[test]
1029    fn forbidden_is_403() {
1030        let err = AutumnError::forbidden(TestError("forbidden".into()));
1031        assert_eq!(err.status(), StatusCode::FORBIDDEN);
1032    }
1033
1034    #[test]
1035    fn validation_is_422() {
1036        let mut details = std::collections::HashMap::new();
1037        details.insert("field".to_string(), vec!["error".to_string()]);
1038        let err = AutumnError::validation(details);
1039        assert_eq!(err.status(), StatusCode::UNPROCESSABLE_ENTITY);
1040    }
1041
1042    #[test]
1043    fn service_unavailable_is_503() {
1044        let err = AutumnError::service_unavailable(TestError("pool exhausted".into()));
1045        assert_eq!(err.status(), StatusCode::SERVICE_UNAVAILABLE);
1046    }
1047
1048    #[test]
1049    fn internal_server_error_msg_is_500() {
1050        let err = AutumnError::internal_server_error_msg("db failure");
1051        assert_eq!(err.status(), StatusCode::INTERNAL_SERVER_ERROR);
1052        assert_eq!(err.to_string(), "db failure");
1053    }
1054
1055    #[test]
1056    fn not_found_msg_is_404() {
1057        let err = AutumnError::not_found_msg("no such user");
1058        assert_eq!(err.status(), StatusCode::NOT_FOUND);
1059        assert_eq!(err.to_string(), "no such user");
1060    }
1061
1062    #[test]
1063    fn bad_request_msg_is_400() {
1064        let err = AutumnError::bad_request_msg("invalid input");
1065        assert_eq!(err.status(), StatusCode::BAD_REQUEST);
1066    }
1067
1068    #[test]
1069    fn unprocessable_msg_is_422() {
1070        let err = AutumnError::unprocessable_msg("title required");
1071        assert_eq!(err.status(), StatusCode::UNPROCESSABLE_ENTITY);
1072    }
1073
1074    #[test]
1075    fn unauthorized_msg_is_401() {
1076        let err = AutumnError::unauthorized_msg("login required");
1077        assert_eq!(err.status(), StatusCode::UNAUTHORIZED);
1078    }
1079
1080    #[test]
1081    fn forbidden_msg_is_403() {
1082        let err = AutumnError::forbidden_msg("no access");
1083        assert_eq!(err.status(), StatusCode::FORBIDDEN);
1084    }
1085
1086    #[test]
1087    fn service_unavailable_msg_is_503() {
1088        let err = AutumnError::service_unavailable_msg("db down");
1089        assert_eq!(err.status(), StatusCode::SERVICE_UNAVAILABLE);
1090        assert_eq!(err.to_string(), "db down");
1091    }
1092
1093    #[test]
1094    fn with_status_overrides() {
1095        let err: AutumnError = TestError("forbidden".into()).into();
1096        let err = err.with_status(StatusCode::FORBIDDEN);
1097        assert_eq!(err.status(), StatusCode::FORBIDDEN);
1098    }
1099
1100    #[test]
1101    fn display_uses_inner_message() {
1102        let err: AutumnError = TestError("something broke".into()).into();
1103        assert_eq!(err.to_string(), "something broke");
1104    }
1105
1106    #[test]
1107    fn source_chain_lists_inner_sources() {
1108        let err = AutumnError::internal_server_error(WrappedError {
1109            message: "failed to backfill".to_string(),
1110            source: TestError("database connection dropped".to_string()),
1111        });
1112
1113        assert_eq!(
1114            err.source_chain(),
1115            vec!["database connection dropped".to_string()]
1116        );
1117    }
1118
1119    #[test]
1120    fn into_response_has_correct_status() {
1121        let err = AutumnError::not_found(TestError("not found".into()));
1122        let response = err.into_response();
1123        assert_eq!(response.status(), StatusCode::NOT_FOUND);
1124    }
1125
1126    #[tokio::test]
1127    async fn into_response_has_json_body() -> Result<(), axum::Error> {
1128        let err = AutumnError::not_found(TestError("not found".into()));
1129        let response = err.into_response();
1130
1131        let body = axum::body::to_bytes(response.into_body(), usize::MAX).await?;
1132        let json: serde_json::Value = serde_json::from_slice(&body).expect("valid json");
1133
1134        assert_eq!(json["status"], 404);
1135        assert_eq!(json["detail"], "not found");
1136        assert_eq!(json["code"], "autumn.not_found");
1137        Ok(())
1138    }
1139
1140    #[test]
1141    fn debug_shows_status_and_inner() {
1142        let err = AutumnError::bad_request(TestError("oops".into()));
1143        let debug = format!("{err:?}");
1144        assert!(debug.contains("AutumnError"));
1145        assert!(debug.contains("400"));
1146    }
1147
1148    #[tokio::test]
1149    async fn msg_constructor_produces_valid_json_response() -> Result<(), axum::Error> {
1150        let err = AutumnError::unprocessable_msg("title required");
1151        let response = err.into_response();
1152
1153        assert_eq!(response.status(), StatusCode::UNPROCESSABLE_ENTITY);
1154        let body = axum::body::to_bytes(response.into_body(), usize::MAX).await?;
1155        let json: serde_json::Value = serde_json::from_slice(&body).expect("valid json");
1156        assert_eq!(json["status"], 422);
1157        assert_eq!(json["detail"], "title required");
1158        assert_eq!(json["code"], "autumn.unprocessable_entity");
1159        Ok(())
1160    }
1161
1162    #[tokio::test]
1163    async fn service_unavailable_response_is_503() -> Result<(), axum::Error> {
1164        let err = AutumnError::service_unavailable_msg("db down");
1165        let response = err.into_response();
1166
1167        assert_eq!(response.status(), StatusCode::SERVICE_UNAVAILABLE);
1168        let body = axum::body::to_bytes(response.into_body(), usize::MAX).await?;
1169        let json: serde_json::Value = serde_json::from_slice(&body).expect("valid json");
1170        assert_eq!(json["status"], 503);
1171        assert_eq!(json["detail"], "db down");
1172        assert_eq!(json["code"], "autumn.service_unavailable");
1173        Ok(())
1174    }
1175
1176    #[test]
1177    fn conflict_is_409() {
1178        let err = AutumnError::conflict(TestError("stale version".into()));
1179        assert_eq!(err.status(), StatusCode::CONFLICT);
1180    }
1181
1182    #[test]
1183    fn conflict_msg_is_409() {
1184        let err = AutumnError::conflict_msg("please reload and retry");
1185        assert_eq!(err.status(), StatusCode::CONFLICT);
1186        assert_eq!(err.to_string(), "please reload and retry");
1187    }
1188
1189    #[test]
1190    fn gone_is_410() {
1191        let err = AutumnError::gone(TestError("sunsetted".into()));
1192        assert_eq!(err.status(), StatusCode::GONE);
1193    }
1194
1195    #[test]
1196    fn gone_msg_is_410() {
1197        let err = AutumnError::gone_msg("API version has been sunsetted");
1198        assert_eq!(err.status(), StatusCode::GONE);
1199        assert_eq!(err.to_string(), "API version has been sunsetted");
1200    }
1201
1202    #[tokio::test]
1203    async fn conflict_response_is_409_json() -> Result<(), axum::Error> {
1204        let err = AutumnError::conflict_msg("version mismatch");
1205        let response = err.into_response();
1206
1207        assert_eq!(response.status(), StatusCode::CONFLICT);
1208        let body = axum::body::to_bytes(response.into_body(), usize::MAX).await?;
1209        let json: serde_json::Value = serde_json::from_slice(&body).expect("valid json");
1210        assert_eq!(json["status"], 409);
1211        assert_eq!(json["detail"], "version mismatch");
1212        assert_eq!(json["type"], "https://autumn.dev/problems/conflict");
1213        assert_eq!(json["title"], "Conflict");
1214        Ok(())
1215    }
1216
1217    #[tokio::test]
1218    async fn conflict_response_has_hx_trigger_header() -> Result<(), axum::Error> {
1219        let err = AutumnError::conflict_msg("version mismatch");
1220        let response = err.into_response();
1221
1222        assert_eq!(response.status(), StatusCode::CONFLICT);
1223        let hx_trigger = response
1224            .headers()
1225            .get("HX-Trigger")
1226            .expect("HX-Trigger header present");
1227        assert_eq!(hx_trigger, r#"{"autumn:conflict":true}"#);
1228        Ok(())
1229    }
1230}