Skip to main content

toolkit/api/
canonical_error_layer.rs

1//! Canonical error middleware (DESIGN.md §3.2 / §3.6 / §3.7).
2//!
3//! Post-processes responses with `Content-Type: application/problem+json`,
4//! filling missing `trace_id` (live `OTel` span context → W3C `traceparent`)
5//! and `instance` (request URI path).
6//! Logs at `warn!` for 4xx / `error!` for 5xx with structured fields.
7//!
8//! Any other error-status response whose body is genuinely unstructured (a
9//! tower-layer short-circuit such as `RequestBodyLimitLayer`, an unmatched
10//! route, or any other rejection never typed as `CanonicalError` - all of
11//! which render as `text/plain` or omit `Content-Type` entirely, never a
12//! real content type a handler chose on purpose) is wrapped into a minimal,
13//! valid RFC 9457 `Problem` rather than passed through as-is - see
14//! `wrap_foreign_response` and `is_unstructured_error_body`. A response with
15//! any other `Content-Type` (`application/json`, `text/html`, ...) was
16//! deliberately shaped by the handler that returned it and is left alone -
17//! this fallback exists to rescue responses nothing ever shaped on purpose,
18//! not to overwrite one that was.
19//! A foreign 5xx is mapped to the real `internal` canonical category
20//! (DESIGN.md §2.1's fail-safe fallback: "no error escapes the system
21//! without a canonical category") since it is, by definition, this
22//! platform's own fault, not the client's. A foreign 4xx uses RFC 9457
23//! §4.2.1's `"about:blank"` convention instead - unlike a 5xx, a bare 4xx
24//! genuinely doesn't determine one canonical category over another (e.g.
25//! `invalid_argument` vs. `failed_precondition` vs. `out_of_range`), so
26//! `about:blank` is the honest "no more specific type than the status code"
27//! rather than a guess.
28//! `crate::api::rest::extract` (`Json`, `Query`, `Path`) is the precise,
29//! per-extractor counterpart to this generic fallback: prefer it wherever
30//! the failure's shape is known ahead of time. See
31//! `docs/arch/errors/ADR/0006-cpt-cf-adr-error-middleware-catchall.md` for
32//! the decision record. Panics remain out of scope here - `CatchPanicLayer`
33//! handles those before this middleware ever sees a response.
34//!
35//! **This middleware always renders JSON, gear-wide, with no content
36//! negotiation, for the responses it actually touches.** Every wrapped
37//! `Problem` (this fallback's, and every `CanonicalError`'s `IntoResponse`)
38//! is `application/problem+json` unconditionally - there is no
39//! `Accept`-header check anywhere in this pipeline. Since this middleware
40//! wraps a gear's *entire* router (every gear, via `toolkit`'s `OoP`
41//! bootstrap), it would be equally unconditional about *which* responses it
42//! rewrites if it matched on status alone - which is exactly the bug
43//! `is_unstructured_error_body` exists to close: a gear route that
44//! deliberately returns a non-`CanonicalError`, non-JSON, or custom-shaped
45//! JSON error body (server-rendered HTML, a legacy hand-rolled health
46//! check, ...) keeps that body exactly as returned, on any status. Only a
47//! response with no shape at all - because nothing ever gave it one - is
48//! this fallback's business.
49
50use std::time::Duration;
51
52use axum::{
53    body::{Body, to_bytes},
54    extract::Request,
55    http::{HeaderMap, HeaderName, HeaderValue, StatusCode, header, response::Parts},
56    middleware::Next,
57    response::Response,
58};
59use toolkit_canonical_errors::{CanonicalError, ForeignPassthrough, Http, Problem};
60
61use toolkit_trace_context::extract_trace_id;
62
63const PROBLEM_JSON: &str = "application/problem+json";
64
65/// Cap on how much of a foreign (non-`Problem`) error response body the
66/// generic-wrap fallback will read for server-side diagnostic logging. Never
67/// placed in the client-visible `detail` - see `wrap_as_generic_problem`.
68/// Every rejection body seen in this codebase today (axum's own
69/// `JsonRejection` messages, tower-http's body-limit text) is well under a
70/// kilobyte; this bounds the worst case for a response this middleware does
71/// not control the size of.
72const MAX_FOREIGN_BODY_LOG_BYTES: usize = 8 * 1024;
73
74/// Hard cap on how many characters of a foreign body are ever placed in a
75/// log line, after the byte cap above and UTF-8 lossy decoding.
76const MAX_FOREIGN_BODY_LOG_CHARS: usize = 256;
77
78/// Budget for reading a foreign response body before wrapping it. A stalled
79/// or slow upstream body (e.g. one proxied by `toolkit-gateway::Forwarder`)
80/// must not turn a fast error into a hung request - on elapse, wrapping
81/// proceeds without the diagnostic log, using the status's reason phrase.
82/// Reused (not just for the diagnostic-log read) as the budget for
83/// `enrich_problem_response`'s own initial body read below - a response
84/// merely labeled `application/problem+json` is exactly as untrusted as any
85/// other foreign body until its content is actually validated.
86const FOREIGN_BODY_READ_TIMEOUT: Duration = Duration::from_secs(2);
87
88/// Cap on `enrich_problem_response`'s initial `application/problem+json`
89/// body read. Distinct from `MAX_FOREIGN_BODY_LOG_BYTES`: that one only
90/// bounds a truncated *diagnostic log excerpt*, while this bounds the real
91/// body used to build the client response, so it needs headroom for a
92/// legitimate `field_violations` list rather than a short log line. This
93/// platform's own `CanonicalError`-produced bodies never come close to it;
94/// it exists to bound a foreign peer's body before it's fully buffered.
95const MAX_PROBLEM_BODY_BYTES: usize = 64 * 1024;
96
97/// Tower middleware function that fills `trace_id` / `instance` on canonical
98/// Problem responses and logs at `warn!` (4xx) / `error!` (5xx); wraps any
99/// other error-status response into a minimal RFC 9457 `Problem`.
100///
101/// A response tagged `ForeignPassthrough` (by a reverse-proxy layer like
102/// `toolkit-gateway::Forwarder` or `oagw`'s proxy data-plane) is returned
103/// completely unchanged, regardless of status or `Content-Type` - it was
104/// never constructed via `CanonicalError` and was never meant to be;
105/// rewriting or even reading its body would destroy a genuine upstream's
106/// own error identity and force full buffering of what may still be a
107/// streamed body.
108///
109/// Success/redirect responses pass through unchanged. Malformed Problem
110/// bodies are logged at `error!`; on an error status they are re-wrapped
111/// through the same fallback as any other untyped rejection, and on a
112/// non-error status they pass through unchanged.
113pub async fn canonical_error_middleware(request: Request, next: Next) -> Response {
114    let uri_path = request.uri().path().to_owned();
115    let request_headers = request.headers().clone();
116
117    let response = next.run(request).await;
118
119    if response.extensions().get::<ForeignPassthrough>().is_some() {
120        return response;
121    }
122
123    if is_problem_response(&response) {
124        return enrich_problem_response(response, &uri_path, &request_headers).await;
125    }
126
127    let status = response.status();
128    let is_error_status = status.is_client_error() || status.is_server_error();
129    if is_error_status && is_unstructured_error_body(&response) {
130        // A response reaching this branch is not `application/problem+json`
131        // (see the `is_problem_response` check above), so it essentially
132        // never carries a `CanonicalError` extension in practice - only
133        // `CanonicalError::into_response()` sets one, and that impl always
134        // sets `Content-Type: application/problem+json` in the same call.
135        // Checked anyway for the same reason `enrich_problem_response`'s
136        // malformed-body path checks it: defensively, in case some future
137        // layer sets the extension without matching Content-Type.
138        let recovered = response.extensions().get::<CanonicalError>().cloned();
139        return wrap_foreign_response(response, &uri_path, &request_headers, recovered).await;
140    }
141
142    response
143}
144
145/// Fills `instance` / `trace_id` on a response already carrying
146/// `Content-Type: application/problem+json` and logs it.
147async fn enrich_problem_response(
148    response: Response,
149    uri_path: &str,
150    request_headers: &HeaderMap,
151) -> Response {
152    let (parts, body) = response.into_parts();
153
154    // The `IntoResponse` impl for `CanonicalError` stashes the original error
155    // into the response extensions. Recover it so the diagnostic on
156    // `Internal` / `Unknown` (carried by `#[serde(skip)]` fields and thus
157    // absent from the wire body) can be logged server-side per DESIGN §3.6.
158    let canonical_err = parts.extensions.get::<CanonicalError>().cloned();
159
160    let bytes = match tokio::time::timeout(
161        FOREIGN_BODY_READ_TIMEOUT,
162        to_bytes(body, MAX_PROBLEM_BODY_BYTES),
163    )
164    .await
165    {
166        Ok(Ok(b)) => b,
167        Ok(Err(e)) => {
168            tracing::error!(error = %e, "canonical error middleware: failed to read response body");
169            return finish_unreadable_problem_body(parts, uri_path, request_headers, canonical_err)
170                .await;
171        }
172        Err(_) => {
173            tracing::error!(
174                instance = uri_path,
175                "canonical error middleware: timed out reading problem+json response body"
176            );
177            return finish_unreadable_problem_body(parts, uri_path, request_headers, canonical_err)
178                .await;
179        }
180    };
181
182    let mut problem: Problem = match serde_json::from_slice(&bytes) {
183        Ok(p) => p,
184        Err(e) => {
185            tracing::error!(error = %e, "canonical error middleware: failed to deserialize problem+json body");
186            let status = parts.status;
187            if !(status.is_client_error() || status.is_server_error()) {
188                // A non-error response (2xx/3xx) that merely mislabeled its
189                // Content-Type as `application/problem+json` is not this
190                // middleware's business to rewrite into an error - the
191                // "every error response is a valid Problem" guarantee only
192                // applies to actual error statuses. Pass the original bytes
193                // through unchanged rather than manufacturing a Problem with
194                // a success status.
195                tracing::warn!(
196                    status = status.as_u16(),
197                    instance = uri_path,
198                    "canonical error middleware: non-error response claimed application/problem+json but its body is not valid Problem JSON; passing through unchanged"
199                );
200                return Response::from_parts(parts, Body::from(bytes));
201            }
202            // A response claiming `application/problem+json` that isn't
203            // actually valid `Problem` JSON still needs to become one - the
204            // platform-wide guarantee is "every error response is a valid
205            // RFC 9457 Problem", not "unless it lied about its own
206            // Content-Type". Route it through the same safe fallback as any
207            // other untyped rejection, from the bytes already read (no
208            // second body read).
209            let foreign = Response::from_parts(parts, Body::from(bytes));
210            return wrap_foreign_response(foreign, uri_path, request_headers, canonical_err).await;
211        }
212    };
213
214    if problem.instance.is_none() {
215        problem.instance = Some(uri_path.to_owned());
216    }
217    if problem.trace_id.is_none() {
218        problem.trace_id = extract_trace_id(request_headers);
219    }
220    // RFC 9457 §3.1 makes `status` advisory; a peer that omitted it is
221    // still fully spec-compliant, and the real response status is right here.
222    problem.status.get_or_insert(parts.status.as_u16());
223
224    log_problem(&problem, canonical_err.as_ref());
225
226    let new_bytes = match serde_json::to_vec(&problem) {
227        Ok(b) => b,
228        Err(e) => {
229            tracing::error!(
230                error = %e,
231                "canonical error middleware: failed to re-serialize problem+json body"
232            );
233            return Response::from_parts(parts, Body::from(bytes));
234        }
235    };
236
237    let len = new_bytes.len();
238    let mut response = Response::from_parts(parts, Body::from(new_bytes));
239    response
240        .headers_mut()
241        .insert(header::CONTENT_LENGTH, HeaderValue::from(len));
242    response
243}
244
245/// Shared fallback for `enrich_problem_response` when its initial body read
246/// failed outright (I/O error, size limit, or timeout) rather than merely
247/// deserializing to something other than `Problem` - there are no bytes to
248/// build a `Problem` from or to pass through unchanged, so this mirrors the
249/// same 2xx/3xx-vs-error-status split the deserialize-failure path applies:
250/// a non-error response is left as an empty body (no valid bytes exist to
251/// preserve either way), an error response is routed through the same safe
252/// fallback as any other untyped rejection.
253async fn finish_unreadable_problem_body(
254    parts: Parts,
255    uri_path: &str,
256    request_headers: &HeaderMap,
257    canonical_err: Option<CanonicalError>,
258) -> Response {
259    let status = parts.status;
260    if !(status.is_client_error() || status.is_server_error()) {
261        return Response::from_parts(parts, Body::empty());
262    }
263    let foreign = Response::from_parts(parts, Body::empty());
264    wrap_foreign_response(foreign, uri_path, request_headers, canonical_err).await
265}
266
267/// Builds a minimal, valid `Problem` for a response that was never typed as
268/// `CanonicalError` - a tower-layer short-circuit, an unmatched route, or any
269/// other untyped rejection. If `recovered` is `Some` (a `CanonicalError` was
270/// recovered from the original response's extensions - see
271/// `enrich_problem_response`'s malformed-body path), that real category is
272/// used directly: it isn't a guess from a bare status code, so there's no
273/// reason to discard it in favor of a class-based fallback. Only when
274/// `recovered` is `None` does the class-based split apply: a foreign 5xx
275/// maps to the real `internal` canonical category (`wrap_as_internal_problem`);
276/// a foreign 4xx uses RFC 9457 §4.2.1's `"about:blank"` convention
277/// (`wrap_as_generic_problem`) - see this module's doc comment for why the
278/// two classes are treated differently in that case.
279async fn wrap_foreign_response(
280    response: Response,
281    uri_path: &str,
282    request_headers: &HeaderMap,
283    recovered: Option<CanonicalError>,
284) -> Response {
285    if let Some(canonical) = recovered {
286        return wrap_recovered_canonical_error(response, uri_path, request_headers, canonical)
287            .await;
288    }
289    if response.status().is_server_error() {
290        wrap_as_internal_problem(response, uri_path, request_headers).await
291    } else {
292        wrap_as_generic_problem(response, uri_path, request_headers).await
293    }
294}
295
296/// Builds a `Problem` directly from a `CanonicalError` recovered from the
297/// original response's extensions, for a response whose body could not be
298/// trusted (failed to read, or failed to deserialize as `Problem`) but whose
299/// extensions still carried the real error the handler produced. Skips the
300/// class-based `about:blank`/`internal` guess entirely - the real category,
301/// status, and diagnostic are already known.
302async fn wrap_recovered_canonical_error(
303    response: Response,
304    uri_path: &str,
305    request_headers: &HeaderMap,
306    canonical: CanonicalError,
307) -> Response {
308    let status = response.status();
309    let (parts, body) = response.into_parts();
310
311    log_foreign_body(body, status, uri_path).await;
312
313    let mut problem: Problem = canonical.clone().into();
314    problem.instance = Some(uri_path.to_owned());
315    problem.trace_id = extract_trace_id(request_headers);
316
317    log_problem(&problem, Some(&canonical));
318    finish_wrapped_response(parts, &problem)
319}
320
321/// Reads a foreign response body for server-side diagnostic logging only -
322/// never surfaced in any client-visible field. This response was never
323/// typed as `CanonicalError`, so nothing here has vetted it for the "no
324/// internal details on the wire" guarantee the rest of this error system
325/// enforces (`Internal`/`Unknown`'s `description` is `#[serde(skip)]` for
326/// the same reason); a foreign 5xx in particular could carry a stack trace,
327/// an internal hostname, or a credential. The log line itself is bounded
328/// and escaped: it reaches a less-restricted access boundary than the HTTP
329/// response, and the raw text is client-controlled, so it's escaped to
330/// block log-line injection (embedded newlines/ANSI) and hard-truncated in
331/// addition to the byte cap already applied to the read. Bounded by
332/// `FOREIGN_BODY_READ_TIMEOUT` so a stalled or slow body (e.g. one proxied
333/// by `toolkit-gateway::Forwarder`) cannot turn a fast error into a hung
334/// request.
335async fn log_foreign_body(body: Body, status: StatusCode, uri_path: &str) {
336    tracing::warn!(
337        status = status.as_u16(),
338        instance = uri_path,
339        "canonical error middleware: wrapping a foreign error response"
340    );
341    match tokio::time::timeout(
342        FOREIGN_BODY_READ_TIMEOUT,
343        to_bytes(body, MAX_FOREIGN_BODY_LOG_BYTES),
344    )
345    .await
346    {
347        Ok(Ok(bytes)) => {
348            let text = String::from_utf8_lossy(&bytes);
349            let text = text.trim();
350            if !text.is_empty() {
351                let truncated: String = text.chars().take(MAX_FOREIGN_BODY_LOG_CHARS).collect();
352                tracing::debug!(
353                    status = status.as_u16(),
354                    instance = uri_path,
355                    body = %truncated.escape_debug(),
356                    "canonical error middleware: foreign response body (diagnostic only, never sent to client)"
357                );
358            }
359        }
360        Ok(Err(e)) => {
361            tracing::warn!(error = %e, "canonical error middleware: failed to read foreign response body while wrapping");
362        }
363        Err(_) => {
364            tracing::warn!(
365                status = status.as_u16(),
366                instance = uri_path,
367                "canonical error middleware: timed out reading foreign response body while wrapping"
368            );
369        }
370    }
371}
372
373/// Headers preserved from the original foreign response onto the wrapped
374/// `Problem` response. The body is fully replaced, so the correct default is
375/// an allowlist, not a denylist: nothing that described or applied to the
376/// discarded original body (e.g. `Content-Encoding`, `ETag`, `Server`, an
377/// internal `X-*` header) is assumed to still be true of the new one.
378/// `Content-Type`/`Content-Length` are set separately, fresh, for the new
379/// body. What's kept: `WWW-Authenticate`/`Retry-After` carry client-actionable
380/// semantics independent of body content (auth challenge, backoff); the
381/// CORS headers and `Vary` are needed for a wrapped error to still pass a
382/// browser's CORS check.
383const PRESERVED_FOREIGN_HEADERS: &[axum::http::HeaderName] = &[
384    header::RETRY_AFTER,
385    header::ACCESS_CONTROL_ALLOW_ORIGIN,
386    header::ACCESS_CONTROL_ALLOW_CREDENTIALS,
387    header::ACCESS_CONTROL_EXPOSE_HEADERS,
388    header::VARY,
389];
390
391/// Rate-limit/quota headers set alongside a `resource_exhausted`/
392/// `service_unavailable` `CanonicalError` response by
393/// `gears/system/api-gateway/src/middleware/throttling.rs` (`RateLimit-Policy`,
394/// `RateLimit-Limit`, `X-RateLimit-Limit`) and `gears/system/oagw`'s
395/// `error_response` (`x-ratelimit-limit`, `x-ratelimit-remaining`,
396/// `x-ratelimit-reset`) - both attach these to the same kind of response
397/// (`CanonicalError`/`Problem`, extension present) that can reach
398/// `wrap_recovered_canonical_error` if its body is ever corrupted
399/// downstream; without this list, `Retry-After` alone would survive that
400/// path while its sibling quota numbers silently vanished.
401const PRESERVED_RATE_LIMIT_HEADERS: &[&str] = &[
402    "ratelimit-policy",
403    "ratelimit-limit",
404    "x-ratelimit-limit",
405    "x-ratelimit-remaining",
406    "x-ratelimit-reset",
407];
408
409/// Preserved via `get_all`/`append`, not `get`/`insert` like
410/// `PRESERVED_FOREIGN_HEADERS` - both are repeatable headers.
411/// `Set-Cookie` (RFC 6265 §4.1.1 forbids folding multiple cookies into one
412/// line) - a response clearing a session or setting several cookies on an
413/// error path would silently lose all but one if treated as single-valued.
414/// `WWW-Authenticate` can likewise carry multiple challenges (RFC 9110
415/// §11.6.1) - no call site in this codebase appends more than one today
416/// (checked), but treating it as single-valued here would be a latent
417/// version of the exact bug `Set-Cookie` just had.
418const PRESERVED_MULTI_VALUE_FOREIGN_HEADERS: &[axum::http::HeaderName] =
419    &[header::SET_COOKIE, header::WWW_AUTHENTICATE];
420
421/// Serializes `problem` and swaps it in as the response body, keeping only
422/// `PRESERVED_FOREIGN_HEADERS`/`PRESERVED_RATE_LIMIT_HEADERS`/
423/// `PRESERVED_MULTI_VALUE_FOREIGN_HEADERS` from the original response.
424fn finish_wrapped_response(parts: axum::http::response::Parts, problem: &Problem) -> Response {
425    let bytes = match serde_json::to_vec(problem) {
426        Ok(b) => b,
427        Err(e) => {
428            tracing::error!(
429                error = %e,
430                "canonical error middleware: failed to serialize generic-wrap problem body"
431            );
432            return Response::from_parts(parts, Body::empty());
433        }
434    };
435
436    let len = bytes.len();
437    let mut headers = HeaderMap::new();
438    for name in PRESERVED_FOREIGN_HEADERS {
439        if let Some(value) = parts.headers.get(name) {
440            headers.insert(name.clone(), value.clone());
441        }
442    }
443    for name in PRESERVED_RATE_LIMIT_HEADERS {
444        // Static, pre-lowercased literals - safe to unwrap.
445        let name = HeaderName::from_static(name);
446        if let Some(value) = parts.headers.get(&name) {
447            headers.insert(name, value.clone());
448        }
449    }
450    for name in PRESERVED_MULTI_VALUE_FOREIGN_HEADERS {
451        for value in parts.headers.get_all(name) {
452            headers.append(name.clone(), value.clone());
453        }
454    }
455    headers.insert(header::CONTENT_TYPE, HeaderValue::from_static(PROBLEM_JSON));
456    headers.insert(header::CONTENT_LENGTH, HeaderValue::from(len));
457
458    let mut response = Response::from_parts(parts, Body::from(bytes));
459    *response.headers_mut() = headers;
460    response
461}
462
463/// Builds a minimal `about:blank` `Problem` for a foreign **4xx** response -
464/// a bare client-error status alone doesn't determine one canonical
465/// category over another (e.g. 400 could be `invalid_argument`,
466/// `failed_precondition`, or `out_of_range`), so this is the honest "no
467/// more specific type than the status code" per RFC 9457 §4.2.1, not a
468/// guess.
469async fn wrap_as_generic_problem(
470    response: Response,
471    uri_path: &str,
472    request_headers: &HeaderMap,
473) -> Response {
474    let status = response.status();
475    let (parts, body) = response.into_parts();
476    let reason = status.canonical_reason().unwrap_or("Error");
477
478    log_foreign_body(body, status, uri_path).await;
479
480    let problem = Problem {
481        problem_type: "about:blank".to_owned(),
482        title: reason.to_owned(),
483        status: Some(status.as_u16()),
484        detail: reason.to_owned(),
485        instance: Some(uri_path.to_owned()),
486        trace_id: extract_trace_id(request_headers),
487        context: serde_json::Value::Object(serde_json::Map::new()),
488        error_code: None,
489        error_domain: None,
490    };
491
492    log_problem(&problem, None);
493    finish_wrapped_response(parts, &problem)
494}
495
496/// Builds a real `internal` canonical-category `Problem` for a foreign
497/// **5xx** response - unlike a 4xx, a 5xx is unambiguous: it's this
498/// platform's own fault, not the client's, so DESIGN.md §2.1's fail-safe
499/// fallback ("no error escapes the system without a canonical category")
500/// applies directly. The original status is preserved via `.with_override`
501/// when it isn't already 500 (e.g. a proxied 502/503/504) - `internal`'s
502/// default status is already 500, and every override here stays within the
503/// 5xx class, so the builder's same-status-class invariant always holds.
504async fn wrap_as_internal_problem(
505    response: Response,
506    uri_path: &str,
507    request_headers: &HeaderMap,
508) -> Response {
509    let status = response.status();
510    let (parts, body) = response.into_parts();
511    let reason = status.canonical_reason().unwrap_or("Error");
512
513    log_foreign_body(body, status, uri_path).await;
514
515    let canonical = if status == StatusCode::INTERNAL_SERVER_ERROR {
516        CanonicalError::internal(reason).create()
517    } else {
518        CanonicalError::internal(reason)
519            .with_override(Http::status_code(status.as_u16()))
520            .create()
521    };
522    let mut problem: Problem = canonical.clone().into();
523    problem.instance = Some(uri_path.to_owned());
524    problem.trace_id = extract_trace_id(request_headers);
525
526    log_problem(&problem, Some(&canonical));
527    finish_wrapped_response(parts, &problem)
528}
529
530/// The response's `Content-Type` media type, ignoring parameters (e.g.
531/// `; charset=...`) - per RFC 9110, those never affect the type itself, and
532/// a naive full-header comparison would both reject a validly-cased-but-
533/// parameterized value and wrongly distinguish two responses of the same
534/// underlying type.
535fn content_type_media(response: &Response) -> Option<&str> {
536    response
537        .headers()
538        .get(header::CONTENT_TYPE)
539        .and_then(|v| v.to_str().ok())
540        .map(|ct| {
541            ct.split_once(';')
542                .map_or(ct, |(media_type, _)| media_type)
543                .trim()
544        })
545}
546
547fn is_problem_response(response: &Response) -> bool {
548    // Comparison is ASCII-case-insensitive per RFC 9110 - a naive
549    // `starts_with` would both reject a validly-cased
550    // `Application/Problem+Json` and wrongly accept an unrelated type like
551    // `application/problem+json-seq` that merely shares this prefix.
552    content_type_media(response).is_some_and(|mt| mt.eq_ignore_ascii_case(PROBLEM_JSON))
553}
554
555/// Whether a response's body is genuinely unshaped rather than something a
556/// handler deliberately built. Every case this fallback is actually meant
557/// to rescue - an axum extractor rejection not yet migrated to
558/// `crate::api::rest::extract`, a tower-layer short-circuit like
559/// `RequestBodyLimitLayer`, an unmatched route - renders as `text/plain` or
560/// omits `Content-Type` entirely; none of them is a real content type any
561/// handler chose on purpose. A response with any other `Content-Type`
562/// (`application/json`, `text/html`, ...) was shaped by the code that
563/// returned it, even if that shape isn't `CanonicalError`/`Problem` - e.g. a
564/// legacy hand-rolled JSON error body predating this platform's RFC 9457
565/// adoption. Overwriting that body would silently destroy real,
566/// client-relied-on fields with no way for the handler to opt out short of
567/// switching its own `Content-Type` - this happened for real to
568/// `api-gateway`'s `/health` endpoint (its `components` array), which is
569/// exactly why this check exists rather than matching on status alone.
570fn is_unstructured_error_body(response: &Response) -> bool {
571    match content_type_media(response) {
572        None => true,
573        Some(mt) => mt.eq_ignore_ascii_case("text/plain"),
574    }
575}
576
577fn log_problem(problem: &Problem, canonical: Option<&CanonicalError>) {
578    // Every Problem this middleware logs was either just normalized from a
579    // real HTTP status (`enrich_problem_response`) or built via
580    // `From<CanonicalError>`, so `status` is always `Some` in practice; `0`
581    // is a safe fallback that simply logs nothing (it matches neither range
582    // below) rather than a guessed status.
583    let status = problem.status.unwrap_or(0);
584    let problem_type = problem.problem_type.as_str();
585    let instance = problem.instance.as_deref().unwrap_or("");
586    // `trace_id` is intentionally NOT an event field: the log-correlation
587    // formatter splices the live span's `trace_id` onto the top level of every
588    // record (`bootstrap::host::log_correlation`), so a nested copy here would
589    // be a duplicate key.
590    // `diagnostic()` returns Some only for `Internal` / `Unknown` (5xx-only
591    // categories). Surface it server-side so operators can correlate root
592    // cause without exposing it on the wire.
593    let description = canonical.and_then(CanonicalError::diagnostic).unwrap_or("");
594
595    if (400..500).contains(&status) {
596        tracing::warn!(
597            status,
598            problem_type,
599            instance,
600            "canonical error response (client)"
601        );
602    } else if (500..600).contains(&status) {
603        tracing::error!(
604            status,
605            problem_type,
606            instance,
607            description,
608            "canonical error response (server)"
609        );
610    }
611}
612
613#[cfg(test)]
614#[cfg_attr(coverage_nightly, coverage(off))]
615#[path = "canonical_error_layer_tests.rs"]
616mod tests;