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