Skip to main content

toolkit_canonical_errors/
problem.rs

1use serde::{Deserialize, Serialize};
2
3// `GTS_ID_PREFIX` is the compile-time configured GTS identifier prefix
4// (overridable via the `GTS_ID_PREFIX` env var at build
5// time). Used to assemble the canonical error type id prefix without
6// hard-coding the literal prefix.
7use toolkit_gts::{GTS_ID_PREFIX, GTS_ID_URI_PREFIX, gts_id, gts_uri};
8
9use crate::context::{
10    Aborted, AlreadyExists, Cancelled, DataLoss, DeadlineExceeded, FailedPrecondition, Internal,
11    InvalidArgument, NotFound, OutOfRange, PermissionDenied, ResourceExhausted, ServiceUnavailable,
12    Unauthenticated, Unimplemented, Unknown,
13};
14use crate::error::CanonicalError;
15use crate::transport::TransportOverrides;
16
17/// Media type for RFC 9457 `application/problem+json` responses.
18pub const APPLICATION_PROBLEM_JSON: &str = "application/problem+json";
19
20// ---------------------------------------------------------------------------
21// ProblemCategory — canonical-category selector for typed contract errors.
22// ---------------------------------------------------------------------------
23
24/// One of the 16 canonical AIP-193 categories. Mirrors [`CanonicalError`]
25/// variants for the purpose of building a [`Problem`] envelope from a
26/// typed contract error (PRD #1536 `#[derive(ContractError)]`) without
27/// requiring the SDK author to construct a full `CanonicalError`
28/// (which requires per-category context payloads).
29///
30/// HTTP status and GTS URI are determined entirely by the category; the
31/// contract error supplies `error_code` / `error_domain` extensions plus a
32/// JSON payload in `context["data"]`.
33#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
34#[non_exhaustive]
35pub enum ProblemCategory {
36    Cancelled,
37    Unknown,
38    InvalidArgument,
39    DeadlineExceeded,
40    NotFound,
41    AlreadyExists,
42    PermissionDenied,
43    ResourceExhausted,
44    FailedPrecondition,
45    Aborted,
46    OutOfRange,
47    Unimplemented,
48    Internal,
49    ServiceUnavailable,
50    DataLoss,
51    Unauthenticated,
52}
53
54impl ProblemCategory {
55    /// Every [`ProblemCategory`] variant, in declaration order.
56    ///
57    /// Lets exhaustive checks — a category → gRPC/HTTP code table, a round-trip
58    /// test — be driven from the source of truth rather than a hand-maintained
59    /// list, so a variant added below can't be silently omitted. Kept complete
60    /// by the exhaustiveness guard just after this `impl`.
61    pub const ALL: &'static [Self] = &[
62        Self::Cancelled,
63        Self::Unknown,
64        Self::InvalidArgument,
65        Self::DeadlineExceeded,
66        Self::NotFound,
67        Self::AlreadyExists,
68        Self::PermissionDenied,
69        Self::ResourceExhausted,
70        Self::FailedPrecondition,
71        Self::Aborted,
72        Self::OutOfRange,
73        Self::Unimplemented,
74        Self::Internal,
75        Self::ServiceUnavailable,
76        Self::DataLoss,
77        Self::Unauthenticated,
78    ];
79
80    /// GTS URI fragment (without the `gts://` scheme). Identical to the
81    /// fragment emitted by [`CanonicalError::gts_type`] for the matching
82    /// variant.
83    #[must_use]
84    pub fn gts_fragment(self) -> &'static str {
85        match self {
86            Self::Cancelled => gts_id!("cf.core.errors.err.v1~cf.core.err.cancelled.v1~"),
87            Self::Unknown => gts_id!("cf.core.errors.err.v1~cf.core.err.unknown.v1~"),
88            Self::InvalidArgument => {
89                gts_id!("cf.core.errors.err.v1~cf.core.err.invalid_argument.v1~")
90            }
91            Self::DeadlineExceeded => {
92                gts_id!("cf.core.errors.err.v1~cf.core.err.deadline_exceeded.v1~")
93            }
94            Self::NotFound => gts_id!("cf.core.errors.err.v1~cf.core.err.not_found.v1~"),
95            Self::AlreadyExists => {
96                gts_id!("cf.core.errors.err.v1~cf.core.err.already_exists.v1~")
97            }
98            Self::PermissionDenied => {
99                gts_id!("cf.core.errors.err.v1~cf.core.err.permission_denied.v1~")
100            }
101            Self::ResourceExhausted => {
102                gts_id!("cf.core.errors.err.v1~cf.core.err.resource_exhausted.v1~")
103            }
104            Self::FailedPrecondition => {
105                gts_id!("cf.core.errors.err.v1~cf.core.err.failed_precondition.v1~")
106            }
107            Self::Aborted => gts_id!("cf.core.errors.err.v1~cf.core.err.aborted.v1~"),
108            Self::OutOfRange => gts_id!("cf.core.errors.err.v1~cf.core.err.out_of_range.v1~"),
109            Self::Unimplemented => {
110                gts_id!("cf.core.errors.err.v1~cf.core.err.unimplemented.v1~")
111            }
112            Self::Internal => gts_id!("cf.core.errors.err.v1~cf.core.err.internal.v1~"),
113            Self::ServiceUnavailable => {
114                gts_id!("cf.core.errors.err.v1~cf.core.err.service_unavailable.v1~")
115            }
116            Self::DataLoss => gts_id!("cf.core.errors.err.v1~cf.core.err.data_loss.v1~"),
117            Self::Unauthenticated => {
118                gts_id!("cf.core.errors.err.v1~cf.core.err.unauthenticated.v1~")
119            }
120        }
121    }
122
123    /// HTTP status mapping per AIP-193 and gRPC↔HTTP conventions.
124    #[must_use]
125    #[allow(
126        clippy::match_same_arms,
127        reason = "each canonical category is mapped explicitly per AIP-193; collapsing arms whose codes happen to coincide today would silently hide a future schema mismatch."
128    )]
129    pub fn http_status(self) -> u16 {
130        match self {
131            Self::Cancelled => 499,
132            Self::Unknown => 500,
133            Self::InvalidArgument => 400,
134            Self::DeadlineExceeded => 504,
135            Self::NotFound => 404,
136            Self::AlreadyExists => 409,
137            Self::PermissionDenied => 403,
138            Self::ResourceExhausted => 429,
139            Self::FailedPrecondition => 400,
140            Self::Aborted => 409,
141            Self::OutOfRange => 400,
142            Self::Unimplemented => 501,
143            Self::Internal => 500,
144            Self::ServiceUnavailable => 503,
145            Self::DataLoss => 500,
146            Self::Unauthenticated => 401,
147        }
148    }
149
150    /// Human-readable title for the RFC 9457 envelope. Same string as
151    /// [`CanonicalError::title`] for the matching variant.
152    #[must_use]
153    pub fn title(self) -> &'static str {
154        match self {
155            Self::Cancelled => "Cancelled",
156            Self::Unknown => "Unknown",
157            Self::InvalidArgument => "Invalid argument",
158            Self::DeadlineExceeded => "Deadline exceeded",
159            Self::NotFound => "Not found",
160            Self::AlreadyExists => "Already exists",
161            Self::PermissionDenied => "Permission denied",
162            Self::ResourceExhausted => "Resource exhausted",
163            Self::FailedPrecondition => "Failed precondition",
164            Self::Aborted => "Aborted",
165            Self::OutOfRange => "Out of range",
166            Self::Unimplemented => "Unimplemented",
167            Self::Internal => "Internal",
168            Self::ServiceUnavailable => "Service unavailable",
169            Self::DataLoss => "Data loss",
170            Self::Unauthenticated => "Unauthenticated",
171        }
172    }
173}
174
175// Exhaustiveness guard for [`ProblemCategory::ALL`]. `#[non_exhaustive]` only
176// forces a wildcard on *downstream* crates; within this crate this match must
177// cover every variant, so adding one fails to compile here — the reminder to
178// append it to `ALL` above (and to `ALL.len()` below).
179const _: () = {
180    fn _assert_all_variants_listed(category: ProblemCategory) {
181        match category {
182            ProblemCategory::Cancelled
183            | ProblemCategory::Unknown
184            | ProblemCategory::InvalidArgument
185            | ProblemCategory::DeadlineExceeded
186            | ProblemCategory::NotFound
187            | ProblemCategory::AlreadyExists
188            | ProblemCategory::PermissionDenied
189            | ProblemCategory::ResourceExhausted
190            | ProblemCategory::FailedPrecondition
191            | ProblemCategory::Aborted
192            | ProblemCategory::OutOfRange
193            | ProblemCategory::Unimplemented
194            | ProblemCategory::Internal
195            | ProblemCategory::ServiceUnavailable
196            | ProblemCategory::DataLoss
197            | ProblemCategory::Unauthenticated => {}
198        }
199    }
200    // A variant added to the match but not to `ALL` (or vice versa) trips this.
201    assert!(ProblemCategory::ALL.len() == 16);
202};
203
204// ---------------------------------------------------------------------------
205// Problem (RFC 9457)
206// ---------------------------------------------------------------------------
207
208/// RFC 9457 §3.1.1's own designated default for `type` when a Problem
209/// carries no more specific type than its HTTP status code.
210fn default_problem_type() -> String {
211    "about:blank".to_owned()
212}
213
214/// Deserialize default for `context` when a foreign peer's Problem omits it.
215/// `serde_json::Value`'s own `Default` is `Value::Null`, which is wrong here:
216/// `TryFrom<Problem>` dispatches a category's context type by deserializing
217/// this value, and context-free categories (`NotFoundV1 {}` and similar) fail
218/// to deserialize from `null` ("invalid type: null, expected struct") but
219/// succeed from an empty object - `{}` is the value that actually round-trips.
220fn default_context() -> serde_json::Value {
221    serde_json::Value::Object(serde_json::Map::new())
222}
223
224/// RFC 9457 §3.1 makes every member but this struct's Rust type reflects
225/// optional on the wire; `status` is `Option<u16>` rather than a defaulted
226/// `u16` since a synthetic `0` would be an actively wrong status, not an
227/// honest "wasn't there" (same reasoning as `default_context`, below).
228/// Callers that have a real status to fall back to normalize `None` after
229/// parsing (`enrich_problem_response`, `map_http_error`); SSE has none and
230/// preserves the absence. `Serialize` is unaffected - `from_error` always
231/// populates every field, so wire output is unchanged.
232#[derive(Debug, Clone, Serialize, Deserialize)]
233pub struct Problem {
234    #[serde(rename = "type", default = "default_problem_type")]
235    pub problem_type: String,
236    #[serde(default)]
237    pub title: String,
238    #[serde(default, skip_serializing_if = "Option::is_none")]
239    pub status: Option<u16>,
240    #[serde(default)]
241    pub detail: String,
242    #[serde(skip_serializing_if = "Option::is_none")]
243    pub instance: Option<String>,
244    #[serde(skip_serializing_if = "Option::is_none")]
245    pub trace_id: Option<String>,
246    #[serde(default = "default_context")]
247    pub context: serde_json::Value,
248
249    /// Machine-readable identifier of the typed error variant inside its
250    /// domain. Set by [`#[derive(ContractError)]`] when a contract error
251    /// crosses the wire so PRD-conformant peers can reconstruct the
252    /// original Rust enum variant via `error_code` + `error_domain`.
253    /// `None` for canonical-category-only errors.
254    #[serde(skip_serializing_if = "Option::is_none")]
255    pub error_code: Option<String>,
256
257    /// Namespace owning the `error_code`. Conventionally
258    /// `<service>.<version>` (e.g. `billing.v1`). `None` when no contract
259    /// error is in play.
260    #[serde(skip_serializing_if = "Option::is_none")]
261    pub error_domain: Option<String>,
262}
263
264impl Problem {
265    /// Convert a `CanonicalError` to a `Problem`.
266    ///
267    /// # Errors
268    ///
269    /// Returns `serde_json::Error` if the error-category context type
270    /// fails to serialize.  Built-in context types are plain structs and
271    /// should never fail, but this keeps the failure visible rather than
272    /// silently producing an empty `"context": {}`.
273    pub fn from_error(err: &CanonicalError) -> Result<Self, serde_json::Error> {
274        let problem_type = gts_uri!(err.gts_type());
275        let title = err.title().to_owned();
276        let status = err.status_code();
277        let detail = err.detail().to_owned();
278
279        let mut context = serialize_context(err)?;
280
281        if let Some(rt) = err.resource_type() {
282            context["resource_type"] = serde_json::Value::String(rt.to_owned());
283        }
284
285        if let Some(rn) = err.resource_name() {
286            context["resource_name"] = serde_json::Value::String(rn.to_owned());
287        }
288
289        Ok(Problem {
290            problem_type,
291            title,
292            status: Some(status),
293            detail,
294            instance: None,
295            trace_id: None,
296            context,
297            error_code: None,
298            error_domain: None,
299        })
300    }
301
302    /// Attach the `error_code` extension field (PRD #1536 contract-error
303    /// envelope). Returns `self` for chaining.
304    #[must_use]
305    pub fn with_error_code(mut self, code: impl Into<String>) -> Self {
306        self.error_code = Some(code.into());
307        self
308    }
309
310    /// Attach the `error_domain` extension field. Returns `self` for chaining.
311    #[must_use]
312    pub fn with_error_domain(mut self, domain: impl Into<String>) -> Self {
313        self.error_domain = Some(domain.into());
314        self
315    }
316
317    /// Build a [`Problem`] for a typed contract error (PRD #1536 envelope).
318    ///
319    /// `category` selects one of the 16 canonical AIP-193 categories; the
320    /// resulting `Problem` carries the matching GTS URI in `type`, the
321    /// canonical HTTP status, and the canonical title. `error_code` and
322    /// `error_domain` populate the PRD extension fields, and `data` is
323    /// placed at `context["data"]` to carry variant-specific payload.
324    ///
325    /// Used by `#[derive(ContractError)]` emit-paths; SDK authors rarely
326    /// call this directly.
327    pub fn contract_error(
328        category: ProblemCategory,
329        error_code: impl Into<String>,
330        error_domain: impl Into<String>,
331        detail: impl Into<String>,
332        data: serde_json::Value,
333    ) -> Self {
334        let mut context = serde_json::Map::new();
335        context.insert("data".to_owned(), data);
336        Problem {
337            problem_type: format!("gts://{}", category.gts_fragment()),
338            title: category.title().to_owned(),
339            status: Some(category.http_status()),
340            detail: detail.into(),
341            instance: None,
342            trace_id: None,
343            context: serde_json::Value::Object(context),
344            error_code: Some(error_code.into()),
345            error_domain: Some(error_domain.into()),
346        }
347    }
348
349    /// Convert a `CanonicalError` to a `Problem`, including the internal
350    /// diagnostic string in the `context` for `Internal` and `Unknown`
351    /// variants.
352    ///
353    /// **This method MUST NOT be used in production.** It exists so that
354    /// development and test environments can surface the real error cause
355    /// in the wire response for easier debugging.
356    ///
357    /// In production, use [`from_error`](Self::from_error) instead — it
358    /// never leaks the diagnostic string.
359    ///
360    /// Available only when the `debug-problem` feature is enabled — intended for
361    /// local development. Enabling this in production leaks diagnostic detail
362    /// onto the wire.
363    ///
364    /// # Errors
365    ///
366    /// Returns `serde_json::Error` if the context fails to serialize.
367    #[cfg(feature = "debug-problem")]
368    pub fn from_error_debug(err: &CanonicalError) -> Result<Self, serde_json::Error> {
369        let mut problem = Self::from_error(err)?;
370
371        if let Some(diag) = err.diagnostic() {
372            problem.context["description"] = serde_json::Value::String(diag.to_owned());
373        }
374
375        Ok(problem)
376    }
377
378    /// Set the `trace_id` field, returning `self` for chaining.
379    #[must_use]
380    pub fn with_trace_id(mut self, trace_id: impl Into<String>) -> Self {
381        self.trace_id = Some(trace_id.into());
382        self
383    }
384
385    /// Set the `instance` field, returning `self` for chaining.
386    #[must_use]
387    pub fn with_instance(mut self, instance: impl Into<String>) -> Self {
388        self.instance = Some(instance.into());
389        self
390    }
391}
392
393fn serialize_context(err: &CanonicalError) -> Result<serde_json::Value, serde_json::Error> {
394    match err {
395        CanonicalError::Cancelled { ctx, .. } => serde_json::to_value(ctx),
396        CanonicalError::Unknown { ctx, .. } => serde_json::to_value(ctx),
397        CanonicalError::InvalidArgument { ctx, .. } => serde_json::to_value(ctx),
398        CanonicalError::DeadlineExceeded { ctx, .. } => serde_json::to_value(ctx),
399        CanonicalError::NotFound { ctx, .. } => serde_json::to_value(ctx),
400        CanonicalError::AlreadyExists { ctx, .. } => serde_json::to_value(ctx),
401        CanonicalError::PermissionDenied { ctx, .. } => serde_json::to_value(ctx),
402        CanonicalError::ResourceExhausted { ctx, .. } => serde_json::to_value(ctx),
403        CanonicalError::FailedPrecondition { ctx, .. } => serde_json::to_value(ctx),
404        CanonicalError::Aborted { ctx, .. } => serde_json::to_value(ctx),
405        CanonicalError::OutOfRange { ctx, .. } => serde_json::to_value(ctx),
406        CanonicalError::Unimplemented { ctx, .. } => serde_json::to_value(ctx),
407        CanonicalError::Internal { ctx, .. } => serde_json::to_value(ctx),
408        CanonicalError::ServiceUnavailable { ctx, .. } => serde_json::to_value(ctx),
409        CanonicalError::DataLoss { ctx, .. } => serde_json::to_value(ctx),
410        CanonicalError::Unauthenticated { ctx, .. } => serde_json::to_value(ctx),
411    }
412}
413
414// `Problem.context` must be a JSON object per the OpenAPI schema, so we wrap
415// the serialization error in `{ "serialization_error": ... }`. The original
416// CanonicalError is already preserved in the other Problem fields.
417#[allow(unknown_lints, de1302_error_from_to_string)]
418impl From<CanonicalError> for Problem {
419    fn from(err: CanonicalError) -> Self {
420        match Problem::from_error(&err) {
421            Ok(p) => p,
422            Err(ser_err) => Problem {
423                problem_type: gts_uri!(err.gts_type()),
424                title: err.title().to_owned(),
425                status: Some(err.status_code()),
426                detail: err.detail().to_owned(),
427                instance: None,
428                trace_id: None,
429                context: serde_json::json!({ "serialization_error": ser_err.to_string() }),
430                error_code: None,
431                error_domain: None,
432            },
433        }
434    }
435}
436
437// ---------------------------------------------------------------------------
438// Round-trip: Problem → CanonicalError
439//
440// Reverse direction of `From<CanonicalError> for Problem`. Out-of-process SDK
441// consumers receive `application/problem+json` over the wire, deserialize into
442// `Problem`, and reconstruct the typed `CanonicalError` via this `TryFrom`.
443// In-process consumers do not need this hop — they hold `CanonicalError`
444// directly from the ClientHub call.
445//
446// Lossy by design:
447// * `Internal.description` and `Unknown.description` are `#[serde(skip)]` on
448//   the wire, so they reconstruct as empty strings. This is intentional —
449//   production wire responses never carry the server-side diagnostic.
450// * Transport fields (`instance`, `trace_id`) live on `Problem`, not on
451//   `CanonicalError`. Callers that need them should read them off the
452//   `Problem` before converting.
453// ---------------------------------------------------------------------------
454
455/// Prefix on `Problem.problem_type` produced by the forward conversion.
456const PROBLEM_TYPE_PREFIX: &str = GTS_ID_URI_PREFIX;
457
458/// Reasons a `Problem` cannot be reconstructed as a `CanonicalError`.
459#[derive(Debug, thiserror::Error)]
460pub enum ProblemConversionError {
461    /// The `problem_type` URI does not match any of the 16 canonical
462    /// category identifiers. Either the server emitted a non-canonical
463    /// problem or the wire format has drifted.
464    #[error("unrecognized problem_type: {0}")]
465    UnknownProblemType(String),
466
467    /// The `context` payload could not be deserialized into the context
468    /// type for the matched category. The category and underlying serde
469    /// error are surfaced for diagnostics.
470    #[error("invalid context for canonical category {category}: {source}")]
471    InvalidContext {
472        category: &'static str,
473        #[source]
474        source: serde_json::Error,
475    },
476
477    /// The wire `status` is not a valid HTTP status code (RFC 9110: a
478    /// three-digit integer in the 100-599 range). Rejected here rather than
479    /// silently stored as an override, since `TryFrom<Problem>` is the
480    /// crate's one boundary for untrusted/out-of-process input.
481    #[error("invalid HTTP status in Problem: {0}")]
482    InvalidStatus(u16),
483
484    /// The wire `status` is syntactically valid but is in a different HTTP
485    /// status-code class than the matched category's default, e.g. an
486    /// `invalid_argument` (4xx) `Problem` carrying `status: 500`. Flipping
487    /// between client-fault and server-fault semantics changes client retry
488    /// behavior, so this is rejected rather than accepted as an override.
489    #[error("status {status} is a different HTTP status class than category {category}'s default")]
490    CategoryStatusMismatch { category: &'static str, status: u16 },
491}
492
493/// Prefix of every canonical GTS identifier. Stripped to expose the category
494/// name (e.g. `cancelled`, `invalid_argument`). Not a complete GTS string by
495/// itself — only the concatenation `{prefix}{category}{suffix}` is a valid
496/// GTS identifier.
497#[allow(unknown_lints, de0901_gts_string_pattern)]
498/// Prefix of every canonical GTS error type id. Built by concatenating the
499/// configured GTS ID prefix (overridable via `GTS_ID_PREFIX`
500/// at compile time) with the fixed "cf.core.errors.err.v1~cf.core.err."
501/// suffix. This is *not* a complete GTS id (the trailing per-error token is
502/// appended at runtime in `CanonicalError::gts_type`), so `gts_id!` (which
503/// validates a full id at macro-expansion time) is not applicable here.
504/// `concat!` also cannot be used since it only accepts literals; the value
505/// is therefore materialised once via a `OnceLock` and exposed as a
506/// `&'static str`.
507fn gts_type_prefix() -> &'static str {
508    use std::sync::OnceLock;
509    static PREFIX: OnceLock<String> = OnceLock::new();
510    PREFIX.get_or_init(|| format!("{GTS_ID_PREFIX}cf.core.errors.err.v1~cf.core.err."))
511}
512/// Suffix of every canonical GTS identifier. See [`gts_type_prefix`].
513const GTS_TYPE_SUFFIX: &str = ".v1~";
514
515/// Strip the canonical problem-type URI down to
516/// `<category>`. Returns `None` if the URI doesn't match the canonical shape.
517fn category_from_problem_type(problem_type: &str) -> Option<&str> {
518    let rest = problem_type.strip_prefix(PROBLEM_TYPE_PREFIX)?;
519    let after_prefix = rest.strip_prefix(gts_type_prefix())?;
520    after_prefix.strip_suffix(GTS_TYPE_SUFFIX)
521}
522
523/// Extract `resource_type` and `resource_name` from the `Problem.context`
524/// JSON, returning the pair (both `None` if absent). The forward conversion
525/// stamps these as plain string fields alongside the category-specific
526/// payload; here we read them back without disturbing the serde
527/// deserialization of the category context (serde ignores unknown fields).
528fn extract_resource_fields(context: &serde_json::Value) -> (Option<String>, Option<String>) {
529    let resource_type = context
530        .get("resource_type")
531        .and_then(serde_json::Value::as_str)
532        .map(str::to_owned);
533    let resource_name = context
534        .get("resource_name")
535        .and_then(serde_json::Value::as_str)
536        .map(str::to_owned);
537    (resource_type, resource_name)
538}
539
540fn deserialize_ctx<T>(
541    category: &'static str,
542    context: serde_json::Value,
543) -> Result<T, ProblemConversionError>
544where
545    T: serde::de::DeserializeOwned,
546{
547    serde_json::from_value(context)
548        .map_err(|source| ProblemConversionError::InvalidContext { category, source })
549}
550
551impl TryFrom<Problem> for CanonicalError {
552    type Error = ProblemConversionError;
553
554    fn try_from(problem: Problem) -> Result<Self, Self::Error> {
555        let category = category_from_problem_type(&problem.problem_type).ok_or_else(|| {
556            ProblemConversionError::UnknownProblemType(problem.problem_type.clone())
557        })?;
558
559        let detail = problem.detail;
560        let (resource_type, resource_name) = extract_resource_fields(&problem.context);
561        let ctx_value = problem.context;
562
563        let mut canonical = match category {
564            "cancelled" => Self::Cancelled {
565                ctx: deserialize_ctx::<Cancelled>("cancelled", ctx_value)?,
566                detail,
567                resource_type,
568                resource_name,
569                overrides: TransportOverrides::default(),
570            },
571            "unknown" => Self::Unknown {
572                ctx: deserialize_ctx::<Unknown>("unknown", ctx_value)?,
573                detail,
574                resource_type,
575                resource_name,
576                overrides: TransportOverrides::default(),
577            },
578            "invalid_argument" => Self::InvalidArgument {
579                ctx: deserialize_ctx::<InvalidArgument>("invalid_argument", ctx_value)?,
580                detail,
581                resource_type,
582                resource_name,
583                overrides: TransportOverrides::default(),
584            },
585            "deadline_exceeded" => Self::DeadlineExceeded {
586                ctx: deserialize_ctx::<DeadlineExceeded>("deadline_exceeded", ctx_value)?,
587                detail,
588                resource_type,
589                resource_name,
590                overrides: TransportOverrides::default(),
591            },
592            "not_found" => Self::NotFound {
593                ctx: deserialize_ctx::<NotFound>("not_found", ctx_value)?,
594                detail,
595                resource_type,
596                resource_name,
597                overrides: TransportOverrides::default(),
598            },
599            "already_exists" => Self::AlreadyExists {
600                ctx: deserialize_ctx::<AlreadyExists>("already_exists", ctx_value)?,
601                detail,
602                resource_type,
603                resource_name,
604                overrides: TransportOverrides::default(),
605            },
606            "permission_denied" => Self::PermissionDenied {
607                ctx: deserialize_ctx::<PermissionDenied>("permission_denied", ctx_value)?,
608                detail,
609                resource_type,
610                resource_name,
611                overrides: TransportOverrides::default(),
612            },
613            "resource_exhausted" => Self::ResourceExhausted {
614                ctx: deserialize_ctx::<ResourceExhausted>("resource_exhausted", ctx_value)?,
615                detail,
616                resource_type,
617                resource_name,
618                overrides: TransportOverrides::default(),
619            },
620            "failed_precondition" => Self::FailedPrecondition {
621                ctx: deserialize_ctx::<FailedPrecondition>("failed_precondition", ctx_value)?,
622                detail,
623                resource_type,
624                resource_name,
625                overrides: TransportOverrides::default(),
626            },
627            "aborted" => Self::Aborted {
628                ctx: deserialize_ctx::<Aborted>("aborted", ctx_value)?,
629                detail,
630                resource_type,
631                resource_name,
632                overrides: TransportOverrides::default(),
633            },
634            "out_of_range" => Self::OutOfRange {
635                ctx: deserialize_ctx::<OutOfRange>("out_of_range", ctx_value)?,
636                detail,
637                resource_type,
638                resource_name,
639                overrides: TransportOverrides::default(),
640            },
641            "unimplemented" => Self::Unimplemented {
642                ctx: deserialize_ctx::<Unimplemented>("unimplemented", ctx_value)?,
643                detail,
644                resource_type,
645                resource_name,
646                overrides: TransportOverrides::default(),
647            },
648            "internal" => Self::Internal {
649                // `Internal.description` is `#[serde(skip)]`; the wire
650                // never carries it, so it reconstructs as an empty string.
651                ctx: deserialize_ctx::<Internal>("internal", ctx_value)?,
652                detail,
653                overrides: TransportOverrides::default(),
654            },
655            "service_unavailable" => Self::ServiceUnavailable {
656                ctx: deserialize_ctx::<ServiceUnavailable>("service_unavailable", ctx_value)?,
657                detail,
658                resource_type,
659                resource_name,
660                overrides: TransportOverrides::default(),
661            },
662            "data_loss" => Self::DataLoss {
663                ctx: deserialize_ctx::<DataLoss>("data_loss", ctx_value)?,
664                detail,
665                resource_type,
666                resource_name,
667                overrides: TransportOverrides::default(),
668            },
669            "unauthenticated" => Self::Unauthenticated {
670                ctx: deserialize_ctx::<Unauthenticated>("unauthenticated", ctx_value)?,
671                detail,
672                resource_type,
673                resource_name,
674                overrides: TransportOverrides::default(),
675            },
676            _ => {
677                return Err(ProblemConversionError::UnknownProblemType(
678                    problem.problem_type,
679                ));
680            }
681        };
682
683        // A genuinely absent `status` isn't an error - `canonical` just
684        // keeps the category's own default status untouched.
685        if let Some(status) = problem.status {
686            if !(100..=599).contains(&status) {
687                return Err(ProblemConversionError::InvalidStatus(status));
688            }
689
690            if !canonical.is_same_status_class(status) {
691                return Err(ProblemConversionError::CategoryStatusMismatch {
692                    category: canonical.category_name(),
693                    status,
694                });
695            }
696
697            // Recover any transport override purely from the wire `status`
698            // — no additional field on `Problem` is needed since the
699            // category's default status is already known
700            // (`default_http_status`).
701            if status != canonical.default_http_status() {
702                canonical.transport_overrides_mut().http_status = Some(status);
703            }
704        }
705
706        Ok(canonical)
707    }
708}
709
710// ---------------------------------------------------------------------------
711// axum integration (feature = "axum")
712// ---------------------------------------------------------------------------
713
714#[cfg(feature = "axum")]
715fn service_unavailable_retry_after_seconds(problem: &Problem) -> Option<u64> {
716    if category_from_problem_type(&problem.problem_type) != Some("service_unavailable") {
717        return None;
718    }
719
720    ServiceUnavailable::deserialize(&problem.context)
721        .ok()?
722        .retry_after_seconds
723}
724
725#[cfg(feature = "axum")]
726impl axum::response::IntoResponse for Problem {
727    fn into_response(self) -> axum::response::Response {
728        match serde_json::to_vec(&self) {
729            Ok(body) => {
730                let status = self
731                    .status
732                    .and_then(|s| http::StatusCode::from_u16(s).ok())
733                    .unwrap_or(http::StatusCode::INTERNAL_SERVER_ERROR);
734                let retry_after_seconds = service_unavailable_retry_after_seconds(&self);
735                let mut response = (
736                    status,
737                    [(http::header::CONTENT_TYPE, APPLICATION_PROBLEM_JSON)],
738                    body,
739                )
740                    .into_response();
741
742                if let Some(seconds) = retry_after_seconds {
743                    response
744                        .headers_mut()
745                        .insert(http::header::RETRY_AFTER, http::HeaderValue::from(seconds));
746                }
747
748                response
749            }
750            Err(e) => {
751                tracing::error!(
752                    error = %e,
753                    problem_type = %self.problem_type,
754                    status = ?self.status,
755                    "failed to serialize Problem; emitting fallback body",
756                );
757                let body = format!(
758                    r#"{{"type":"{}{}internal{}","title":"Internal","status":500,"detail":"failed to serialize problem","context":{{}}}}"#,
759                    PROBLEM_TYPE_PREFIX,
760                    gts_type_prefix(),
761                    GTS_TYPE_SUFFIX
762                );
763                (
764                    http::StatusCode::INTERNAL_SERVER_ERROR,
765                    [(http::header::CONTENT_TYPE, APPLICATION_PROBLEM_JSON)],
766                    body,
767                )
768                    .into_response()
769            }
770        }
771    }
772}
773
774#[cfg(feature = "axum")]
775impl axum::response::IntoResponse for CanonicalError {
776    fn into_response(self) -> axum::response::Response {
777        // Stash a clone of self into the response extensions so the canonical
778        // error middleware (DESIGN.md §3.6) can recover `diagnostic()` and log
779        // the unredacted description server-side without putting it on the
780        // wire. The `description` fields on `Internal` / `Unknown` are
781        // `#[serde(skip)]`, so the bytes-roundtrip path alone cannot surface
782        // them.
783        let for_extension = self.clone();
784        let mut response = Problem::from(self).into_response();
785        response.extensions_mut().insert(for_extension);
786        response
787    }
788}
789
790// ---------------------------------------------------------------------------
791// utoipa integration (feature = "utoipa")
792// ---------------------------------------------------------------------------
793
794#[cfg(feature = "utoipa")]
795impl utoipa::PartialSchema for Problem {
796    fn schema() -> utoipa::openapi::RefOr<utoipa::openapi::schema::Schema> {
797        use utoipa::openapi::schema::{KnownFormat, ObjectBuilder, SchemaFormat, SchemaType, Type};
798
799        ObjectBuilder::new()
800            .property(
801                "type",
802                ObjectBuilder::new().schema_type(SchemaType::Type(Type::String)),
803            )
804            .required("type")
805            .property(
806                "title",
807                ObjectBuilder::new().schema_type(SchemaType::Type(Type::String)),
808            )
809            .required("title")
810            .property(
811                "status",
812                ObjectBuilder::new()
813                    .schema_type(SchemaType::Type(Type::Integer))
814                    .format(Some(SchemaFormat::KnownFormat(KnownFormat::Int32))),
815            )
816            .required("status")
817            .property(
818                "detail",
819                ObjectBuilder::new().schema_type(SchemaType::Type(Type::String)),
820            )
821            .required("detail")
822            .property(
823                "instance",
824                ObjectBuilder::new().schema_type(SchemaType::Type(Type::String)),
825            )
826            .property(
827                "trace_id",
828                ObjectBuilder::new().schema_type(SchemaType::Type(Type::String)),
829            )
830            .property(
831                "context",
832                ObjectBuilder::new().schema_type(SchemaType::Type(Type::Object)),
833            )
834            .required("context")
835            .description(Some(
836                "RFC 9457 problem+json. `context` varies by error category.",
837            ))
838            .into()
839    }
840}
841
842#[cfg(feature = "utoipa")]
843impl utoipa::ToSchema for Problem {
844    fn name() -> std::borrow::Cow<'static, str> {
845        std::borrow::Cow::Borrowed("Problem")
846    }
847}
848
849#[cfg(test)]
850#[cfg_attr(coverage_nightly, coverage(off))]
851#[path = "problem_tests.rs"]
852mod tests;