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;