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//! Catch-all behaviour (panics, unknown error types) is out of scope per
9//! PRD §4.2 — `CatchPanicLayer` handles panics; handlers are responsible
10//! for typing their errors as `CanonicalError`.
11
12use axum::{
13    body::{Body, to_bytes},
14    extract::Request,
15    http::{HeaderMap, HeaderValue, header},
16    middleware::Next,
17    response::Response,
18};
19use toolkit_canonical_errors::{CanonicalError, Problem};
20
21const PROBLEM_JSON: &str = "application/problem+json";
22
23/// Tower middleware function that fills `trace_id` / `instance` on canonical
24/// Problem responses and logs at `warn!` (4xx) / `error!` (5xx).
25///
26/// Non-problem responses pass through unchanged. Malformed Problem bodies
27/// are logged at `error!` and returned to the client as-is.
28pub async fn canonical_error_middleware(request: Request, next: Next) -> Response {
29    let uri_path = request.uri().path().to_owned();
30    let request_headers = request.headers().clone();
31
32    let response = next.run(request).await;
33
34    if !is_problem_response(&response) {
35        return response;
36    }
37
38    let (parts, body) = response.into_parts();
39
40    // The `IntoResponse` impl for `CanonicalError` stashes the original error
41    // into the response extensions. Recover it so the diagnostic on
42    // `Internal` / `Unknown` (carried by `#[serde(skip)]` fields and thus
43    // absent from the wire body) can be logged server-side per DESIGN §3.6.
44    let canonical_err = parts.extensions.get::<CanonicalError>().cloned();
45
46    let bytes = match to_bytes(body, usize::MAX).await {
47        Ok(b) => b,
48        Err(e) => {
49            tracing::error!(error = %e, "canonical error middleware: failed to read response body");
50            return Response::from_parts(parts, Body::empty());
51        }
52    };
53
54    let mut problem: Problem = match serde_json::from_slice(&bytes) {
55        Ok(p) => p,
56        Err(e) => {
57            tracing::error!(error = %e, "canonical error middleware: failed to deserialize problem+json body");
58            return Response::from_parts(parts, Body::from(bytes));
59        }
60    };
61
62    if problem.instance.is_none() {
63        problem.instance = Some(uri_path);
64    }
65    if problem.trace_id.is_none() {
66        problem.trace_id = extract_trace_id(&request_headers);
67    }
68
69    log_problem(&problem, canonical_err.as_ref());
70
71    let new_bytes = match serde_json::to_vec(&problem) {
72        Ok(b) => b,
73        Err(e) => {
74            tracing::error!(
75                error = %e,
76                "canonical error middleware: failed to re-serialize problem+json body"
77            );
78            return Response::from_parts(parts, Body::from(bytes));
79        }
80    };
81
82    let mut response = Response::from_parts(parts, Body::from(new_bytes.clone()));
83    response
84        .headers_mut()
85        .insert(header::CONTENT_LENGTH, HeaderValue::from(new_bytes.len()));
86    response
87}
88
89fn is_problem_response(response: &Response) -> bool {
90    response
91        .headers()
92        .get(header::CONTENT_TYPE)
93        .and_then(|v| v.to_str().ok())
94        .is_some_and(|ct| ct.starts_with(PROBLEM_JSON))
95}
96
97/// W3C `traceparent` → `x-trace-id` → `x-request-id` → span-id fallback.
98///
99/// For `traceparent`, returns the 32-hex trace-id segment only — matching
100/// `toolkit_http::otel::parse_trace_id` and the access log / `OTel` span
101/// recording in this codebase, so the wire `trace_id` is grep-equal to the
102/// trace-id surfaced in logs and traces. A malformed traceparent falls
103/// through to `x-trace-id` / `x-request-id` (preserves the function's
104/// graceful-failure semantics).
105fn extract_trace_id(headers: &HeaderMap) -> Option<String> {
106    if let Some(tp) = headers.get("traceparent").and_then(|v| v.to_str().ok())
107        && let Some(trace_id) = parse_w3c_trace_id(tp)
108    {
109        return Some(trace_id);
110    }
111    for name in ["x-trace-id", "x-request-id"] {
112        if let Some(v) = headers.get(name).and_then(|v| v.to_str().ok()) {
113            return Some(v.to_owned());
114        }
115    }
116    tracing::Span::current()
117        .id()
118        .map(|id| id.into_u64().to_string())
119}
120
121/// Mirror of `toolkit_http::otel::parse_trace_id`. Duplicated rather than
122/// taking a new dep edge from `toolkit` onto `toolkit-http` for seven lines
123/// of parsing. Keep behaviour in lock-step with the source.
124fn parse_w3c_trace_id(traceparent: &str) -> Option<String> {
125    let parts: Vec<&str> = traceparent.split('-').collect();
126    if parts.len() >= 4 && parts[0] == "00" {
127        Some(parts[1].to_owned())
128    } else {
129        None
130    }
131}
132
133fn log_problem(problem: &Problem, canonical: Option<&CanonicalError>) {
134    let status = problem.status;
135    let problem_type = problem.problem_type.as_str();
136    let instance = problem.instance.as_deref().unwrap_or("");
137    let trace_id = problem.trace_id.as_deref().unwrap_or("");
138    // `diagnostic()` returns Some only for `Internal` / `Unknown` (5xx-only
139    // categories). Surface it server-side so operators can correlate
140    // `trace_id` → root cause without exposing it on the wire.
141    let description = canonical.and_then(CanonicalError::diagnostic).unwrap_or("");
142
143    if (400..500).contains(&status) {
144        tracing::warn!(
145            status,
146            problem_type,
147            instance,
148            trace_id,
149            "canonical error response (client)"
150        );
151    } else if (500..600).contains(&status) {
152        tracing::error!(
153            status,
154            problem_type,
155            instance,
156            trace_id,
157            description,
158            "canonical error response (server)"
159        );
160    }
161}
162
163#[cfg(test)]
164#[cfg_attr(coverage_nightly, coverage(off))]
165mod tests {
166    use super::*;
167    use axum::{
168        Router,
169        body::Body,
170        http::{Request, StatusCode},
171        middleware::from_fn,
172        routing::get,
173    };
174    use serde_json::Value;
175    use toolkit_canonical_errors::CanonicalError;
176    use tower::ServiceExt;
177
178    fn problem_response(problem: &Problem, status: StatusCode) -> Response {
179        let body = serde_json::to_vec(problem).expect("serialize problem");
180        Response::builder()
181            .status(status)
182            .header(header::CONTENT_TYPE, PROBLEM_JSON)
183            .body(Body::from(body))
184            .expect("build response")
185    }
186
187    fn build_app(responder: impl Fn() -> Response + Clone + Send + Sync + 'static) -> Router {
188        Router::new()
189            .route(
190                "/api/v1/widgets/42",
191                get(move || {
192                    let responder = responder.clone();
193                    async move { responder() }
194                }),
195            )
196            .layer(from_fn(canonical_error_middleware))
197    }
198
199    async fn body_to_problem(response: Response) -> Problem {
200        let bytes = axum::body::to_bytes(response.into_body(), usize::MAX)
201            .await
202            .expect("read body");
203        serde_json::from_slice(&bytes).expect("parse problem+json")
204    }
205
206    #[tokio::test]
207    async fn fills_instance_and_trace_id_from_headers() {
208        let problem: Problem = CanonicalError::internal("boom").create().into();
209        let app = build_app(move || problem_response(&problem, StatusCode::INTERNAL_SERVER_ERROR));
210
211        let req = Request::builder()
212            .uri("/api/v1/widgets/42")
213            .header(
214                "traceparent",
215                "00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01",
216            )
217            .body(Body::empty())
218            .unwrap();
219        let res = app.oneshot(req).await.unwrap();
220        assert_eq!(res.status(), StatusCode::INTERNAL_SERVER_ERROR);
221        let problem = body_to_problem(res).await;
222
223        assert_eq!(problem.instance.as_deref(), Some("/api/v1/widgets/42"));
224        // Only the 32-hex trace-id segment is surfaced on the wire — matches
225        // the format the access log and OTel span recording use, so an
226        // operator can grep the same value across all three.
227        assert_eq!(
228            problem.trace_id.as_deref(),
229            Some("4bf92f3577b34da6a3ce929d0e0e4736")
230        );
231    }
232
233    #[tokio::test]
234    async fn does_not_overwrite_existing_instance() {
235        let preset: Problem =
236            Problem::from(CanonicalError::internal("boom").create()).with_instance("/handler-set");
237        let app = build_app(move || problem_response(&preset, StatusCode::INTERNAL_SERVER_ERROR));
238
239        let req = Request::builder()
240            .uri("/api/v1/widgets/42")
241            .body(Body::empty())
242            .unwrap();
243        let res = app.oneshot(req).await.unwrap();
244        let problem = body_to_problem(res).await;
245
246        assert_eq!(problem.instance.as_deref(), Some("/handler-set"));
247    }
248
249    #[tokio::test]
250    async fn does_not_overwrite_existing_trace_id() {
251        let preset: Problem =
252            Problem::from(CanonicalError::internal("boom").create()).with_trace_id("handler-trace");
253        let app = build_app(move || problem_response(&preset, StatusCode::INTERNAL_SERVER_ERROR));
254
255        let req = Request::builder()
256            .uri("/api/v1/widgets/42")
257            .header("traceparent", "should-be-ignored")
258            .body(Body::empty())
259            .unwrap();
260        let res = app.oneshot(req).await.unwrap();
261        let problem = body_to_problem(res).await;
262
263        assert_eq!(problem.trace_id.as_deref(), Some("handler-trace"));
264    }
265
266    #[tokio::test]
267    async fn passes_through_non_problem_responses_verbatim() {
268        let payload = b"{\"hello\":\"world\"}";
269        let app = Router::new()
270            .route(
271                "/plain",
272                get(|| async {
273                    Response::builder()
274                        .status(StatusCode::OK)
275                        .header(header::CONTENT_TYPE, "application/json")
276                        .body(Body::from(&b"{\"hello\":\"world\"}"[..]))
277                        .unwrap()
278                }),
279            )
280            .layer(from_fn(canonical_error_middleware));
281
282        let req = Request::builder()
283            .uri("/plain")
284            .body(Body::empty())
285            .unwrap();
286        let res = app.oneshot(req).await.unwrap();
287        assert_eq!(res.status(), StatusCode::OK);
288        assert_eq!(
289            res.headers()
290                .get(header::CONTENT_TYPE)
291                .and_then(|v| v.to_str().ok()),
292            Some("application/json")
293        );
294        let bytes = axum::body::to_bytes(res.into_body(), usize::MAX)
295            .await
296            .unwrap();
297        assert_eq!(bytes.as_ref(), payload);
298    }
299
300    #[tokio::test]
301    #[tracing_test::traced_test]
302    async fn malformed_problem_passes_through_with_error_log() {
303        let raw = b"{not-json}";
304        let app = Router::new()
305            .route(
306                "/api/v1/widgets/42",
307                get(|| async {
308                    Response::builder()
309                        .status(StatusCode::INTERNAL_SERVER_ERROR)
310                        .header(header::CONTENT_TYPE, PROBLEM_JSON)
311                        .body(Body::from(&b"{not-json}"[..]))
312                        .unwrap()
313                }),
314            )
315            .layer(from_fn(canonical_error_middleware));
316
317        let req = Request::builder()
318            .uri("/api/v1/widgets/42")
319            .body(Body::empty())
320            .unwrap();
321        let res = app.oneshot(req).await.unwrap();
322        assert_eq!(res.status(), StatusCode::INTERNAL_SERVER_ERROR);
323        let bytes = axum::body::to_bytes(res.into_body(), usize::MAX)
324            .await
325            .unwrap();
326        assert_eq!(bytes.as_ref(), raw);
327        assert!(logs_contain(
328            "canonical error middleware: failed to deserialize problem+json body"
329        ));
330    }
331
332    #[tokio::test]
333    #[tracing_test::traced_test]
334    async fn logs_warn_for_4xx_and_error_for_5xx() {
335        // 4xx → warn
336        let problem_4xx: Problem = CanonicalError::unauthenticated()
337            .with_reason("MISSING_TOKEN")
338            .create()
339            .into();
340        let app_4xx = build_app(move || problem_response(&problem_4xx, StatusCode::UNAUTHORIZED));
341        let req = Request::builder()
342            .uri("/api/v1/widgets/42")
343            .body(Body::empty())
344            .unwrap();
345        let _ = app_4xx.oneshot(req).await.unwrap();
346        assert!(logs_contain("canonical error response (client)"));
347
348        // 5xx → error
349        let problem_5xx: Problem = CanonicalError::internal("boom").create().into();
350        let app_5xx =
351            build_app(move || problem_response(&problem_5xx, StatusCode::INTERNAL_SERVER_ERROR));
352        let req = Request::builder()
353            .uri("/api/v1/widgets/42")
354            .body(Body::empty())
355            .unwrap();
356        let _ = app_5xx.oneshot(req).await.unwrap();
357        assert!(logs_contain("canonical error response (server)"));
358    }
359
360    #[tokio::test]
361    async fn extract_trace_id_prefers_traceparent_over_other_headers() {
362        let problem: Problem = CanonicalError::internal("boom").create().into();
363        let app = build_app(move || problem_response(&problem, StatusCode::INTERNAL_SERVER_ERROR));
364
365        let req = Request::builder()
366            .uri("/api/v1/widgets/42")
367            .header(
368                "traceparent",
369                "00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01",
370            )
371            .header("x-trace-id", "from-x-trace-id")
372            .header("x-request-id", "from-x-request-id")
373            .body(Body::empty())
374            .unwrap();
375        let res = app.oneshot(req).await.unwrap();
376        let problem = body_to_problem(res).await;
377
378        // 32-hex trace-id segment from traceparent wins over the other headers.
379        assert_eq!(
380            problem.trace_id.as_deref(),
381            Some("4bf92f3577b34da6a3ce929d0e0e4736")
382        );
383    }
384
385    #[tokio::test]
386    async fn malformed_traceparent_falls_through_to_x_trace_id() {
387        let problem: Problem = CanonicalError::internal("boom").create().into();
388        let app = build_app(move || problem_response(&problem, StatusCode::INTERNAL_SERVER_ERROR));
389
390        let req = Request::builder()
391            .uri("/api/v1/widgets/42")
392            .header("traceparent", "not-a-w3c-traceparent")
393            .header("x-trace-id", "from-x-trace-id")
394            .body(Body::empty())
395            .unwrap();
396        let res = app.oneshot(req).await.unwrap();
397        let problem = body_to_problem(res).await;
398
399        // Malformed traceparent does not block extraction from the next header.
400        assert_eq!(problem.trace_id.as_deref(), Some("from-x-trace-id"));
401    }
402
403    #[tokio::test]
404    async fn falls_back_to_span_id_when_no_trace_headers_present() {
405        // Parity with the `sets_trace_id_when_in_span` test the legacy
406        // `CanonicalProblemMigrationExt` trait file used to carry: when
407        // none of `traceparent` / `x-trace-id` / `x-request-id` is set,
408        // the middleware fills `trace_id` from `tracing::Span::current().id()`.
409        use tracing::Instrument;
410        use tracing_subscriber::fmt;
411
412        // Thread-local subscriber so the assigned span ID is observable;
413        // `set_default` returns a guard that restores the previous default
414        // when dropped.
415        let subscriber = fmt().with_test_writer().finish();
416        let _guard = tracing::subscriber::set_default(subscriber);
417
418        let span = tracing::info_span!("span_id_fallback_test");
419        let span_id = span
420            .id()
421            .expect("the test subscriber must assign an ID to the span")
422            .into_u64()
423            .to_string();
424
425        let problem: Problem = CanonicalError::internal("boom").create().into();
426        let app = build_app(move || problem_response(&problem, StatusCode::INTERNAL_SERVER_ERROR));
427
428        let req = Request::builder()
429            .uri("/api/v1/widgets/42")
430            .body(Body::empty())
431            .unwrap();
432
433        // `.instrument(span)` makes `span` the current span every time the
434        // request future is polled, so `Span::current().id()` inside the
435        // middleware (after `next.run(...).await`) resolves to `Some(span)`.
436        let res = app.oneshot(req).instrument(span).await.unwrap();
437        let problem = body_to_problem(res).await;
438
439        assert_eq!(
440            problem.trace_id.as_deref(),
441            Some(span_id.as_str()),
442            "trace_id should fall back to the active span's id when no header is present",
443        );
444    }
445
446    #[tokio::test]
447    async fn body_is_valid_json_after_rewrite() {
448        let problem: Problem = CanonicalError::internal("boom").create().into();
449        let app = build_app(move || problem_response(&problem, StatusCode::INTERNAL_SERVER_ERROR));
450
451        let req = Request::builder()
452            .uri("/api/v1/widgets/42")
453            .header("x-trace-id", "abc123")
454            .body(Body::empty())
455            .unwrap();
456        let res = app.oneshot(req).await.unwrap();
457        let bytes = axum::body::to_bytes(res.into_body(), usize::MAX)
458            .await
459            .unwrap();
460        let v: Value = serde_json::from_slice(&bytes).expect("rewritten body must be valid JSON");
461        assert_eq!(v["instance"].as_str(), Some("/api/v1/widgets/42"));
462        assert_eq!(v["trace_id"].as_str(), Some("abc123"));
463    }
464
465    #[tokio::test]
466    #[tracing_test::traced_test]
467    async fn logs_internal_description_from_extension() {
468        // `Internal::description` is `#[serde(skip)]` so the wire body cannot
469        // carry the unredacted message. The middleware recovers the original
470        // `CanonicalError` from response extensions (DESIGN §3.6) and logs
471        // the diagnostic alongside `trace_id` for server-side correlation.
472        use axum::response::IntoResponse;
473
474        let app = Router::new()
475            .route(
476                "/api/v1/widgets/42",
477                get(|| async {
478                    CanonicalError::internal("db connection refused: secret-host:5432")
479                        .create()
480                        .into_response()
481                }),
482            )
483            .layer(from_fn(canonical_error_middleware));
484
485        let req = Request::builder()
486            .uri("/api/v1/widgets/42")
487            .body(Body::empty())
488            .unwrap();
489        let res = app.oneshot(req).await.unwrap();
490        assert_eq!(res.status(), StatusCode::INTERNAL_SERVER_ERROR);
491
492        // The wire body must NOT contain the diagnostic.
493        let bytes = axum::body::to_bytes(res.into_body(), usize::MAX)
494            .await
495            .unwrap();
496        let body_str = std::str::from_utf8(&bytes).unwrap();
497        assert!(
498            !body_str.contains("secret-host:5432"),
499            "diagnostic must not appear on the wire"
500        );
501
502        // Server-side log must contain the diagnostic.
503        assert!(logs_contain("canonical error response (server)"));
504        assert!(logs_contain("db connection refused: secret-host:5432"));
505    }
506}