ifc_lite_server/middleware/
error_envelope.rs

1// This Source Code Form is subject to the terms of the Mozilla Public
2// License, v. 2.0. If a copy of the MPL was not distributed with this
3// file, You can obtain one at https://mozilla.org/MPL/2.0/.
4
5//! One error body for the failures no handler writes (#5750).
6//!
7//! Every handler failure is an [`ApiError`](crate::error::ApiError), which
8//! renders the shared `ErrorResponse` envelope. The responses produced
9//! AROUND the handlers did not: an extractor rejection (a bad query string, a
10//! request that is not multipart) was `text/plain`, and an unknown route, a
11//! wrong method, the request timeout and the panic catcher answered with an
12//! empty body. A client decoding `{"error", "code"}` got a JSON parse failure
13//! on exactly those statuses. This layer rewrites them into the envelope,
14//! keeping the status, every header but the body's own, and the framework's
15//! message where it wrote one.
16//!
17//! A JSON error body is left alone: `ApiError` has already written the
18//! envelope, and the one other JSON non-2xx body, `/api/v1/ready`'s `503`, is
19//! a probe document whose status field is its contract.
20
21use axum::{
22    body::to_bytes,
23    http::{header, HeaderMap, StatusCode},
24    response::Response,
25};
26
27use crate::error::error_response;
28
29/// The most of a framework-written body carried into `error`. Rejection texts
30/// are a line or two; anything longer is not a message worth relaying.
31const MAX_MESSAGE_BYTES: usize = 4096;
32
33/// `axum::middleware::map_response` target: wrap any non-JSON `4xx`/`5xx` in
34/// the shared error envelope.
35pub async fn envelope_errors(response: Response) -> Response {
36    let status = response.status();
37    if !(status.is_client_error() || status.is_server_error()) || is_json(response.headers()) {
38        return response;
39    }
40
41    let (mut parts, body) = response.into_parts();
42    // An encoded body is not text; only the router below this layer writes
43    // these, and compression sits above it, so this is a guard, not a path.
44    let encoded = parts.headers.contains_key(header::CONTENT_ENCODING);
45    let message = match to_bytes(body, MAX_MESSAGE_BYTES).await {
46        Ok(bytes) if !encoded => String::from_utf8_lossy(&bytes).trim().to_owned(),
47        _ => String::new(),
48    };
49    let error = if message.is_empty() {
50        status.canonical_reason().unwrap_or("Error").to_owned()
51    } else {
52        message
53    };
54    // Rebuilt through the one envelope builder, then given back every header
55    // the original carried (`Allow`, `Retry-After`, CORS) except the ones
56    // that described the old body.
57    let mut envelope = error_response(status, &code_for_status(status), error);
58    parts.headers.remove(header::CONTENT_LENGTH);
59    parts.headers.remove(header::CONTENT_ENCODING);
60    parts.headers.remove(header::CONTENT_TYPE);
61    envelope.headers_mut().extend(parts.headers);
62    envelope
63}
64
65fn is_json(headers: &HeaderMap) -> bool {
66    headers
67        .get(header::CONTENT_TYPE)
68        .and_then(|value| value.to_str().ok())
69        .is_some_and(|value| value.starts_with("application/json"))
70}
71
72/// The `code` for a status no `ApiError` variant chose. The statuses
73/// `ApiError` also produces keep its spelling (`INTERNAL_ERROR`,
74/// `FILE_TOO_LARGE`, `OVERLOADED`), so a client branching on the code sees one
75/// name per condition whichever layer answered; the rest are the status's
76/// reason phrase in `SCREAMING_SNAKE` (`METHOD_NOT_ALLOWED`, `REQUEST_TIMEOUT`).
77fn code_for_status(status: StatusCode) -> String {
78    match status {
79        StatusCode::INTERNAL_SERVER_ERROR => "INTERNAL_ERROR".to_owned(),
80        StatusCode::PAYLOAD_TOO_LARGE => "FILE_TOO_LARGE".to_owned(),
81        StatusCode::SERVICE_UNAVAILABLE => "OVERLOADED".to_owned(),
82        _ => status
83            .canonical_reason()
84            .map(|reason| {
85                reason
86                    .chars()
87                    .map(|c| if c.is_ascii_alphanumeric() { c.to_ascii_uppercase() } else { '_' })
88                    .collect()
89            })
90            .unwrap_or_else(|| format!("HTTP_{}", status.as_u16())),
91    }
92}
93
94/// The `CatchPanicLayer` response. That layer sits outside this one, so it
95/// renders the envelope itself, as an `INTERNAL_ERROR`. The panic payload is
96/// file-derived text and goes to the log, never to the client.
97pub fn panic_response(panic: Box<dyn std::any::Any + Send + 'static>) -> Response {
98    use axum::response::IntoResponse;
99    let detail = panic
100        .downcast_ref::<String>()
101        .map(String::as_str)
102        .or_else(|| panic.downcast_ref::<&str>().copied())
103        .unwrap_or("non-string panic payload");
104    tracing::error!(panic = %detail, "Request handler panicked");
105    crate::error::ApiError::Internal("request handler panicked".to_owned()).into_response()
106}
107
108#[cfg(test)]
109#[path = "error_envelope_tests.rs"]
110mod error_envelope_tests;