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/// # `Display`
130///
131/// `Display` prints the wrapped error's message. A validation error then
132/// appends its fields, sorted by field name:
133///
134/// ```text
135/// Validation failed: email: Must be a valid email address; title: Too short
136/// ```
137///
138/// Fields are separated by `"; "` and a field's messages by `", "`. A field
139/// with no messages is skipped. The messages are developer-authored, so do
140/// not put untrusted text in them: `Display` output reaches logs.
141///
142/// [`message`](AutumnError::message) returns the wrapped error's message
143/// alone. The response body renders that string, not this one, and redacts
144/// it for a `5xx` outside a dev profile. Neither is redacted here.
145///
146/// # Why no `Error` impl
147///
148/// `AutumnError` intentionally does **not** implement [`std::error::Error`].
149/// Doing so would conflict with the blanket `From<E: Error>` impl (the
150/// reflexive `From<T> for T` would overlap). This type is a *response*
151/// wrapper, not a propagatable error.
152pub struct AutumnError {
153    inner: Box<dyn std::error::Error + Send + Sync>,
154    status: StatusCode,
155    details: Option<std::collections::HashMap<String, Vec<String>>>,
156    problem_type: Option<&'static str>,
157    cache_idempotency_response: bool,
158    /// Backtrace captured at error creation time in debug builds.
159    /// Transferred to `AutumnErrorInfo` for the dev overlay.
160    #[cfg(debug_assertions)]
161    pub(crate) backtrace_string: Option<String>,
162}
163
164/// Convenience alias -- the standard return type for Autumn handlers.
165///
166/// Equivalent to `Result<T, AutumnError>`. Use this as the return type
167/// for any handler that might fail.
168///
169/// # Examples
170///
171/// ```rust,no_run
172/// use autumn_web::prelude::*;
173///
174/// #[get("/")]
175/// async fn index() -> AutumnResult<&'static str> {
176///     Ok("hello")
177/// }
178/// ```
179pub type AutumnResult<T> = Result<T, AutumnError>;
180
181impl<E> From<E> for AutumnError
182where
183    E: std::error::Error + Send + Sync + 'static,
184{
185    fn from(err: E) -> Self {
186        // #2423: Postgres refuses an embedded NUL byte in a TEXT/VARCHAR column
187        // (SQLSTATE 22021). That is malformed client input reaching the
188        // database, not a server bug, so it takes the 422 a `#[validate(...)]`
189        // rejection would have produced had a validator been able to see the
190        // byte. Handled first and by returning, so this classification has one
191        // unambiguous precedence rather than depending on where it sits among
192        // the status overrides below.
193        //
194        // The error is re-wrapped rather than merely restatused: the 422 page
195        // renders the message verbatim where the 500 page redacts it, so
196        // downgrading alone would put a raw Postgres message on screen. The
197        // original stays reachable as `source()`.
198        #[cfg(feature = "db")]
199        if error_chain_is_pg_nul_rejection(&err) {
200            return Self {
201                inner: Box::new(NulByteRejected(err)),
202                status: StatusCode::UNPROCESSABLE_ENTITY,
203                details: None,
204                problem_type: None,
205                cache_idempotency_response: false,
206                #[cfg(debug_assertions)]
207                backtrace_string: Some(format!("{}", std::backtrace::Backtrace::force_capture())),
208            };
209        }
210
211        let mut status = StatusCode::INTERNAL_SERVER_ERROR;
212        let any_err: &dyn std::any::Any = &err;
213
214        if std::any::type_name::<E>().contains("CircuitBreakerError")
215            && err.to_string() == "circuit breaker is open"
216        {
217            status = StatusCode::SERVICE_UNAVAILABLE;
218        }
219
220        #[cfg(feature = "http-client")]
221        {
222            if matches!(
223                any_err.downcast_ref::<crate::http_client::ClientError>(),
224                Some(
225                    crate::http_client::ClientError::CircuitBreakerOpen
226                        | crate::http_client::ClientError::ThrottledLocally { .. }
227                        | crate::http_client::ClientError::FaultInjected(_)
228                )
229            ) {
230                status = StatusCode::SERVICE_UNAVAILABLE;
231            }
232        }
233
234        // A failed service-to-service call (#1755) is a dependency fault, not
235        // a client one, so `?` on one in a handler produces 502 rather than
236        // blaming the caller for an upstream 404.
237        #[cfg(feature = "http-client")]
238        if let Some(wire_err) = any_err.downcast_ref::<crate::wire::WireError>() {
239            status = wire_err.status();
240        }
241
242        // Web Push (#1392) distinguishes client-fault failures (a malformed
243        // browser subscription, an endpoint already claimed) from server-fault
244        // ones, so an app calling `push.subscribe(…).await?` from its own
245        // handler gets the same status the built-in push router would return
246        // rather than a blanket 500.
247        if let Some(push_err) = any_err.downcast_ref::<crate::push::PushError>() {
248            status = push_err.status();
249        }
250
251        // A Constela document that fails to parse, validate or render is
252        // malformed input, not a server fault: `?` on one in a handler should
253        // produce the same 422 a `#[validate(...)]` rejection does rather than
254        // a 500. Mapped here, by downcast, because `AutumnError`'s blanket
255        // `From<E: Error>` impl above forecloses a dedicated `From` impl on
256        // any concrete error type in this crate.
257        #[cfg(feature = "constela")]
258        if let Some(constela_err) = any_err.downcast_ref::<crate::constela::ConstelaError>() {
259            status = constela_err.http_status();
260        }
261
262        // Money (#1837). A value that cannot be built or combined, and a
263        // posting the double-entry rules refuse, are malformed input rather
264        // than a server fault, so they take 422. A reused idempotency key and
265        // a refused negative balance are state conflicts, so they take 409.
266        // An overflow is a value the server built and could not hold, so it
267        // stays a 500. Mapped by downcast for the reason on the Constela arm.
268        // A SQLite fleet (ADR 0019): an unusable tenant id is the client's
269        // fault (400), an unknown tenant database is 404, a pending migration
270        // is 503. Mapped by downcast for the reason on the Constela arm.
271        #[cfg(feature = "sqlite")]
272        if let Some(fleet_err) = any_err.downcast_ref::<crate::db::fleet::FleetError>() {
273            status = fleet_err.http_status();
274        }
275
276        if let Some(money_err) = any_err.downcast_ref::<crate::money::MoneyError>() {
277            status = money_err.http_status();
278        }
279        #[cfg(feature = "db")]
280        if let Some(ledger_err) = any_err.downcast_ref::<crate::money::ledger::LedgerError>() {
281            status = ledger_err.http_status();
282        }
283
284        // Issue #3394: a form body that does not decode is the client's fault.
285        // 422 for a repeated single-value field, 400 for the rest.
286        if let Some(form_err) = any_err.downcast_ref::<crate::form::FormDecodeError>() {
287            status = form_err.status();
288        }
289
290        if matches!(
291            any_err.downcast_ref::<crate::lock::LockError>(),
292            Some(
293                crate::lock::LockError::PoolUnavailable(_)
294                    | crate::lock::LockError::Timeout { .. }
295                    | crate::lock::LockError::LeaseLost { .. }
296            )
297        ) {
298            status = StatusCode::SERVICE_UNAVAILABLE;
299        }
300
301        #[cfg(feature = "mail")]
302        {
303            if let Some(crate::mail::MailError::RuntimeUnavailable(msg)) =
304                any_err.downcast_ref::<crate::mail::MailError>()
305                && msg.contains("circuit breaker is open")
306            {
307                status = StatusCode::SERVICE_UNAVAILABLE;
308            }
309        }
310
311        // A per-tenant memory quota breach is a soft, retryable resource limit,
312        // so it maps to 503 Service Unavailable rather than a 500.
313        if any_err
314            .downcast_ref::<crate::tenant_cell::QuotaExceeded>()
315            .is_some()
316            || any_err
317                .downcast_ref::<crate::tenant_cell::TenantAllocationError>()
318                .is_some()
319        {
320            status = StatusCode::SERVICE_UNAVAILABLE;
321        }
322
323        // The request deadline stopped an upstream call or a
324        // `deadline::bounded` call (#3058), here or in a wrapped error. Last,
325        // so it wins over a wrapper's own status (a typed service call's
326        // `WireError::Transport` is a 502).
327        if chain_has_deadline_stop(&err, false) {
328            status = StatusCode::GATEWAY_TIMEOUT;
329        }
330
331        Self {
332            inner: Box::new(err),
333            status,
334            details: None,
335            problem_type: None,
336            cache_idempotency_response: false,
337            #[cfg(debug_assertions)]
338            backtrace_string: Some(format!("{}", std::backtrace::Backtrace::force_capture())),
339        }
340    }
341}
342
343impl AutumnError {
344    /// Override the HTTP status code.
345    ///
346    /// # Examples
347    ///
348    /// ```rust
349    /// use autumn_web::error::AutumnError;
350    /// use http::StatusCode;
351    ///
352    /// let err: AutumnError = std::io::Error::other("forbidden").into();
353    /// let err = err.with_status(StatusCode::FORBIDDEN);
354    /// assert_eq!(err.status(), StatusCode::FORBIDDEN);
355    /// ```
356    #[must_use]
357    pub const fn with_status(mut self, status: StatusCode) -> Self {
358        self.status = status;
359        self
360    }
361
362    /// Create a `500 Internal Server Error`.
363    ///
364    /// # Examples
365    ///
366    /// ```rust
367    /// use autumn_web::error::AutumnError;
368    /// use http::StatusCode;
369    ///
370    /// let err = AutumnError::internal_server_error(std::io::Error::other("boom"));
371    /// assert_eq!(err.status(), StatusCode::INTERNAL_SERVER_ERROR);
372    /// ```
373    pub fn internal_server_error(err: impl std::error::Error + Send + Sync + 'static) -> Self {
374        Self {
375            inner: Box::new(err),
376            status: StatusCode::INTERNAL_SERVER_ERROR,
377            details: None,
378            problem_type: None,
379            cache_idempotency_response: false,
380            #[cfg(debug_assertions)]
381            backtrace_string: Some(format!("{}", std::backtrace::Backtrace::force_capture())),
382        }
383    }
384
385    /// Create a `404 Not Found` error.
386    ///
387    /// # Examples
388    ///
389    /// ```rust
390    /// use autumn_web::error::AutumnError;
391    /// use http::StatusCode;
392    ///
393    /// let err = AutumnError::not_found(std::io::Error::other("no such user"));
394    /// assert_eq!(err.status(), StatusCode::NOT_FOUND);
395    /// ```
396    pub fn not_found(err: impl std::error::Error + Send + Sync + 'static) -> Self {
397        Self {
398            inner: Box::new(err),
399            status: StatusCode::NOT_FOUND,
400            details: None,
401            problem_type: None,
402            cache_idempotency_response: false,
403            #[cfg(debug_assertions)]
404            backtrace_string: Some(format!("{}", std::backtrace::Backtrace::force_capture())),
405        }
406    }
407
408    /// Create a `400 Bad Request` error.
409    ///
410    /// # Examples
411    ///
412    /// ```rust
413    /// use autumn_web::error::AutumnError;
414    /// use http::StatusCode;
415    ///
416    /// let err = AutumnError::bad_request(std::io::Error::other("invalid input"));
417    /// assert_eq!(err.status(), StatusCode::BAD_REQUEST);
418    /// ```
419    pub fn bad_request(err: impl std::error::Error + Send + Sync + 'static) -> Self {
420        Self {
421            inner: Box::new(err),
422            status: StatusCode::BAD_REQUEST,
423            details: None,
424            problem_type: None,
425            cache_idempotency_response: false,
426            #[cfg(debug_assertions)]
427            backtrace_string: Some(format!("{}", std::backtrace::Backtrace::force_capture())),
428        }
429    }
430
431    /// Create a `422 Unprocessable Entity` error.
432    ///
433    /// Use this for validation failures where the request is syntactically
434    /// valid but semantically incorrect.
435    ///
436    /// # Examples
437    ///
438    /// ```rust
439    /// use autumn_web::error::AutumnError;
440    /// use http::StatusCode;
441    ///
442    /// let err = AutumnError::unprocessable(std::io::Error::other("age must be positive"));
443    /// assert_eq!(err.status(), StatusCode::UNPROCESSABLE_ENTITY);
444    /// ```
445    pub fn unprocessable(err: impl std::error::Error + Send + Sync + 'static) -> Self {
446        Self {
447            inner: Box::new(err),
448            status: StatusCode::UNPROCESSABLE_ENTITY,
449            details: None,
450            problem_type: None,
451            cache_idempotency_response: false,
452            #[cfg(debug_assertions)]
453            backtrace_string: Some(format!("{}", std::backtrace::Backtrace::force_capture())),
454        }
455    }
456
457    /// Create a `503 Service Unavailable` error.
458    ///
459    /// # Examples
460    ///
461    /// ```rust
462    /// use autumn_web::error::AutumnError;
463    /// use http::StatusCode;
464    ///
465    /// let err = AutumnError::service_unavailable(std::io::Error::other("pool exhausted"));
466    /// assert_eq!(err.status(), StatusCode::SERVICE_UNAVAILABLE);
467    /// ```
468    pub fn service_unavailable(err: impl std::error::Error + Send + Sync + 'static) -> Self {
469        Self {
470            inner: Box::new(err),
471            status: StatusCode::SERVICE_UNAVAILABLE,
472            details: None,
473            problem_type: None,
474            cache_idempotency_response: false,
475            #[cfg(debug_assertions)]
476            backtrace_string: Some(format!("{}", std::backtrace::Backtrace::force_capture())),
477        }
478    }
479
480    /// Create a `401 Unauthorized` error.
481    ///
482    /// # Examples
483    ///
484    /// ```rust
485    /// use autumn_web::error::AutumnError;
486    /// use http::StatusCode;
487    ///
488    /// let err = AutumnError::unauthorized(std::io::Error::other("not logged in"));
489    /// assert_eq!(err.status(), StatusCode::UNAUTHORIZED);
490    /// ```
491    pub fn unauthorized(err: impl std::error::Error + Send + Sync + 'static) -> Self {
492        Self {
493            inner: Box::new(err),
494            status: StatusCode::UNAUTHORIZED,
495            details: None,
496            problem_type: None,
497            cache_idempotency_response: false,
498            #[cfg(debug_assertions)]
499            backtrace_string: Some(format!("{}", std::backtrace::Backtrace::force_capture())),
500        }
501    }
502
503    /// Create a `403 Forbidden` error.
504    ///
505    /// # Examples
506    ///
507    /// ```rust
508    /// use autumn_web::error::AutumnError;
509    /// use http::StatusCode;
510    ///
511    /// let err = AutumnError::forbidden(std::io::Error::other("not allowed"));
512    /// assert_eq!(err.status(), StatusCode::FORBIDDEN);
513    /// ```
514    pub fn forbidden(err: impl std::error::Error + Send + Sync + 'static) -> Self {
515        Self {
516            inner: Box::new(err),
517            status: StatusCode::FORBIDDEN,
518            details: None,
519            problem_type: None,
520            cache_idempotency_response: false,
521            #[cfg(debug_assertions)]
522            backtrace_string: Some(format!("{}", std::backtrace::Backtrace::force_capture())),
523        }
524    }
525
526    /// Create a `422 Unprocessable Entity` error with field-level
527    /// validation details.
528    ///
529    /// Use this when a request fails multiple field-specific validation rules
530    /// (e.g., in a form submission). It attaches the `details` parameter, a mapping
531    /// of field names to their respective error messages, so the client can display
532    /// errors next to the relevant inputs.
533    ///
534    /// # Examples
535    ///
536    /// ```rust
537    /// use autumn_web::error::AutumnError;
538    /// use http::StatusCode;
539    /// use std::collections::HashMap;
540    ///
541    /// let mut errors = HashMap::new();
542    /// errors.insert("username".to_string(), vec!["Username is taken".to_string()]);
543    ///
544    /// let err = AutumnError::validation(errors);
545    /// assert_eq!(err.status(), StatusCode::UNPROCESSABLE_ENTITY);
546    /// ```
547    #[must_use]
548    pub fn validation(details: std::collections::HashMap<String, Vec<String>>) -> Self {
549        Self {
550            inner: Box::new(StringError("Validation failed".into())),
551            status: StatusCode::UNPROCESSABLE_ENTITY,
552            details: Some(details),
553            problem_type: None,
554            cache_idempotency_response: false,
555            #[cfg(debug_assertions)]
556            backtrace_string: Some(format!("{}", std::backtrace::Backtrace::force_capture())),
557        }
558    }
559
560    // ── String-message convenience constructors ────────────────
561
562    /// Create a `500 Internal Server Error` from a plain string message.
563    ///
564    /// # Examples
565    ///
566    /// ```rust
567    /// use autumn_web::error::AutumnError;
568    /// use http::StatusCode;
569    ///
570    /// let err = AutumnError::internal_server_error_msg("Database explosion");
571    /// assert_eq!(err.status(), StatusCode::INTERNAL_SERVER_ERROR);
572    /// ```
573    pub fn internal_server_error_msg(msg: impl Into<String>) -> Self {
574        Self::internal_server_error(StringError(msg.into()))
575    }
576
577    /// Create a `404 Not Found` error from a plain string message.
578    ///
579    /// # Examples
580    ///
581    /// ```rust
582    /// use autumn_web::error::AutumnError;
583    /// use http::StatusCode;
584    ///
585    /// let err = AutumnError::not_found_msg("No such user");
586    /// assert_eq!(err.status(), StatusCode::NOT_FOUND);
587    /// assert_eq!(err.to_string(), "No such user");
588    /// ```
589    pub fn not_found_msg(msg: impl Into<String>) -> Self {
590        Self::not_found(StringError(msg.into()))
591    }
592
593    /// Create a `400 Bad Request` error from a plain string message.
594    ///
595    /// # Examples
596    ///
597    /// ```rust
598    /// use autumn_web::error::AutumnError;
599    /// use http::StatusCode;
600    ///
601    /// let err = AutumnError::bad_request_msg("Invalid input parameter");
602    /// assert_eq!(err.status(), StatusCode::BAD_REQUEST);
603    /// ```
604    pub fn bad_request_msg(msg: impl Into<String>) -> Self {
605        Self::bad_request(StringError(msg.into()))
606    }
607
608    /// Create a `422 Unprocessable Entity` error from a plain string message.
609    ///
610    /// # Examples
611    ///
612    /// ```rust
613    /// use autumn_web::error::AutumnError;
614    /// use http::StatusCode;
615    ///
616    /// let err = AutumnError::unprocessable_msg("Title is required");
617    /// assert_eq!(err.status(), StatusCode::UNPROCESSABLE_ENTITY);
618    /// ```
619    pub fn unprocessable_msg(msg: impl Into<String>) -> Self {
620        Self::unprocessable(StringError(msg.into()))
621    }
622
623    /// Create a `401 Unauthorized` error from a plain string message.
624    ///
625    /// # Examples
626    ///
627    /// ```rust
628    /// use autumn_web::error::AutumnError;
629    /// use http::StatusCode;
630    ///
631    /// let err = AutumnError::unauthorized_msg("Please log in to continue");
632    /// assert_eq!(err.status(), StatusCode::UNAUTHORIZED);
633    /// ```
634    pub fn unauthorized_msg(msg: impl Into<String>) -> Self {
635        Self::unauthorized(StringError(msg.into()))
636    }
637
638    /// Create a `403 Forbidden` error from a plain string message.
639    ///
640    /// # Examples
641    ///
642    /// ```rust
643    /// use autumn_web::error::AutumnError;
644    /// use http::StatusCode;
645    ///
646    /// let err = AutumnError::forbidden_msg("You lack admin privileges");
647    /// assert_eq!(err.status(), StatusCode::FORBIDDEN);
648    /// ```
649    pub fn forbidden_msg(msg: impl Into<String>) -> Self {
650        Self::forbidden(StringError(msg.into()))
651    }
652
653    /// Create a `503 Service Unavailable` error from a plain string message.
654    ///
655    /// # Examples
656    ///
657    /// ```rust
658    /// use autumn_web::error::AutumnError;
659    /// use http::StatusCode;
660    ///
661    /// let err = AutumnError::service_unavailable_msg("Database connection pool exhausted");
662    /// assert_eq!(err.status(), StatusCode::SERVICE_UNAVAILABLE);
663    /// ```
664    pub fn service_unavailable_msg(msg: impl Into<String>) -> Self {
665        Self::service_unavailable(StringError(msg.into()))
666    }
667
668    /// Create a `409 Conflict` error.
669    ///
670    /// Use this for optimistic-lock conflicts surfaced by repository `update`
671    /// calls when the client's expected version is stale.
672    ///
673    /// # Examples
674    ///
675    /// ```rust
676    /// use autumn_web::error::AutumnError;
677    /// use http::StatusCode;
678    ///
679    /// let err = AutumnError::conflict(std::io::Error::other("stale version"));
680    /// assert_eq!(err.status(), StatusCode::CONFLICT);
681    /// ```
682    pub fn conflict(err: impl std::error::Error + Send + Sync + 'static) -> Self {
683        Self {
684            inner: Box::new(err),
685            status: StatusCode::CONFLICT,
686            details: None,
687            problem_type: Some(PROBLEM_TYPE_CONFLICT),
688            cache_idempotency_response: false,
689            #[cfg(debug_assertions)]
690            backtrace_string: Some(format!("{}", std::backtrace::Backtrace::force_capture())),
691        }
692    }
693
694    /// Create a `409 Conflict` error from a plain string message.
695    ///
696    /// # Examples
697    ///
698    /// ```rust
699    /// use autumn_web::error::AutumnError;
700    /// use http::StatusCode;
701    ///
702    /// let err = AutumnError::conflict_msg("Concurrent edit: please reload and retry");
703    /// assert_eq!(err.status(), StatusCode::CONFLICT);
704    /// ```
705    pub fn conflict_msg(msg: impl Into<String>) -> Self {
706        Self::conflict(StringError(msg.into()))
707    }
708
709    /// Create a `410 Gone` error.
710    ///
711    /// # Examples
712    ///
713    /// ```rust
714    /// use autumn_web::error::AutumnError;
715    /// use http::StatusCode;
716    ///
717    /// let err = AutumnError::gone(std::io::Error::other("sunsetted"));
718    /// assert_eq!(err.status(), StatusCode::GONE);
719    /// ```
720    pub fn gone(err: impl std::error::Error + Send + Sync + 'static) -> Self {
721        Self {
722            inner: Box::new(err),
723            status: StatusCode::GONE,
724            details: None,
725            problem_type: Some(PROBLEM_TYPE_GONE),
726            cache_idempotency_response: false,
727            #[cfg(debug_assertions)]
728            backtrace_string: Some(format!("{}", std::backtrace::Backtrace::force_capture())),
729        }
730    }
731
732    /// Create a `410 Gone` error from a plain string message.
733    ///
734    /// # Examples
735    ///
736    /// ```rust
737    /// use autumn_web::error::AutumnError;
738    /// use http::StatusCode;
739    ///
740    /// let err = AutumnError::gone_msg("API version has been sunsetted");
741    /// assert_eq!(err.status(), StatusCode::GONE);
742    /// ```
743    pub fn gone_msg(msg: impl Into<String>) -> Self {
744        Self::gone(StringError(msg.into()))
745    }
746
747    /// Create a `503 Service Unavailable` error indicating that a database
748    /// query was cancelled due to a statement timeout (Postgres `57014`).
749    ///
750    /// The problem details payload carries `"autumn.query_timeout"` as the
751    /// machine-readable code, which allows clients to distinguish a transient
752    /// timeout from other 503 conditions and apply appropriate retry logic.
753    ///
754    /// # Examples
755    ///
756    /// ```rust
757    /// use autumn_web::error::AutumnError;
758    /// use http::StatusCode;
759    ///
760    /// let err = AutumnError::query_timeout("query exceeded statement_timeout");
761    /// assert_eq!(err.status(), StatusCode::SERVICE_UNAVAILABLE);
762    /// ```
763    pub fn query_timeout(msg: impl Into<String>) -> Self {
764        Self {
765            inner: Box::new(StringError(msg.into())),
766            status: StatusCode::SERVICE_UNAVAILABLE,
767            details: None,
768            problem_type: Some(PROBLEM_TYPE_QUERY_TIMEOUT),
769            cache_idempotency_response: false,
770            #[cfg(debug_assertions)]
771            backtrace_string: Some(format!("{}", std::backtrace::Backtrace::force_capture())),
772        }
773    }
774
775    /// Returns the HTTP status code associated with this error.
776    ///
777    /// This is the status the error was assigned. The response reclassifies
778    /// a cancelled database statement to `503`, so [`code`](Self::code) can
779    /// report `autumn.query_timeout` while this still reports the assigned
780    /// status.
781    ///
782    /// # Examples
783    ///
784    /// ```rust
785    /// use autumn_web::error::AutumnError;
786    /// use http::StatusCode;
787    ///
788    /// let err: AutumnError = std::io::Error::other("boom").into();
789    /// assert_eq!(err.status(), StatusCode::INTERNAL_SERVER_ERROR);
790    /// ```
791    #[must_use]
792    pub const fn status(&self) -> StatusCode {
793        self.status
794    }
795
796    /// Returns the validation messages, keyed by field name.
797    ///
798    /// The map is `Some` for [`AutumnError::validation`] and for a failed
799    /// [`ValidateExt::validate`](crate::ValidateExt::validate). It is `None`
800    /// for every other error. Read it where there is no HTTP response to
801    /// parse.
802    ///
803    /// The map is a [`HashMap`](std::collections::HashMap), so its iteration
804    /// order is unspecified. Sort the keys before you render them.
805    ///
806    /// An empty map is still `Some`. [`code`](Self::code) then reports the
807    /// status code, not `autumn.validation_failed`.
808    ///
809    /// # Examples
810    ///
811    /// ```rust
812    /// use autumn_web::error::AutumnError;
813    /// use std::collections::HashMap;
814    ///
815    /// let mut errors = HashMap::new();
816    /// errors.insert("email".to_string(), vec!["Invalid".to_string()]);
817    ///
818    /// let err = AutumnError::validation(errors);
819    /// assert_eq!(err.details().unwrap()["email"], ["Invalid".to_string()]);
820    /// ```
821    #[must_use]
822    pub const fn details(&self) -> Option<&std::collections::HashMap<String, Vec<String>>> {
823        self.details.as_ref()
824    }
825
826    /// The wrapped error's message, without the validation fields.
827    ///
828    /// [`Display`](std::fmt::Display) appends the failing fields on top of
829    /// this and is for a human reader. Use `message` where the string is
830    /// stored, broadcast, or compared across versions.
831    ///
832    /// **Not redacted, and never assume it is.** A response outside a dev
833    /// profile replaces a server-error `detail` with a generic line; this
834    /// still returns the wrapped error — a database or infrastructure
835    /// message. [`status`](Self::status) does not tell you which case you
836    /// are in: it reports the assigned status, and the renderer reclassifies
837    /// a cancelled database statement to a redacted `503`, so a `4xx` here
838    /// can still be a redacted response. Render the error when you need a
839    /// client-safe string.
840    ///
841    /// # Examples
842    ///
843    /// ```rust
844    /// use autumn_web::error::AutumnError;
845    /// use std::collections::HashMap;
846    ///
847    /// let mut errors = HashMap::new();
848    /// errors.insert("email".to_string(), vec!["Invalid".to_string()]);
849    ///
850    /// let err = AutumnError::validation(errors);
851    /// assert_eq!(err.message(), "Validation failed");
852    /// assert_eq!(err.to_string(), "Validation failed: email: Invalid");
853    /// ```
854    #[must_use]
855    pub fn message(&self) -> String {
856        self.inner.to_string()
857    }
858
859    /// The stable problem code the rendered response carries.
860    ///
861    /// Same value as the `code` member of the `application/problem+json`
862    /// body Autumn renders for this error — both come from one derivation —
863    /// so a non-HTTP caller can branch on it without building a response. It
864    /// is borrowed for every code the framework names today.
865    ///
866    /// Middleware that builds its own body from [`status`](Self::status)
867    /// alone, rather than rendering the error, can still carry a different
868    /// code.
869    ///
870    /// A cancelled database statement is reclassified here as it is in the
871    /// response, so this can read `autumn.query_timeout` where
872    /// [`status`](Self::status) still reads the assigned status. See that
873    /// method.
874    ///
875    /// # Examples
876    ///
877    /// ```rust
878    /// use autumn_web::error::AutumnError;
879    ///
880    /// assert_eq!(AutumnError::not_found_msg("no such user").code(), "autumn.not_found");
881    /// assert_eq!(AutumnError::gone_msg("sunsetted").code(), "autumn.gone");
882    /// ```
883    #[must_use]
884    pub fn code(&self) -> std::borrow::Cow<'static, str> {
885        let (status, problem_type) = self.rendered_problem();
886        problem_code(status, self.has_field_errors(), problem_type)
887    }
888
889    /// Whether this error names at least one field.
890    ///
891    /// A field with an empty message list still counts, so this matches the
892    /// `has_validation_errors` test the response body uses.
893    fn has_field_errors(&self) -> bool {
894        self.details.as_ref().is_some_and(|map| !map.is_empty())
895    }
896
897    /// Status and problem type the rendered response uses.
898    ///
899    /// A cancelled database statement arrives as a plain `500`; the response
900    /// demotes it to a `503` query timeout, so [`code`](Self::code) reads the
901    /// same classification the body shows. Reads the wrapped error, never
902    /// `Display`, so a validation message cannot reclassify a `422`.
903    pub(crate) fn rendered_problem(&self) -> (StatusCode, Option<&'static str>) {
904        let lowered = self.inner.to_string().to_lowercase();
905        if lowered.contains("57014")
906            || lowered.contains("query_canceled")
907            || lowered.contains("canceling statement due to statement timeout")
908            || lowered.contains("statement timeout")
909            || lowered.contains("query canceled")
910        {
911            return (
912                StatusCode::SERVICE_UNAVAILABLE,
913                Some(PROBLEM_TYPE_QUERY_TIMEOUT),
914            );
915        }
916        (self.status, self.problem_type)
917    }
918
919    #[doc(hidden)]
920    #[must_use]
921    pub(crate) const fn cache_idempotency_response(mut self) -> Self {
922        self.cache_idempotency_response = true;
923        self
924    }
925
926    /// Return the wrapped error's source chain as displayable messages.
927    ///
928    /// [`message`](Self::message) already gives the wrapped error's own
929    /// message, so this list starts at that error's first source.
930    #[must_use]
931    pub fn source_chain(&self) -> Vec<String> {
932        let mut chain = Vec::new();
933        let mut source = self.inner.source();
934        while let Some(error) = source {
935            chain.push(error.to_string());
936            source = error.source();
937        }
938        chain
939    }
940
941    /// Try to downcast the inner error to a specific type.
942    #[must_use]
943    pub fn downcast_ref<T: std::error::Error + 'static>(&self) -> Option<&T> {
944        let err: &(dyn std::error::Error + 'static) = self.inner.as_ref();
945        err.downcast_ref::<T>()
946    }
947
948    /// Try to downcast the inner error, or any error in its `source()` chain,
949    /// to a specific type.
950    ///
951    /// Unlike [`downcast_ref`](Self::downcast_ref), which only inspects the
952    /// top-level wrapped error, this walks the full chain — useful when a
953    /// custom error type wraps a lower-level error (e.g. via
954    /// `#[source]`/`#[from]`) without itself being that type.
955    #[must_use]
956    pub fn downcast_chain_ref<T: std::error::Error + 'static>(&self) -> Option<&T> {
957        let mut current: Option<&(dyn std::error::Error + 'static)> = Some(self.inner.as_ref());
958        while let Some(err) = current {
959            if let Some(t) = err.downcast_ref::<T>() {
960                return Some(t);
961            }
962            current = err.source();
963        }
964        None
965    }
966}
967
968/// Checks whether `err`'s inner error is a Postgres unique-constraint
969/// violation (SQLSTATE `23505`) matching `mapping` (issue #1032).
970///
971/// If `err` is a unique-violation whose constraint name matches one of
972/// `mapping`'s entries, returns the offending field name and the message to
973/// surface inline. `mapping` pairs each unique index/constraint name (e.g.
974/// `idx_users_email_unique` — see `autumn generate`'s
975/// `schema_edit::unique_index_sql`) with the form field name and message to
976/// show when that constraint is violated.
977///
978/// Returns `None` for any other error, or for an unrecognized constraint
979/// name — both cases the caller should propagate normally (`?`/[`From`]),
980/// which falls through to the blanket `500` mapping. This is the single
981/// shared place this classification happens; generated `create`/`update`
982/// handlers call it instead of hand-rolling a `DatabaseErrorKind` match
983/// per scaffold.
984///
985/// Works whether `err` wraps a raw `diesel::result::Error` directly (a bare
986/// `.execute(...).await?`) or one already converted by a generated
987/// repository method (`repo.save(...).await?`) — both route the original
988/// diesel error through the `?` operator's blanket [`From`] impl, so
989/// [`AutumnError::downcast_ref`] recovers it either way.
990///
991/// # Examples
992///
993/// ```rust
994/// use autumn_web::error::{AutumnError, unique_violation_field};
995///
996/// let err = AutumnError::internal_server_error_msg("not a db error");
997/// assert_eq!(unique_violation_field(&err, &[("idx_users_email_unique", "email", "taken")]), None);
998/// ```
999///
1000/// # `SQLite`
1001///
1002/// Postgres reports the violated constraint by name (`constraint_name()`),
1003/// which is what `mapping` keys on. `SQLite` reports no constraint name at
1004/// all — diesel boxes its error information as a bare `String`, whose
1005/// `constraint_name()` is always `None` — so a violation is instead resolved
1006/// by column: `SQLite`'s own message names the offending column(s) (e.g.
1007/// `UNIQUE constraint failed: widgets.email`), and `mapping`'s `field` for a
1008/// single-column unique index already spells that column name (`autumn
1009/// generate`'s `schema_edit::unique_index_sql` names the field it maps a
1010/// violation to the same way). A composite unique index still resolves
1011/// correctly because at least one of its columns is the mapped field (see
1012/// `idx_invitations_pending_email` in `examples/teams`, mapped to `"email"`).
1013#[cfg(feature = "db")]
1014#[must_use]
1015pub fn unique_violation_field<'a>(
1016    err: &AutumnError,
1017    mapping: &'a [(&str, &str, &str)],
1018) -> Option<(&'a str, &'a str)> {
1019    let diesel::result::Error::DatabaseError(
1020        diesel::result::DatabaseErrorKind::UniqueViolation,
1021        info,
1022    ) = err.downcast_ref::<diesel::result::Error>()?
1023    else {
1024        return None;
1025    };
1026    if let Some(constraint) = info.constraint_name() {
1027        return mapping
1028            .iter()
1029            .find(|(c, _, _)| *c == constraint)
1030            .map(|(_, field, message)| (*field, *message));
1031    }
1032    let violated_columns = sqlite_unique_violation_columns(info.message())?;
1033    mapping
1034        .iter()
1035        .find(|(_, field, _)| violated_columns.iter().any(|column| column == field))
1036        .map(|(_, field, message)| (*field, *message))
1037}
1038
1039/// Parses the column names out of `SQLite`'s own unique-violation wording,
1040/// e.g. `"UNIQUE constraint failed: widgets.email"` ->
1041/// `["email"]`, or `"UNIQUE constraint failed: invitations.tenant_id,
1042/// invitations.email"` -> `["tenant_id", "email"]` for a composite index.
1043/// Returns `None` for any other message (a non-`SQLite` backend, or a
1044/// `SQLite` error that is not this one) so callers fall through unmatched
1045/// rather than misreading unrelated text.
1046#[cfg(feature = "db")]
1047fn sqlite_unique_violation_columns(message: &str) -> Option<Vec<&str>> {
1048    let columns = message.strip_prefix("UNIQUE constraint failed: ")?;
1049    Some(
1050        columns
1051            .split(", ")
1052            .map(|qualified| qualified.rsplit('.').next().unwrap_or(qualified))
1053            .collect(),
1054    )
1055}
1056
1057// ── #2423: Postgres refuses a NUL byte in TEXT — that is client input ──────
1058
1059/// The client-facing message substituted for a Postgres NUL-byte rejection.
1060///
1061/// The raw server message (`invalid byte sequence for encoding "UTF8": 0x00`)
1062/// is a database internal. It must not become the visible message, because the
1063/// `422` error page renders `message` verbatim where the `500` page
1064/// deliberately does not — see `error_pages::defaults`' `render_422` and
1065/// `render_500`. Downgrading the status alone would therefore move a database
1066/// message onto a page that shows it.
1067pub const NUL_BYTE_REJECTED_MESSAGE: &str =
1068    "The submitted text contains a NUL character (0x00), which cannot be stored.";
1069
1070/// Wrapper that gives a classified NUL rejection [`NUL_BYTE_REJECTED_MESSAGE`]
1071/// as its `Display` while keeping the original error as its `source()`, so
1072/// logs, error reporting and [`AutumnError::downcast_chain_ref`] still reach
1073/// the underlying diesel error.
1074#[cfg(feature = "db")]
1075#[derive(Debug)]
1076pub(crate) struct NulByteRejected<E>(E);
1077
1078#[cfg(feature = "db")]
1079impl<E> std::fmt::Display for NulByteRejected<E> {
1080    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1081        f.write_str(NUL_BYTE_REJECTED_MESSAGE)
1082    }
1083}
1084
1085#[cfg(feature = "db")]
1086impl<E: std::error::Error + 'static> std::error::Error for NulByteRejected<E> {
1087    fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
1088        Some(&self.0)
1089    }
1090}
1091
1092/// Whether `err` carries a Postgres rejection of an embedded NUL byte
1093/// (SQLSTATE `22021` for text, `22P05` for `JSONB`; issues #2423, #2439) —
1094/// malformed *client* input rather than a server bug.
1095///
1096/// A `TEXT`/`VARCHAR` column cannot hold `0x00`, so a value carrying one is
1097/// refused at `INSERT`/`UPDATE` time no matter how it arrived. The blanket
1098/// [`From`] impl already downgrades such an error to
1099/// `422 Unprocessable Entity`; this predicate is for handlers that want to go
1100/// further and fold it back into a form as a field error, the way
1101/// [`unique_violation_field`] is used for a uniqueness clash.
1102///
1103/// Walks the whole `source()` chain — the same walk the [`From`] impl makes,
1104/// through the one shared helper, so the two can never disagree about whether
1105/// a given error is this one.
1106///
1107/// Prefer preventing it: [`crate::form::ChangesetForm`] already rejects a NUL
1108/// at the form boundary with [`crate::form::NUL_CHARACTER_FIELD_ERROR`], so
1109/// this only fires for the paths no form extractor sees — a JSON API body, a
1110/// hand-written query, a background job.
1111///
1112/// # Examples
1113///
1114/// ```rust
1115/// use autumn_web::error::{AutumnError, is_nul_byte_violation};
1116///
1117/// let err = AutumnError::internal_server_error_msg("not a db error");
1118/// assert!(!is_nul_byte_violation(&err));
1119/// ```
1120#[cfg(feature = "db")]
1121#[must_use]
1122pub fn is_nul_byte_violation(err: &AutumnError) -> bool {
1123    error_chain_is_pg_nul_rejection(err.inner.as_ref())
1124}
1125
1126/// Whether `err`, or anything in its `source()` chain, is Postgres refusing an
1127/// embedded NUL byte.
1128///
1129/// The single definition of "this was the client's byte, not our bug", shared
1130/// by [`is_nul_byte_violation`] and the blanket [`From`] impl. The chain walk
1131/// matters because a repository or service may wrap the diesel error in its own
1132/// type, and it continues past a non-matching diesel error rather than stopping
1133/// at the first one found.
1134#[cfg(feature = "db")]
1135pub(crate) fn error_chain_is_pg_nul_rejection(err: &(dyn std::error::Error + 'static)) -> bool {
1136    let mut current = Some(err);
1137    while let Some(cause) = current {
1138        if cause
1139            .downcast_ref::<diesel::result::Error>()
1140            .is_some_and(is_pg_nul_byte_error)
1141        {
1142            return true;
1143        }
1144        current = cause.source();
1145    }
1146    false
1147}
1148
1149/// Whether a raw diesel error is Postgres refusing an embedded NUL byte.
1150#[cfg(feature = "db")]
1151fn is_pg_nul_byte_error(err: &diesel::result::Error) -> bool {
1152    let diesel::result::Error::DatabaseError(_, info) = err else {
1153        return false;
1154    };
1155    pg_message_is_nul_rejection(info.message()) || pg_error_is_jsonb_nul_rejection(info.as_ref())
1156}
1157
1158/// Classify a Postgres server message as the `22021` NUL rejection.
1159///
1160/// Message matching, not SQLSTATE matching, because the code is unavailable:
1161/// `diesel-async`'s `pg/error_helper.rs` maps every SQLSTATE it does not
1162/// special-case (`22021` among them) to `DatabaseErrorKind::Unknown` and keeps
1163/// no copy of the code, and diesel's `DatabaseErrorInformation` trait exposes no
1164/// accessor for it.
1165///
1166/// **Anchored on purpose.** Postgres echoes submitted text into the primary
1167/// message of other errors (`invalid input syntax for type integer: "…"`,
1168/// `column "…" does not exist`), so a substring search for `0x00` is
1169/// attacker-satisfiable: a client that submits the right literal could get a
1170/// genuine server fault relabelled as its own fault — hidden from 5xx alerting
1171/// and rendered on the 422 page. Requiring the message to *end* with `: 0x00`
1172/// defeats that, because an echoed value is always followed by a closing quote
1173/// or trailing prose. `UTF` must appear too, and always does: `tokio-postgres`
1174/// fixes `client_encoding` to `UTF8`, so the encoding the server names in this
1175/// message is `UTF8` on every connection this framework opens.
1176///
1177/// The failure this leaves is a locale whose translation moves the byte literal
1178/// off the end of the message — the error then keeps its `500`, which is
1179/// exactly the pre-#2423 behavior. Failing back to "server bug" is the safe
1180/// direction; failing forward to "client's fault" is not.
1181#[cfg(feature = "db")]
1182fn pg_message_is_nul_rejection(message: &str) -> bool {
1183    message.ends_with(": 0x00") && message.contains("UTF")
1184}
1185
1186/// Classify a Postgres error as the `22P05` NUL-in-JSON rejection (`JSONB`).
1187///
1188/// Postgres refuses `\u0000` inside a JSON string with the message
1189/// `unsupported Unicode escape sequence` and the detail
1190/// `\u0000 cannot be converted to text.` A client can echo text into a message,
1191/// so the message and the detail must both match exactly. A Postgres 16 server
1192/// returned these texts. If a locale translates either text, the error stays a
1193/// `500`. This is safe.
1194#[cfg(feature = "db")]
1195fn pg_error_is_jsonb_nul_rejection(info: &dyn diesel::result::DatabaseErrorInformation) -> bool {
1196    info.message() == "unsupported Unicode escape sequence"
1197        && info.details() == Some(r"\u0000 cannot be converted to text.")
1198}
1199
1200/// Paths of the string values in `body` that hold a NUL character.
1201///
1202/// Each path uses the field names that `ChangesetForm` reports: `title`,
1203/// `address.street`, `items[1].sku`. Call it in a handler after
1204/// [`is_nul_byte_violation`] is true. The generated `#[repository(api = ...)]`
1205/// write handlers call it for you.
1206///
1207/// # Examples
1208///
1209/// ```rust
1210/// use autumn_web::error::nul_byte_json_fields;
1211///
1212/// let body = serde_json::json!({"title": "ok", "tags": ["a\u{0}"]});
1213/// assert_eq!(nul_byte_json_fields(&body), ["tags[0]"]);
1214/// ```
1215#[cfg(feature = "db")]
1216#[must_use]
1217pub fn nul_byte_json_fields(body: &serde_json::Value) -> Vec<String> {
1218    fn walk(value: &serde_json::Value, path: &str, out: &mut Vec<String>) {
1219        match value {
1220            serde_json::Value::String(text) if text.contains('\0') => out.push(path.to_owned()),
1221            serde_json::Value::Array(items) => {
1222                for (index, item) in items.iter().enumerate() {
1223                    walk(item, &format!("{path}[{index}]"), out);
1224                }
1225            }
1226            serde_json::Value::Object(map) => {
1227                for (key, item) in map {
1228                    let child = if path.is_empty() {
1229                        key.clone()
1230                    } else {
1231                        format!("{path}.{key}")
1232                    };
1233                    walk(item, &child, out);
1234                }
1235            }
1236            _ => {}
1237        }
1238    }
1239    let mut out = Vec::new();
1240    walk(body, "", &mut out);
1241    out
1242}
1243
1244/// Add the field name to a NUL rejection. The `422` then has an `errors[]`
1245/// entry.
1246///
1247/// Postgres does not say which column held the byte. The generated write
1248/// handlers look for it in the payload. `body` runs only for a NUL rejection.
1249/// Other errors pass through unchanged. A NUL that is not found also passes
1250/// through unchanged.
1251///
1252/// Not part of the stable API — used only by macro-generated code.
1253#[cfg(feature = "db")]
1254#[doc(hidden)]
1255#[must_use]
1256pub fn __name_nul_fields(
1257    mut err: AutumnError,
1258    body: impl FnOnce() -> Option<serde_json::Value>,
1259) -> AutumnError {
1260    if err.details.is_some() || !is_nul_byte_violation(&err) {
1261        return err;
1262    }
1263    let fields = body().map(|body| nul_byte_json_fields(&body));
1264    if let Some(fields) = fields.filter(|fields| !fields.is_empty()) {
1265        err.details = Some(
1266            fields
1267                .into_iter()
1268                .map(|field| {
1269                    (
1270                        field,
1271                        vec![crate::form::NUL_CHARACTER_FIELD_ERROR.to_owned()],
1272                    )
1273                })
1274                .collect(),
1275        );
1276    }
1277    err
1278}
1279
1280// A write payload can lack `Serialize`, for example a hand-written `NewModel`.
1281// The handlers pick the JSON view with autoref specialization, as
1282// `validation::MaybeValidate` does.
1283
1284/// Wrapper for autoref specialization. Generated code uses it with
1285/// [`__name_nul_fields`].
1286///
1287/// Not part of the stable API — used only by macro-generated code.
1288#[cfg(feature = "db")]
1289#[doc(hidden)]
1290pub struct MaybeJsonBody<'a, T>(pub &'a T);
1291
1292/// Specialized branch: the payload implements `Serialize`.
1293#[cfg(feature = "db")]
1294#[doc(hidden)]
1295pub trait MaybeJsonBodyViaSerialize {
1296    /// The payload as JSON.
1297    fn autumn_json_body(&self) -> Option<serde_json::Value>;
1298}
1299
1300#[cfg(feature = "db")]
1301impl<T: serde::Serialize> MaybeJsonBodyViaSerialize for MaybeJsonBody<'_, T> {
1302    fn autumn_json_body(&self) -> Option<serde_json::Value> {
1303        serde_json::to_value(self.0).ok()
1304    }
1305}
1306
1307/// Fallback branch: the payload has no JSON view.
1308#[cfg(feature = "db")]
1309#[doc(hidden)]
1310pub trait MaybeJsonBodyFallback {
1311    /// Always `None`.
1312    fn autumn_json_body(&self) -> Option<serde_json::Value>;
1313}
1314
1315#[cfg(feature = "db")]
1316impl<T> MaybeJsonBodyFallback for &MaybeJsonBody<'_, T> {
1317    fn autumn_json_body(&self) -> Option<serde_json::Value> {
1318        None
1319    }
1320}
1321
1322impl std::fmt::Display for AutumnError {
1323    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1324        write!(f, "{}", self.inner)?;
1325
1326        // Fold the field map in, so `err.to_string()` says which field failed
1327        // off the HTTP path. Sorted, so the rendering is stable across runs.
1328        // The problem+json `detail` renders the wrapped error alone and is
1329        // unaffected.
1330        let mut fields: Vec<_> = self
1331            .details
1332            .iter()
1333            .flatten()
1334            .filter(|(_, messages)| !messages.is_empty())
1335            .collect();
1336        fields.sort_by_key(|(left, _)| *left);
1337
1338        for (index, (field, messages)) in fields.into_iter().enumerate() {
1339            f.write_str(if index == 0 { ": " } else { "; " })?;
1340            write!(f, "{field}: ")?;
1341            for (position, message) in messages.iter().enumerate() {
1342                if position > 0 {
1343                    f.write_str(", ")?;
1344                }
1345                f.write_str(message)?;
1346            }
1347        }
1348        Ok(())
1349    }
1350}
1351
1352impl std::fmt::Debug for AutumnError {
1353    #[allow(clippy::missing_fields_in_debug)]
1354    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1355        f.debug_struct("AutumnError")
1356            .field("status", &self.status)
1357            .field("inner", &self.inner)
1358            .field("details", &self.details)
1359            .field("problem_type", &self.problem_type)
1360            .field(
1361                "cache_idempotency_response",
1362                &self.cache_idempotency_response,
1363            )
1364            .finish_non_exhaustive()
1365    }
1366}
1367
1368impl ProblemDetails {
1369    /// Build a Problem Details payload from framework error metadata.
1370    #[must_use]
1371    pub fn new(
1372        status: StatusCode,
1373        detail: impl Into<String>,
1374        details: Option<&std::collections::HashMap<String, Vec<String>>>,
1375    ) -> Self {
1376        problem_details(status, detail.into(), details, None, None, None, true)
1377    }
1378}
1379
1380/// Build the canonical Problem Details payload.
1381#[must_use]
1382pub(crate) fn problem_details(
1383    status: StatusCode,
1384    detail: String,
1385    details: Option<&std::collections::HashMap<String, Vec<String>>>,
1386    explicit_type: Option<&'static str>,
1387    request_id: Option<String>,
1388    instance: Option<String>,
1389    expose_internal_detail: bool,
1390) -> ProblemDetails {
1391    let has_validation_errors = details.is_some_and(|map| !map.is_empty());
1392    let safe_detail = if status.is_server_error() && !expose_internal_detail {
1393        server_error_detail(status)
1394    } else {
1395        detail
1396    };
1397
1398    let code = problem_code(status, has_validation_errors, explicit_type).into_owned();
1399
1400    ProblemDetails {
1401        type_uri: explicit_type
1402            .unwrap_or_else(|| problem_type_for(status, has_validation_errors))
1403            .to_owned(),
1404        title: problem_title_for(status, has_validation_errors).to_owned(),
1405        status: status.as_u16(),
1406        detail: safe_detail,
1407        instance,
1408        code,
1409        request_id,
1410        errors: validation_errors(details),
1411    }
1412}
1413
1414/// Serialize a Problem Details payload for middleware that cannot return
1415/// `axum::Json` directly because its response body type is generic.
1416#[must_use]
1417pub(crate) fn problem_details_json_string(
1418    status: StatusCode,
1419    detail: impl Into<String>,
1420    details: Option<&std::collections::HashMap<String, Vec<String>>>,
1421    explicit_type: Option<&'static str>,
1422    request_id: Option<String>,
1423    instance: Option<String>,
1424    expose_internal_detail: bool,
1425) -> String {
1426    let problem = problem_details(
1427        status,
1428        detail.into(),
1429        details,
1430        explicit_type,
1431        request_id,
1432        instance,
1433        expose_internal_detail,
1434    );
1435    problem_details_to_json_string(&problem)
1436}
1437
1438/// Serialize an already-built Problem Details payload.
1439#[must_use]
1440pub(crate) fn problem_details_to_json_string(problem: &ProblemDetails) -> String {
1441    serde_json::to_string(&problem).unwrap_or_else(|_| {
1442        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()
1443    })
1444}
1445
1446fn validation_errors(
1447    details: Option<&std::collections::HashMap<String, Vec<String>>>,
1448) -> Vec<ProblemFieldError> {
1449    let mut errors: Vec<_> = details
1450        .into_iter()
1451        .flat_map(std::collections::HashMap::iter)
1452        .filter(|(_, messages)| !messages.is_empty())
1453        .map(|(field, messages)| ProblemFieldError {
1454            field: field.clone(),
1455            messages: messages.clone(),
1456        })
1457        .collect();
1458    errors.sort_by(|left, right| left.field.cmp(&right.field));
1459    errors
1460}
1461
1462// Problem-type URIs Autumn attaches explicitly, overriding the one the status
1463// alone would select.
1464const PROBLEM_TYPE_CONFLICT: &str = "https://autumn.dev/problems/conflict";
1465const PROBLEM_TYPE_GONE: &str = "https://autumn.dev/problems/gone";
1466const PROBLEM_TYPE_QUERY_TIMEOUT: &str = "https://autumn.dev/problems/query-timeout";
1467
1468/// The machine-readable code for a rendered problem.
1469///
1470/// One derivation for both the response body and [`AutumnError::code`], so
1471/// the two cannot disagree. An explicit type names the code through its last
1472/// path segment (hyphens to underscores, `autumn.` prefix); otherwise the
1473/// status and the presence of field errors do.
1474///
1475/// `"https://autumn.dev/problems/query-timeout"` → `"autumn.query_timeout"`.
1476fn problem_code(
1477    status: StatusCode,
1478    has_validation_errors: bool,
1479    explicit_type: Option<&str>,
1480) -> std::borrow::Cow<'static, str> {
1481    explicit_type.map_or_else(
1482        || std::borrow::Cow::Borrowed(problem_code_for(status, has_validation_errors)),
1483        |type_uri| {
1484            let slug = type_uri.rsplit('/').next().unwrap_or(type_uri);
1485            std::borrow::Cow::Owned(format!("autumn.{}", slug.replace('-', "_")))
1486        },
1487    )
1488}
1489
1490const fn problem_type_for(status: StatusCode, has_validation_errors: bool) -> &'static str {
1491    if has_validation_errors {
1492        return "https://autumn.dev/problems/validation-failed";
1493    }
1494
1495    match status {
1496        StatusCode::BAD_REQUEST => "https://autumn.dev/problems/bad-request",
1497        StatusCode::UNAUTHORIZED => "https://autumn.dev/problems/unauthorized",
1498        StatusCode::FORBIDDEN => "https://autumn.dev/problems/forbidden",
1499        StatusCode::NOT_FOUND => "https://autumn.dev/problems/not-found",
1500        StatusCode::GONE => "https://autumn.dev/problems/gone",
1501        StatusCode::CONFLICT => "https://autumn.dev/problems/conflict",
1502        StatusCode::PAYLOAD_TOO_LARGE => "https://autumn.dev/problems/payload-too-large",
1503        StatusCode::UNPROCESSABLE_ENTITY => "https://autumn.dev/problems/unprocessable-entity",
1504        StatusCode::INTERNAL_SERVER_ERROR => "https://autumn.dev/problems/internal-server-error",
1505        StatusCode::NOT_IMPLEMENTED => "https://autumn.dev/problems/not-implemented",
1506        StatusCode::SERVICE_UNAVAILABLE => "https://autumn.dev/problems/service-unavailable",
1507        _ => "about:blank",
1508    }
1509}
1510
1511fn problem_title_for(status: StatusCode, has_validation_errors: bool) -> &'static str {
1512    if has_validation_errors {
1513        return "Validation Failed";
1514    }
1515
1516    match status {
1517        StatusCode::BAD_REQUEST => "Bad Request",
1518        StatusCode::UNAUTHORIZED => "Unauthorized",
1519        StatusCode::FORBIDDEN => "Forbidden",
1520        StatusCode::NOT_FOUND => "Not Found",
1521        StatusCode::GONE => "Gone",
1522        StatusCode::CONFLICT => "Conflict",
1523        StatusCode::PAYLOAD_TOO_LARGE => "Payload Too Large",
1524        StatusCode::UNPROCESSABLE_ENTITY => "Unprocessable Entity",
1525        StatusCode::INTERNAL_SERVER_ERROR => "Internal Server Error",
1526        StatusCode::NOT_IMPLEMENTED => "Not Implemented",
1527        StatusCode::SERVICE_UNAVAILABLE => "Service Unavailable",
1528        _ => status.canonical_reason().unwrap_or("Error"),
1529    }
1530}
1531
1532fn problem_code_for(status: StatusCode, has_validation_errors: bool) -> &'static str {
1533    if has_validation_errors {
1534        return "autumn.validation_failed";
1535    }
1536
1537    match status {
1538        StatusCode::BAD_REQUEST => "autumn.bad_request",
1539        StatusCode::UNAUTHORIZED => "autumn.unauthorized",
1540        StatusCode::FORBIDDEN => "autumn.forbidden",
1541        StatusCode::NOT_FOUND => "autumn.not_found",
1542        StatusCode::GONE => "autumn.gone",
1543        StatusCode::CONFLICT => "autumn.conflict",
1544        StatusCode::PAYLOAD_TOO_LARGE => "autumn.payload_too_large",
1545        StatusCode::UNPROCESSABLE_ENTITY => "autumn.unprocessable_entity",
1546        StatusCode::INTERNAL_SERVER_ERROR => "autumn.internal_server_error",
1547        StatusCode::NOT_IMPLEMENTED => "autumn.not_implemented",
1548        StatusCode::SERVICE_UNAVAILABLE => "autumn.service_unavailable",
1549        _ if status.is_client_error() => "autumn.client_error",
1550        _ if status.is_server_error() => "autumn.server_error",
1551        _ => "autumn.error",
1552    }
1553}
1554
1555fn server_error_detail(status: StatusCode) -> String {
1556    match status {
1557        StatusCode::SERVICE_UNAVAILABLE => "Service unavailable".to_owned(),
1558        StatusCode::NOT_IMPLEMENTED => "Not implemented".to_owned(),
1559        _ => "Internal server error".to_owned(),
1560    }
1561}
1562
1563impl AutumnError {
1564    /// `true` when the request deadline stopped the work behind this error
1565    /// (issue #3058): a `ClientError::DeadlineExceeded`, a
1566    /// [`crate::deadline::DeadlineExceeded`], or a framework call such as a
1567    /// database connection wait that the deadline cut.
1568    fn is_deadline_stop(&self) -> bool {
1569        chain_has_deadline_stop(self.inner.as_ref(), true)
1570    }
1571}
1572
1573/// `true` when `err`, or an error in its `source()` chain, is a request
1574/// deadline stop (issue #3058). With `with_stopped`, a framework
1575/// [`crate::deadline::DeadlineStopped`] counts too; it is left out of the
1576/// status mapping because its caller already chose the status.
1577fn chain_has_deadline_stop(err: &(dyn std::error::Error + 'static), with_stopped: bool) -> bool {
1578    std::iter::successors(Some(err), |err| err.source()).any(|err| {
1579        #[cfg(feature = "http-client")]
1580        if matches!(
1581            err.downcast_ref::<crate::http_client::ClientError>(),
1582            Some(crate::http_client::ClientError::DeadlineExceeded)
1583        ) {
1584            return true;
1585        }
1586        err.downcast_ref::<crate::deadline::DeadlineExceeded>()
1587            .is_some()
1588            || (with_stopped
1589                && err
1590                    .downcast_ref::<crate::deadline::DeadlineStopped>()
1591                    .is_some())
1592    })
1593}
1594
1595impl IntoResponse for AutumnError {
1596    fn into_response(self) -> Response {
1597        let deadline_stop = self.is_deadline_stop();
1598        let message = self.inner.to_string();
1599        let (status, problem_type) = self.rendered_problem();
1600
1601        let details = self.details.clone();
1602        let cache_idempotency_response = self.cache_idempotency_response;
1603
1604        // Stash error metadata for exception filters to inspect without
1605        // parsing the response body.
1606        let error_info = crate::middleware::AutumnErrorInfo {
1607            status,
1608            message: message.clone(),
1609            details: details.clone(),
1610            problem_type,
1611            #[cfg(debug_assertions)]
1612            backtrace_string: self.backtrace_string.clone(),
1613            #[cfg(not(debug_assertions))]
1614            backtrace_string: None,
1615        };
1616
1617        let body = problem_details(
1618            status,
1619            message,
1620            details.as_ref(),
1621            problem_type,
1622            None,
1623            None,
1624            true,
1625        );
1626        let mut response = (status, axum::Json(body)).into_response();
1627        response.headers_mut().insert(
1628            header::CONTENT_TYPE,
1629            HeaderValue::from_static("application/problem+json"),
1630        );
1631        if status == StatusCode::CONFLICT {
1632            response.headers_mut().insert(
1633                "HX-Trigger",
1634                HeaderValue::from_static(r#"{"autumn:conflict":true}"#),
1635            );
1636        }
1637        if cache_idempotency_response {
1638            response
1639                .extensions_mut()
1640                .insert(crate::idempotency::IdempotencyCacheCommittedErrorResponse);
1641        }
1642        // A handler the deadline stopped may leave partial session state,
1643        // as one the timeout layer cancelled would, so the session layer
1644        // must not save it.
1645        if deadline_stop {
1646            response
1647                .extensions_mut()
1648                .insert(crate::router::RequestDeadlineCancelled);
1649        }
1650        response.extensions_mut().insert(error_info);
1651        response
1652    }
1653}
1654
1655#[cfg(test)]
1656mod tests {
1657    use super::*;
1658    use axum::http::StatusCode;
1659
1660    #[test]
1661    fn a_deadline_stop_is_marked_as_cancelled() {
1662        let marked = |error: AutumnError| {
1663            let response = error.into_response();
1664            (
1665                response.status(),
1666                response
1667                    .extensions()
1668                    .get::<crate::router::RequestDeadlineCancelled>()
1669                    .is_some(),
1670            )
1671        };
1672        assert_eq!(
1673            marked(crate::deadline::DeadlineExceeded.into()),
1674            (StatusCode::GATEWAY_TIMEOUT, true)
1675        );
1676        #[cfg(feature = "http-client")]
1677        assert_eq!(
1678            marked(crate::http_client::ClientError::DeadlineExceeded.into()),
1679            (StatusCode::GATEWAY_TIMEOUT, true)
1680        );
1681        assert_eq!(
1682            marked(AutumnError::service_unavailable(
1683                crate::deadline::DeadlineStopped("db wait")
1684            )),
1685            (StatusCode::SERVICE_UNAVAILABLE, true),
1686            "a deadline-cut connection wait keeps its 503"
1687        );
1688        assert_eq!(
1689            marked(std::io::Error::other("boom").into()),
1690            (StatusCode::INTERNAL_SERVER_ERROR, false)
1691        );
1692    }
1693
1694    /// A handler's own error that wraps a deadline stop.
1695    #[derive(Debug, thiserror::Error)]
1696    #[error("lookup failed")]
1697    struct Wrapped(#[source] Box<dyn std::error::Error + Send + Sync>);
1698
1699    #[test]
1700    fn a_wrapped_deadline_stop_is_a_504_marked_as_cancelled() {
1701        let response =
1702            AutumnError::from(Wrapped(Box::new(crate::deadline::DeadlineExceeded))).into_response();
1703        assert_eq!(response.status(), StatusCode::GATEWAY_TIMEOUT);
1704        assert!(
1705            response
1706                .extensions()
1707                .get::<crate::router::RequestDeadlineCancelled>()
1708                .is_some()
1709        );
1710        #[cfg(feature = "http-client")]
1711        {
1712            let response = AutumnError::from(Wrapped(Box::new(
1713                crate::http_client::ClientError::DeadlineExceeded,
1714            )))
1715            .into_response();
1716            assert_eq!(response.status(), StatusCode::GATEWAY_TIMEOUT);
1717            assert!(
1718                response
1719                    .extensions()
1720                    .get::<crate::router::RequestDeadlineCancelled>()
1721                    .is_some()
1722            );
1723        }
1724        let response = AutumnError::service_unavailable(Wrapped(Box::new(
1725            crate::deadline::DeadlineStopped("db wait"),
1726        )))
1727        .into_response();
1728        assert_eq!(response.status(), StatusCode::SERVICE_UNAVAILABLE);
1729        assert!(
1730            response
1731                .extensions()
1732                .get::<crate::router::RequestDeadlineCancelled>()
1733                .is_some()
1734        );
1735    }
1736
1737    #[cfg(feature = "http-client")]
1738    #[test]
1739    fn a_service_call_the_deadline_stopped_is_a_504() {
1740        let error = AutumnError::from(crate::wire::WireError::Transport {
1741            endpoint: "users.get",
1742            source: crate::http_client::ClientError::DeadlineExceeded,
1743        });
1744        assert_eq!(error.status(), StatusCode::GATEWAY_TIMEOUT);
1745    }
1746
1747    #[cfg(feature = "http-client")]
1748    #[test]
1749    fn an_outbound_deadline_exceeded_is_a_504() {
1750        let error = AutumnError::from(crate::http_client::ClientError::DeadlineExceeded);
1751        assert_eq!(error.status(), StatusCode::GATEWAY_TIMEOUT);
1752    }
1753
1754    #[test]
1755    fn a_bounded_call_past_its_deadline_is_a_504() {
1756        let error = AutumnError::from(crate::deadline::DeadlineExceeded);
1757        assert_eq!(error.status(), StatusCode::GATEWAY_TIMEOUT);
1758    }
1759
1760    #[derive(Debug)]
1761    struct TestError(String);
1762
1763    impl std::fmt::Display for TestError {
1764        fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1765            write!(f, "{}", self.0)
1766        }
1767    }
1768
1769    impl std::error::Error for TestError {}
1770
1771    #[derive(Debug)]
1772    struct WrappedError {
1773        message: String,
1774        source: TestError,
1775    }
1776
1777    impl std::fmt::Display for WrappedError {
1778        fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1779            write!(f, "{}", self.message)
1780        }
1781    }
1782
1783    impl std::error::Error for WrappedError {
1784        fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
1785            Some(&self.source)
1786        }
1787    }
1788
1789    #[test]
1790    fn blanket_from_defaults_to_500() {
1791        let err: AutumnError = TestError("boom".into()).into();
1792        assert_eq!(err.status(), StatusCode::INTERNAL_SERVER_ERROR);
1793    }
1794
1795    #[test]
1796    fn internal_server_error_is_500() {
1797        let err = AutumnError::internal_server_error(TestError("boom".into()));
1798        assert_eq!(err.status(), StatusCode::INTERNAL_SERVER_ERROR);
1799    }
1800
1801    #[test]
1802    fn test_not_found_error() {
1803        let err = AutumnError::not_found(std::io::Error::other("no such user"));
1804        assert_eq!(err.status(), StatusCode::NOT_FOUND);
1805    }
1806
1807    #[test]
1808    fn not_found_is_404() {
1809        let err = AutumnError::not_found(TestError("missing".into()));
1810        assert_eq!(err.status(), StatusCode::NOT_FOUND);
1811    }
1812
1813    #[test]
1814    fn bad_request_is_400() {
1815        let err = AutumnError::bad_request(TestError("invalid input".into()));
1816        assert_eq!(err.status(), StatusCode::BAD_REQUEST);
1817    }
1818
1819    #[test]
1820    fn unprocessable_is_422() {
1821        let err = AutumnError::unprocessable(TestError("bad entity".into()));
1822        assert_eq!(err.status(), StatusCode::UNPROCESSABLE_ENTITY);
1823    }
1824
1825    #[test]
1826    fn unauthorized_is_401() {
1827        let err = AutumnError::unauthorized(TestError("unauthorized".into()));
1828        assert_eq!(err.status(), StatusCode::UNAUTHORIZED);
1829    }
1830
1831    #[test]
1832    fn forbidden_is_403() {
1833        let err = AutumnError::forbidden(TestError("forbidden".into()));
1834        assert_eq!(err.status(), StatusCode::FORBIDDEN);
1835    }
1836
1837    #[test]
1838    fn validation_is_422() {
1839        let mut details = std::collections::HashMap::new();
1840        details.insert("field".to_string(), vec!["error".to_string()]);
1841        let err = AutumnError::validation(details);
1842        assert_eq!(err.status(), StatusCode::UNPROCESSABLE_ENTITY);
1843    }
1844
1845    #[test]
1846    fn service_unavailable_is_503() {
1847        let err = AutumnError::service_unavailable(TestError("pool exhausted".into()));
1848        assert_eq!(err.status(), StatusCode::SERVICE_UNAVAILABLE);
1849    }
1850
1851    #[test]
1852    fn internal_server_error_msg_is_500() {
1853        let err = AutumnError::internal_server_error_msg("db failure");
1854        assert_eq!(err.status(), StatusCode::INTERNAL_SERVER_ERROR);
1855        assert_eq!(err.to_string(), "db failure");
1856    }
1857
1858    #[test]
1859    fn not_found_msg_is_404() {
1860        let err = AutumnError::not_found_msg("no such user");
1861        assert_eq!(err.status(), StatusCode::NOT_FOUND);
1862        assert_eq!(err.to_string(), "no such user");
1863    }
1864
1865    #[test]
1866    fn bad_request_msg_is_400() {
1867        let err = AutumnError::bad_request_msg("invalid input");
1868        assert_eq!(err.status(), StatusCode::BAD_REQUEST);
1869    }
1870
1871    #[test]
1872    fn unprocessable_msg_is_422() {
1873        let err = AutumnError::unprocessable_msg("title required");
1874        assert_eq!(err.status(), StatusCode::UNPROCESSABLE_ENTITY);
1875    }
1876
1877    #[test]
1878    fn unauthorized_msg_is_401() {
1879        let err = AutumnError::unauthorized_msg("login required");
1880        assert_eq!(err.status(), StatusCode::UNAUTHORIZED);
1881    }
1882
1883    #[test]
1884    fn forbidden_msg_is_403() {
1885        let err = AutumnError::forbidden_msg("no access");
1886        assert_eq!(err.status(), StatusCode::FORBIDDEN);
1887    }
1888
1889    #[test]
1890    fn service_unavailable_msg_is_503() {
1891        let err = AutumnError::service_unavailable_msg("db down");
1892        assert_eq!(err.status(), StatusCode::SERVICE_UNAVAILABLE);
1893        assert_eq!(err.to_string(), "db down");
1894    }
1895
1896    #[test]
1897    fn with_status_overrides() {
1898        let err: AutumnError = TestError("forbidden".into()).into();
1899        let err = err.with_status(StatusCode::FORBIDDEN);
1900        assert_eq!(err.status(), StatusCode::FORBIDDEN);
1901    }
1902
1903    #[test]
1904    fn display_uses_inner_message() {
1905        let err: AutumnError = TestError("something broke".into()).into();
1906        assert_eq!(err.to_string(), "something broke");
1907    }
1908
1909    #[test]
1910    fn source_chain_lists_inner_sources() {
1911        let err = AutumnError::internal_server_error(WrappedError {
1912            message: "failed to backfill".to_string(),
1913            source: TestError("database connection dropped".to_string()),
1914        });
1915
1916        assert_eq!(
1917            err.source_chain(),
1918            vec!["database connection dropped".to_string()]
1919        );
1920    }
1921
1922    #[test]
1923    fn into_response_has_correct_status() {
1924        let err = AutumnError::not_found(TestError("not found".into()));
1925        let response = err.into_response();
1926        assert_eq!(response.status(), StatusCode::NOT_FOUND);
1927    }
1928
1929    #[tokio::test]
1930    async fn into_response_has_json_body() -> Result<(), axum::Error> {
1931        let err = AutumnError::not_found(TestError("not found".into()));
1932        let response = err.into_response();
1933
1934        let body = axum::body::to_bytes(response.into_body(), usize::MAX).await?;
1935        let json: serde_json::Value = serde_json::from_slice(&body).expect("valid json");
1936
1937        assert_eq!(json["status"], 404);
1938        assert_eq!(json["detail"], "not found");
1939        assert_eq!(json["code"], "autumn.not_found");
1940        Ok(())
1941    }
1942
1943    #[test]
1944    fn debug_shows_status_and_inner() {
1945        let err = AutumnError::bad_request(TestError("oops".into()));
1946        let debug = format!("{err:?}");
1947        assert!(debug.contains("AutumnError"));
1948        assert!(debug.contains("400"));
1949    }
1950
1951    #[tokio::test]
1952    async fn msg_constructor_produces_valid_json_response() -> Result<(), axum::Error> {
1953        let err = AutumnError::unprocessable_msg("title required");
1954        let response = err.into_response();
1955
1956        assert_eq!(response.status(), StatusCode::UNPROCESSABLE_ENTITY);
1957        let body = axum::body::to_bytes(response.into_body(), usize::MAX).await?;
1958        let json: serde_json::Value = serde_json::from_slice(&body).expect("valid json");
1959        assert_eq!(json["status"], 422);
1960        assert_eq!(json["detail"], "title required");
1961        assert_eq!(json["code"], "autumn.unprocessable_entity");
1962        Ok(())
1963    }
1964
1965    #[tokio::test]
1966    async fn service_unavailable_response_is_503() -> Result<(), axum::Error> {
1967        let err = AutumnError::service_unavailable_msg("db down");
1968        let response = err.into_response();
1969
1970        assert_eq!(response.status(), StatusCode::SERVICE_UNAVAILABLE);
1971        let body = axum::body::to_bytes(response.into_body(), usize::MAX).await?;
1972        let json: serde_json::Value = serde_json::from_slice(&body).expect("valid json");
1973        assert_eq!(json["status"], 503);
1974        assert_eq!(json["detail"], "db down");
1975        assert_eq!(json["code"], "autumn.service_unavailable");
1976        Ok(())
1977    }
1978
1979    #[test]
1980    fn conflict_is_409() {
1981        let err = AutumnError::conflict(TestError("stale version".into()));
1982        assert_eq!(err.status(), StatusCode::CONFLICT);
1983    }
1984
1985    #[test]
1986    fn conflict_msg_is_409() {
1987        let err = AutumnError::conflict_msg("please reload and retry");
1988        assert_eq!(err.status(), StatusCode::CONFLICT);
1989        assert_eq!(err.to_string(), "please reload and retry");
1990    }
1991
1992    #[test]
1993    fn gone_is_410() {
1994        let err = AutumnError::gone(TestError("sunsetted".into()));
1995        assert_eq!(err.status(), StatusCode::GONE);
1996    }
1997
1998    #[test]
1999    fn gone_msg_is_410() {
2000        let err = AutumnError::gone_msg("API version has been sunsetted");
2001        assert_eq!(err.status(), StatusCode::GONE);
2002        assert_eq!(err.to_string(), "API version has been sunsetted");
2003    }
2004
2005    #[tokio::test]
2006    async fn conflict_response_is_409_json() -> Result<(), axum::Error> {
2007        let err = AutumnError::conflict_msg("version mismatch");
2008        let response = err.into_response();
2009
2010        assert_eq!(response.status(), StatusCode::CONFLICT);
2011        let body = axum::body::to_bytes(response.into_body(), usize::MAX).await?;
2012        let json: serde_json::Value = serde_json::from_slice(&body).expect("valid json");
2013        assert_eq!(json["status"], 409);
2014        assert_eq!(json["detail"], "version mismatch");
2015        assert_eq!(json["type"], "https://autumn.dev/problems/conflict");
2016        assert_eq!(json["title"], "Conflict");
2017        Ok(())
2018    }
2019
2020    #[tokio::test]
2021    async fn conflict_response_has_hx_trigger_header() -> Result<(), axum::Error> {
2022        let err = AutumnError::conflict_msg("version mismatch");
2023        let response = err.into_response();
2024
2025        assert_eq!(response.status(), StatusCode::CONFLICT);
2026        let hx_trigger = response
2027            .headers()
2028            .get("HX-Trigger")
2029            .expect("HX-Trigger header present");
2030        assert_eq!(hx_trigger, r#"{"autumn:conflict":true}"#);
2031        Ok(())
2032    }
2033
2034    // ── #2423: Postgres rejects NUL in TEXT — that is client input ──────────
2035
2036    #[cfg(feature = "db")]
2037    mod nul_byte_violation_tests {
2038        use super::*;
2039        use crate::error::is_nul_byte_violation;
2040
2041        /// A `DatabaseErrorInformation` carrying only a server message —
2042        /// SQLSTATE is not exposed by diesel's trait (see
2043        /// `diesel-async`'s `pg/error_helper.rs`, which maps `22021` to
2044        /// `DatabaseErrorKind::Unknown` and drops the code), so the message is
2045        /// all the classifier has to go on.
2046        #[derive(Debug)]
2047        struct FakeMessageInfo(&'static str, Option<&'static str>);
2048
2049        impl diesel::result::DatabaseErrorInformation for FakeMessageInfo {
2050            fn message(&self) -> &str {
2051                self.0
2052            }
2053            fn details(&self) -> Option<&str> {
2054                self.1
2055            }
2056            fn hint(&self) -> Option<&str> {
2057                None
2058            }
2059            fn table_name(&self) -> Option<&str> {
2060                None
2061            }
2062            fn column_name(&self) -> Option<&str> {
2063                None
2064            }
2065            fn constraint_name(&self) -> Option<&str> {
2066                None
2067            }
2068            fn statement_position(&self) -> Option<i32> {
2069                None
2070            }
2071        }
2072
2073        fn unknown_db_error(message: &'static str) -> diesel::result::Error {
2074            diesel::result::Error::DatabaseError(
2075                diesel::result::DatabaseErrorKind::Unknown,
2076                Box::new(FakeMessageInfo(message, None)),
2077            )
2078        }
2079
2080        fn unknown_db_error_with_detail(
2081            message: &'static str,
2082            detail: &'static str,
2083        ) -> diesel::result::Error {
2084            diesel::result::Error::DatabaseError(
2085                diesel::result::DatabaseErrorKind::Unknown,
2086                Box::new(FakeMessageInfo(message, Some(detail))),
2087            )
2088        }
2089
2090        /// Postgres 16 text for SQLSTATE `22P05`: a NUL inside a JSON string.
2091        const PG_JSONB_NUL_MESSAGE: &str = "unsupported Unicode escape sequence";
2092        const PG_JSONB_NUL_DETAIL: &str = r"\u0000 cannot be converted to text.";
2093
2094        #[test]
2095        fn pg_jsonb_nul_error_maps_to_422_not_500() {
2096            let err: AutumnError =
2097                unknown_db_error_with_detail(PG_JSONB_NUL_MESSAGE, PG_JSONB_NUL_DETAIL).into();
2098            assert_eq!(err.status(), StatusCode::UNPROCESSABLE_ENTITY);
2099            assert!(is_nul_byte_violation(&err));
2100            assert_eq!(err.to_string(), crate::error::NUL_BYTE_REJECTED_MESSAGE);
2101        }
2102
2103        /// The message alone must not classify. The detail must also match.
2104        #[test]
2105        fn jsonb_message_without_the_nul_detail_stays_500() {
2106            let err: AutumnError = unknown_db_error(PG_JSONB_NUL_MESSAGE).into();
2107            assert_eq!(err.status(), StatusCode::INTERNAL_SERVER_ERROR);
2108            let err: AutumnError = unknown_db_error_with_detail(
2109                PG_JSONB_NUL_MESSAGE,
2110                r"\ud83d is an unpaired surrogate.",
2111            )
2112            .into();
2113            assert_eq!(err.status(), StatusCode::INTERNAL_SERVER_ERROR);
2114        }
2115
2116        /// A client can echo text into a message. The pair must match exactly.
2117        #[test]
2118        fn echoed_jsonb_text_does_not_classify() {
2119            let err: AutumnError = unknown_db_error_with_detail(
2120                r#"invalid input syntax for type integer: "unsupported Unicode escape sequence""#,
2121                PG_JSONB_NUL_DETAIL,
2122            )
2123            .into();
2124            assert_eq!(err.status(), StatusCode::INTERNAL_SERVER_ERROR);
2125        }
2126
2127        /// The exact message Postgres 16 returns for SQLSTATE `22021`.
2128        const PG_NUL_MESSAGE: &str = r#"invalid byte sequence for encoding "UTF8": 0x00"#;
2129
2130        #[test]
2131        fn pg_nul_byte_error_maps_to_422_not_500() {
2132            let err: AutumnError = unknown_db_error(PG_NUL_MESSAGE).into();
2133            assert_eq!(err.status(), StatusCode::UNPROCESSABLE_ENTITY);
2134        }
2135
2136        #[test]
2137        fn predicate_recognizes_the_pg_nul_byte_error() {
2138            let err: AutumnError = unknown_db_error(PG_NUL_MESSAGE).into();
2139            assert!(is_nul_byte_violation(&err));
2140        }
2141
2142        /// `lc_messages` translates the prose but leaves the encoding name and
2143        /// the byte literal alone, so a non-English server still classifies.
2144        #[test]
2145        fn localized_pg_nul_byte_message_is_still_recognized() {
2146            let err: AutumnError =
2147                unknown_db_error("ungueltige Byte-Sequenz fuer Kodierung \u{ab}UTF8\u{bb}: 0x00")
2148                    .into();
2149            assert_eq!(err.status(), StatusCode::UNPROCESSABLE_ENTITY);
2150        }
2151
2152        /// The raw server message must not reach the client: the 422 error page
2153        /// renders `message` verbatim where the 500 page redacts it, so
2154        /// downgrading the status without re-wrapping would newly expose a
2155        /// database internal on a user-facing page.
2156        #[test]
2157        fn the_raw_postgres_message_is_not_the_client_facing_message() {
2158            let err: AutumnError = unknown_db_error(PG_NUL_MESSAGE).into();
2159            assert_eq!(err.to_string(), crate::error::NUL_BYTE_REJECTED_MESSAGE);
2160            assert!(!err.to_string().contains("UTF8"));
2161        }
2162
2163        /// ...but the original is still reachable for logs, error reporting and
2164        /// handler-side classification.
2165        #[test]
2166        fn the_original_diesel_error_survives_as_a_source() {
2167            let err: AutumnError = unknown_db_error(PG_NUL_MESSAGE).into();
2168            let inner = err
2169                .downcast_chain_ref::<diesel::result::Error>()
2170                .expect("the diesel error must stay reachable through the chain");
2171            assert!(inner.to_string().contains("0x00"));
2172        }
2173
2174        /// Narrow by construction: any other database error keeps its 500, so
2175        /// a genuine server bug is never relabelled as the client's fault.
2176        #[test]
2177        fn other_db_errors_still_map_to_500() {
2178            for message in [
2179                "division by zero",
2180                "relation \"posts\" does not exist",
2181                // Mentions a byte literal but is not the encoding rejection.
2182                "invalid input syntax for type bytea: 0x00",
2183            ] {
2184                let err: AutumnError = unknown_db_error(message).into();
2185                assert_eq!(
2186                    err.status(),
2187                    StatusCode::INTERNAL_SERVER_ERROR,
2188                    "unexpectedly reclassified: {message}"
2189                );
2190                assert!(!is_nul_byte_violation(&err), "false positive: {message}");
2191            }
2192        }
2193
2194        /// Postgres echoes submitted text into the primary message of other
2195        /// errors. A client must not be able to spell the classifier's pattern
2196        /// inside its own input and so relabel a real server fault as its own
2197        /// fault — which would hide it from 5xx alerting and put the message on
2198        /// the 422 page. The end-anchor is what defeats this.
2199        #[test]
2200        fn attacker_supplied_text_echoed_into_a_message_does_not_classify() {
2201            for message in [
2202                // The value the client submitted, echoed and quoted.
2203                "invalid input syntax for type integer: \"UTF: 0x00\"",
2204                "invalid input syntax for type uuid: \"0x00 UTF8\"",
2205                // A dynamic identifier built from client input.
2206                "column \"0x00 UTF8 encoding\" does not exist",
2207            ] {
2208                let err: AutumnError = unknown_db_error(message).into();
2209                assert_eq!(
2210                    err.status(),
2211                    StatusCode::INTERNAL_SERVER_ERROR,
2212                    "client-spellable message was reclassified: {message}"
2213                );
2214            }
2215        }
2216
2217        /// The chain walk continues past a non-matching diesel error rather
2218        /// than stopping at the first one, and the predicate agrees with the
2219        /// status the `From` impl assigned.
2220        #[test]
2221        fn predicate_and_status_agree_through_a_wrapping_error() {
2222            #[derive(Debug)]
2223            struct Wrapper(diesel::result::Error);
2224            impl std::fmt::Display for Wrapper {
2225                fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
2226                    write!(f, "repository failed")
2227                }
2228            }
2229            impl std::error::Error for Wrapper {
2230                fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
2231                    Some(&self.0)
2232                }
2233            }
2234
2235            let err: AutumnError = Wrapper(unknown_db_error(PG_NUL_MESSAGE)).into();
2236            assert_eq!(err.status(), StatusCode::UNPROCESSABLE_ENTITY);
2237            assert!(
2238                is_nul_byte_violation(&err),
2239                "the predicate must agree with the status the From impl chose"
2240            );
2241        }
2242
2243        #[test]
2244        fn predicate_is_false_for_non_db_errors() {
2245            let err = AutumnError::internal_server_error_msg(PG_NUL_MESSAGE);
2246            assert!(!is_nul_byte_violation(&err));
2247        }
2248
2249        // ── Naming the field (#2439) ────────────────────────────────────────
2250
2251        // Both traits must be in scope for autoref resolution.
2252        #[allow(unused_imports)]
2253        use crate::error::{
2254            __name_nul_fields, MaybeJsonBody, MaybeJsonBodyFallback as _,
2255            MaybeJsonBodyViaSerialize as _, nul_byte_json_fields,
2256        };
2257
2258        #[test]
2259        fn json_fields_name_top_level_nested_and_array_strings() {
2260            let body = serde_json::json!({
2261                "title": "ok",
2262                "body": "a\u{0}b",
2263                "address": {"street": "x\u{0}"},
2264                "items": [{"sku": "fine"}, {"sku": "\u{0}"}],
2265                "tags": ["ok", "n\u{0}"],
2266                "count": 3,
2267            });
2268            let mut fields = nul_byte_json_fields(&body);
2269            fields.sort();
2270            assert_eq!(
2271                fields,
2272                ["address.street", "body", "items[1].sku", "tags[1]"]
2273            );
2274        }
2275
2276        #[test]
2277        fn json_fields_are_empty_without_a_nul() {
2278            let body = serde_json::json!({"title": "ok", "n": [1, {"a": "b"}]});
2279            assert!(nul_byte_json_fields(&body).is_empty());
2280        }
2281
2282        #[test]
2283        fn a_nul_violation_gets_the_field_named() {
2284            let err: AutumnError = unknown_db_error(PG_NUL_MESSAGE).into();
2285            let err = __name_nul_fields(err, || Some(serde_json::json!({"body": "a\u{0}b"})));
2286            assert_eq!(err.status(), StatusCode::UNPROCESSABLE_ENTITY);
2287            let details = err.details().expect("the field is named");
2288            assert_eq!(details.len(), 1);
2289            assert_eq!(
2290                details["body"],
2291                [crate::form::NUL_CHARACTER_FIELD_ERROR.to_owned()]
2292            );
2293            assert!(is_nul_byte_violation(&err), "the original stays reachable");
2294        }
2295
2296        #[test]
2297        fn a_jsonb_nul_violation_gets_the_field_named() {
2298            let err: AutumnError =
2299                unknown_db_error_with_detail(PG_JSONB_NUL_MESSAGE, PG_JSONB_NUL_DETAIL).into();
2300            let err = __name_nul_fields(err, || Some(serde_json::json!({"meta": {"k": "\u{0}"}})));
2301            assert!(err.details().expect("named").contains_key("meta.k"));
2302        }
2303
2304        #[test]
2305        fn other_errors_are_left_alone_and_the_body_is_never_built() {
2306            let built = std::cell::Cell::new(false);
2307            let err: AutumnError = unknown_db_error("division by zero").into();
2308            let err = __name_nul_fields(err, || {
2309                built.set(true);
2310                Some(serde_json::json!({"body": "\u{0}"}))
2311            });
2312            assert_eq!(err.status(), StatusCode::INTERNAL_SERVER_ERROR);
2313            assert!(!err.has_field_errors());
2314            assert!(!built.get(), "the cold path must not serialize");
2315        }
2316
2317        #[test]
2318        fn an_unlocatable_nul_keeps_the_empty_error_list() {
2319            let err: AutumnError = unknown_db_error(PG_NUL_MESSAGE).into();
2320            let err = __name_nul_fields(err, || None);
2321            assert_eq!(err.status(), StatusCode::UNPROCESSABLE_ENTITY);
2322            assert!(!err.has_field_errors());
2323        }
2324
2325        // The extra `&` makes autoref pick the right branch.
2326        #[allow(clippy::needless_borrow)]
2327        #[test]
2328        fn only_serializable_payloads_expose_a_json_body() {
2329            #[derive(serde::Serialize)]
2330            struct Yes {
2331                body: String,
2332            }
2333            struct No;
2334            let yes = Yes { body: "x".into() };
2335            assert_eq!(
2336                (&MaybeJsonBody(&yes)).autumn_json_body(),
2337                Some(serde_json::json!({"body": "x"}))
2338            );
2339            assert_eq!((&MaybeJsonBody(&No)).autumn_json_body(), None);
2340        }
2341    }
2342
2343    // ── unique_violation_field (issue #1032) ────────────────────────────────
2344
2345    #[cfg(feature = "db")]
2346    mod unique_violation_field_tests {
2347        use super::*;
2348        use crate::error::unique_violation_field;
2349
2350        #[derive(Debug)]
2351        struct FakeDbErrorInfo {
2352            constraint: Option<&'static str>,
2353        }
2354
2355        impl diesel::result::DatabaseErrorInformation for FakeDbErrorInfo {
2356            fn message(&self) -> &'static str {
2357                "duplicate key value violates unique constraint"
2358            }
2359            fn details(&self) -> Option<&str> {
2360                None
2361            }
2362            fn hint(&self) -> Option<&str> {
2363                None
2364            }
2365            fn table_name(&self) -> Option<&str> {
2366                None
2367            }
2368            fn column_name(&self) -> Option<&str> {
2369                None
2370            }
2371            fn constraint_name(&self) -> Option<&str> {
2372                self.constraint
2373            }
2374            fn statement_position(&self) -> Option<i32> {
2375                None
2376            }
2377        }
2378
2379        fn unique_violation(constraint: Option<&'static str>) -> diesel::result::Error {
2380            diesel::result::Error::DatabaseError(
2381                diesel::result::DatabaseErrorKind::UniqueViolation,
2382                Box::new(FakeDbErrorInfo { constraint }),
2383            )
2384        }
2385
2386        const MAPPING: &[(&str, &str, &str)] =
2387            &[("idx_users_email_unique", "email", "has already been taken")];
2388
2389        #[test]
2390        fn matches_constraint_name_to_field_and_message() {
2391            let err: AutumnError = AutumnError::internal_server_error(unique_violation(Some(
2392                "idx_users_email_unique",
2393            )));
2394            assert_eq!(
2395                unique_violation_field(&err, MAPPING),
2396                Some(("email", "has already been taken"))
2397            );
2398        }
2399
2400        #[test]
2401        fn returns_none_for_unrecognized_constraint() {
2402            let err: AutumnError =
2403                AutumnError::internal_server_error(unique_violation(Some("some_other_constraint")));
2404            assert_eq!(unique_violation_field(&err, MAPPING), None);
2405        }
2406
2407        #[test]
2408        fn returns_none_when_constraint_name_is_absent() {
2409            let err: AutumnError = AutumnError::internal_server_error(unique_violation(None));
2410            assert_eq!(unique_violation_field(&err, MAPPING), None);
2411        }
2412
2413        // ── SQLite fallback (issue #2698) ────────────────────────────────────
2414        //
2415        // Diesel boxes a SQLite `DatabaseErrorInformation` as a bare `String`
2416        // whose `constraint_name()` is always `None`, so these fake it the
2417        // same way — `FakeDbErrorInfo` above always names a constraint, which
2418        // is exactly what real SQLite errors never do. A real SQLite engine
2419        // exercising this same fallback lives in
2420        // `autumn/tests/sqlite_unique_violation_field.rs`, since a diesel
2421        // `String` box can't be constructed outside diesel's own sqlite
2422        // backend code.
2423
2424        #[derive(Debug)]
2425        struct FakeSqliteDbErrorInfo {
2426            message: &'static str,
2427        }
2428
2429        impl diesel::result::DatabaseErrorInformation for FakeSqliteDbErrorInfo {
2430            fn message(&self) -> &str {
2431                self.message
2432            }
2433            fn details(&self) -> Option<&str> {
2434                None
2435            }
2436            fn hint(&self) -> Option<&str> {
2437                None
2438            }
2439            fn table_name(&self) -> Option<&str> {
2440                None
2441            }
2442            fn column_name(&self) -> Option<&str> {
2443                None
2444            }
2445            fn constraint_name(&self) -> Option<&str> {
2446                None
2447            }
2448            fn statement_position(&self) -> Option<i32> {
2449                None
2450            }
2451        }
2452
2453        fn sqlite_unique_violation(message: &'static str) -> diesel::result::Error {
2454            diesel::result::Error::DatabaseError(
2455                diesel::result::DatabaseErrorKind::UniqueViolation,
2456                Box::new(FakeSqliteDbErrorInfo { message }),
2457            )
2458        }
2459
2460        #[test]
2461        fn matches_sqlite_message_column_to_field_and_message() {
2462            let err: AutumnError = AutumnError::internal_server_error(sqlite_unique_violation(
2463                "UNIQUE constraint failed: users.email",
2464            ));
2465            assert_eq!(
2466                unique_violation_field(&err, MAPPING),
2467                Some(("email", "has already been taken"))
2468            );
2469        }
2470
2471        #[test]
2472        fn matches_sqlite_message_composite_index_by_any_column() {
2473            const COMPOSITE: &[(&str, &str, &str)] =
2474                &[("idx_invitations_pending_email", "email", "already pending")];
2475            let err: AutumnError = AutumnError::internal_server_error(sqlite_unique_violation(
2476                "UNIQUE constraint failed: invitations.tenant_id, invitations.email",
2477            ));
2478            assert_eq!(
2479                unique_violation_field(&err, COMPOSITE),
2480                Some(("email", "already pending"))
2481            );
2482        }
2483
2484        #[test]
2485        fn returns_none_for_sqlite_message_naming_an_unmapped_column() {
2486            let err: AutumnError = AutumnError::internal_server_error(sqlite_unique_violation(
2487                "UNIQUE constraint failed: users.username",
2488            ));
2489            assert_eq!(unique_violation_field(&err, MAPPING), None);
2490        }
2491
2492        #[test]
2493        fn returns_none_for_unrecognized_message_shape() {
2494            let err: AutumnError = AutumnError::internal_server_error(sqlite_unique_violation(
2495                "some other database error text",
2496            ));
2497            assert_eq!(unique_violation_field(&err, MAPPING), None);
2498        }
2499
2500        #[test]
2501        fn returns_none_for_non_unique_violation_db_errors() {
2502            let err: AutumnError =
2503                AutumnError::internal_server_error(diesel::result::Error::NotFound);
2504            assert_eq!(unique_violation_field(&err, MAPPING), None);
2505        }
2506
2507        #[test]
2508        fn returns_none_for_non_db_errors() {
2509            let err = AutumnError::internal_server_error_msg("plain string error");
2510            assert_eq!(unique_violation_field(&err, MAPPING), None);
2511        }
2512
2513        #[test]
2514        fn recovers_diesel_error_through_repository_style_blanket_from() {
2515            // Mirrors a generated repository method's `.execute(...).await?`
2516            // -- the diesel error is converted to `AutumnError` via the
2517            // blanket `From` impl before `unique_violation_field` ever sees
2518            // it, same as `repo.save(...).await?`'s already-mapped error.
2519            fn insert() -> Result<(), diesel::result::Error> {
2520                Err(unique_violation(Some("idx_users_email_unique")))
2521            }
2522            fn handler() -> Result<(), AutumnError> {
2523                insert()?;
2524                Ok(())
2525            }
2526            let err = handler().unwrap_err();
2527            assert_eq!(
2528                unique_violation_field(&err, MAPPING),
2529                Some(("email", "has already been taken"))
2530            );
2531        }
2532    }
2533
2534    // ── #2587: read accessors for validation details and problem code ──
2535
2536    fn details_map(pairs: &[(&str, &[&str])]) -> std::collections::HashMap<String, Vec<String>> {
2537        pairs
2538            .iter()
2539            .map(|(field, messages)| {
2540                (
2541                    (*field).to_owned(),
2542                    messages.iter().map(|m| (*m).to_owned()).collect(),
2543                )
2544            })
2545            .collect()
2546    }
2547
2548    #[test]
2549    fn details_reads_back_the_validation_map() {
2550        let err = AutumnError::validation(details_map(&[("title", &["must be 1-120 characters"])]));
2551        let details = err.details().expect("validation error carries details");
2552        assert_eq!(
2553            details.get("title").map(Vec::as_slice),
2554            Some(["must be 1-120 characters".to_owned()].as_slice())
2555        );
2556    }
2557
2558    #[test]
2559    fn details_is_none_for_non_validation_errors() {
2560        assert!(
2561            AutumnError::not_found_msg("no such user")
2562                .details()
2563                .is_none()
2564        );
2565        assert!(
2566            AutumnError::conflict_msg("stale version")
2567                .details()
2568                .is_none()
2569        );
2570    }
2571
2572    #[test]
2573    fn code_names_each_constructor() {
2574        let cases: Vec<(AutumnError, &str)> = vec![
2575            (
2576                AutumnError::validation(details_map(&[("title", &["too short"])])),
2577                "autumn.validation_failed",
2578            ),
2579            (AutumnError::not_found_msg("gone"), "autumn.not_found"),
2580            (AutumnError::bad_request_msg("bad"), "autumn.bad_request"),
2581            (AutumnError::conflict_msg("stale"), "autumn.conflict"),
2582            (AutumnError::gone_msg("sunset"), "autumn.gone"),
2583            (
2584                AutumnError::query_timeout("statement_timeout"),
2585                "autumn.query_timeout",
2586            ),
2587            (
2588                AutumnError::internal_server_error_msg("boom"),
2589                "autumn.internal_server_error",
2590            ),
2591        ];
2592
2593        for (err, expected) in cases {
2594            assert_eq!(err.code(), expected, "code() for {err:?}");
2595        }
2596    }
2597
2598    #[test]
2599    fn code_follows_the_statement_timeout_reclassification() {
2600        // `into_response` demotes a 500 whose message names a cancelled
2601        // statement to a 503 `autumn.query_timeout`; `code()` says the same.
2602        let err =
2603            AutumnError::internal_server_error_msg("canceling statement due to statement timeout");
2604        assert_eq!(err.code(), "autumn.query_timeout");
2605    }
2606
2607    #[test]
2608    fn display_lists_the_failing_fields() {
2609        let err = AutumnError::validation(details_map(&[
2610            ("title", &["must be 1-120 characters"]),
2611            ("email", &["invalid"]),
2612        ]));
2613        assert_eq!(
2614            err.to_string(),
2615            "Validation failed: email: invalid; title: must be 1-120 characters"
2616        );
2617    }
2618
2619    #[test]
2620    fn display_field_order_does_not_follow_the_map() {
2621        // A two-field map lands sorted by chance about four runs in ten, so a
2622        // dropped sort survives that test. Five fields, rebuilt each round,
2623        // fail every run instead.
2624        let fields: &[(&str, &[&str])] = &[
2625            ("title", &["e"]),
2626            ("email", &["e"]),
2627            ("author", &["e"]),
2628            ("body", &["e"]),
2629            ("slug", &["e"]),
2630        ];
2631        let expected = "Validation failed: author: e; body: e; email: e; slug: e; title: e";
2632
2633        for _ in 0..64 {
2634            assert_eq!(
2635                AutumnError::validation(details_map(fields)).to_string(),
2636                expected
2637            );
2638        }
2639    }
2640
2641    #[test]
2642    fn display_sorts_non_ascii_fields_deterministically() {
2643        let fields: &[(&str, &[&str])] = &[
2644            ("überschrift", &["zu kurz"]),
2645            ("邮箱", &["无效"]),
2646            ("email", &["invalide"]),
2647        ];
2648        let first = AutumnError::validation(details_map(fields)).to_string();
2649        for _ in 0..32 {
2650            assert_eq!(
2651                AutumnError::validation(details_map(fields)).to_string(),
2652                first
2653            );
2654        }
2655        assert!(first.contains("邮箱: 无效"), "{first}");
2656    }
2657
2658    #[test]
2659    fn message_is_not_redacted_for_a_server_error() {
2660        // The doc caveat, pinned: outside a dev profile the response replaces
2661        // a 5xx `detail`, but `message` still returns the wrapped error.
2662        let err = AutumnError::internal_server_error_msg("password=hunter2 in dsn");
2663        assert_eq!(err.message(), "password=hunter2 in dsn");
2664
2665        let redacted = problem_details(err.status(), err.message(), None, None, None, None, false);
2666        assert_eq!(redacted.detail, "Internal server error");
2667    }
2668
2669    #[test]
2670    fn message_is_the_wrapped_error_without_the_fields() {
2671        let err = AutumnError::validation(details_map(&[("email", &["invalid"])]));
2672        assert_eq!(err.message(), "Validation failed");
2673        assert_eq!(err.to_string(), "Validation failed: email: invalid");
2674        assert_eq!(AutumnError::not_found_msg("gone").message(), "gone");
2675    }
2676
2677    #[test]
2678    fn display_joins_multiple_messages_for_one_field() {
2679        let err = AutumnError::validation(details_map(&[(
2680            "password",
2681            &["too short", "needs a digit"],
2682        )]));
2683        assert_eq!(
2684            err.to_string(),
2685            "Validation failed: password: too short, needs a digit"
2686        );
2687    }
2688
2689    #[test]
2690    fn display_falls_back_to_the_title_without_field_messages() {
2691        assert_eq!(
2692            AutumnError::validation(details_map(&[])).to_string(),
2693            "Validation failed"
2694        );
2695        assert_eq!(
2696            AutumnError::validation(details_map(&[("title", &[])])).to_string(),
2697            "Validation failed"
2698        );
2699    }
2700
2701    #[tokio::test]
2702    async fn json_errors_skip_a_field_with_no_messages_like_display_does() -> Result<(), axum::Error>
2703    {
2704        // `display_falls_back_to_the_title_without_field_messages` pins this
2705        // for `Display`; the `errors` array in the JSON body must agree
2706        // rather than emitting a `{"field":"title","messages":[]}` entry that
2707        // names a field as failing without ever saying why (issue #2587
2708        // follow-up).
2709        let err = AutumnError::validation(details_map(&[("title", &[])]));
2710        let response = err.into_response();
2711        let body = axum::body::to_bytes(response.into_body(), usize::MAX).await?;
2712        let json: serde_json::Value = serde_json::from_slice(&body).expect("valid json");
2713        assert_eq!(json["errors"], serde_json::json!([]));
2714        Ok(())
2715    }
2716
2717    #[test]
2718    fn display_is_unchanged_for_non_validation_errors() {
2719        assert_eq!(
2720            AutumnError::not_found_msg("no such user").to_string(),
2721            "no such user"
2722        );
2723    }
2724
2725    #[tokio::test]
2726    async fn code_equals_the_code_in_the_rendered_body() -> Result<(), axum::Error> {
2727        let builders: Vec<fn() -> AutumnError> = vec![
2728            || AutumnError::validation(details_map(&[("title", &["too short"])])),
2729            || AutumnError::not_found_msg("gone"),
2730            || AutumnError::conflict_msg("stale"),
2731            || AutumnError::gone_msg("sunset"),
2732            || AutumnError::query_timeout("statement_timeout"),
2733            || AutumnError::internal_server_error_msg("boom"),
2734            || AutumnError::internal_server_error_msg("ERROR: 57014 query canceled"),
2735        ];
2736
2737        for build in builders {
2738            let expected = build().code();
2739            let body =
2740                axum::body::to_bytes(build().into_response().into_body(), usize::MAX).await?;
2741            let json: serde_json::Value = serde_json::from_slice(&body).expect("valid json");
2742            assert_eq!(json["code"], &*expected);
2743        }
2744        Ok(())
2745    }
2746
2747    #[test]
2748    fn the_assigned_status_is_not_a_redaction_guard() {
2749        // `status()` is the assigned status. The renderer reclassifies a
2750        // cancelled statement to a redacted 503, so a caller that gates on
2751        // `status()` being a 4xx would publish a message the response hides.
2752        // `message()` documents this rather than recommending that gate.
2753        let err = AutumnError::bad_request_msg(
2754            "db: canceling statement due to statement timeout (dsn password=hunter2)",
2755        );
2756        assert_eq!(err.status(), StatusCode::BAD_REQUEST);
2757
2758        let (status, problem_type) = err.rendered_problem();
2759        assert_eq!(status, StatusCode::SERVICE_UNAVAILABLE);
2760
2761        let rendered =
2762            problem_details(status, err.message(), None, problem_type, None, None, false);
2763        assert_eq!(rendered.detail, "Service unavailable");
2764        assert!(err.message().contains("password=hunter2"));
2765    }
2766}