Skip to main content

modkit_auth/
http_error.rs

1/// Format an [`modkit_http::HttpError`] into a human-readable message with a
2/// context prefix.
3///
4/// The prefix identifies the caller context (e.g. `"JWKS"`, `"OAuth2 token"`)
5/// and is prepended to every message so log output is immediately attributable.
6///
7/// This function is the single place that handles the exhaustive (plus
8/// `#[non_exhaustive]` catch-all) match on `HttpError`, shared by JWKS key
9/// fetching, `OAuth2` token acquisition, and any future HTTP-based provider.
10///
11/// # Security
12///
13/// `HttpStatus` errors include only the status code — the response body is
14/// deliberately excluded to prevent server-side diagnostics from leaking
15/// into logs or error messages. The catch-all arm falls back to the
16/// variant's `Display` impl so that new variants are not silently hidden.
17#[must_use]
18pub fn format_http_error(e: &modkit_http::HttpError, prefix: &str) -> String {
19    use modkit_http::HttpError;
20
21    match e {
22        HttpError::HttpStatus { status, .. } => {
23            format!("{prefix} HTTP {status}")
24        }
25        HttpError::Json(err) => format!("{prefix} JSON parse failed: {err}"),
26        HttpError::Timeout(duration) => {
27            format!("{prefix} request timed out after {duration:?}")
28        }
29        HttpError::DeadlineExceeded(duration) => {
30            format!("{prefix} total deadline exceeded after {duration:?}")
31        }
32        HttpError::Transport(err) => format!("{prefix} transport error: {err}"),
33        HttpError::BodyTooLarge { limit, actual } => {
34            format!("{prefix} response too large: limit {limit} bytes, got {actual} bytes")
35        }
36        HttpError::Tls(err) => format!("{prefix} TLS error: {err}"),
37        HttpError::RequestBuild(err) => format!("{prefix} request build failed: {err}"),
38        HttpError::InvalidHeaderName(err) => format!("{prefix} invalid header name: {err}"),
39        HttpError::InvalidHeaderValue(err) => format!("{prefix} invalid header value: {err}"),
40        HttpError::FormEncode(err) => format!("{prefix} form encode error: {err}"),
41        HttpError::Overloaded => format!("{prefix} request rejected: service overloaded"),
42        HttpError::ServiceClosed => format!("{prefix} service unavailable"),
43        HttpError::InvalidUri { url, reason, .. } => {
44            format!("{prefix} invalid URL '{url}': {reason}")
45        }
46        HttpError::InvalidScheme { scheme, reason } => {
47            format!("{prefix} invalid scheme '{scheme}': {reason}")
48        }
49        // Catch-all required because HttpError is #[non_exhaustive].
50        // Include the Display output so new variants surface in logs.
51        other => format!("{prefix} request failed: {other}"),
52    }
53}
54
55#[cfg(test)]
56#[cfg_attr(coverage_nightly, coverage(off))]
57mod tests {
58    use super::*;
59    use std::time::Duration;
60
61    #[test]
62    fn http_status_without_body() {
63        let err = modkit_http::HttpError::HttpStatus {
64            status: http::StatusCode::NOT_FOUND,
65            body_preview: String::new(),
66            content_type: None,
67            retry_after: None,
68        };
69        let msg = format_http_error(&err, "TEST");
70        assert_eq!(msg, "TEST HTTP 404 Not Found");
71    }
72
73    #[test]
74    fn http_status_with_body_excludes_body() {
75        let err = modkit_http::HttpError::HttpStatus {
76            status: http::StatusCode::INTERNAL_SERVER_ERROR,
77            body_preview: "something broke".into(),
78            content_type: None,
79            retry_after: None,
80        };
81        let msg = format_http_error(&err, "JWKS");
82        // body_preview must NOT appear in the output (security)
83        assert_eq!(msg, "JWKS HTTP 500 Internal Server Error");
84        assert!(!msg.contains("something broke"));
85    }
86
87    #[test]
88    fn timeout_error() {
89        let err = modkit_http::HttpError::Timeout(Duration::from_secs(30));
90        let msg = format_http_error(&err, "OAuth2 token");
91        assert_eq!(msg, "OAuth2 token request timed out after 30s");
92    }
93
94    #[test]
95    fn overloaded_error() {
96        let err = modkit_http::HttpError::Overloaded;
97        let msg = format_http_error(&err, "PREFIX");
98        assert_eq!(msg, "PREFIX request rejected: service overloaded");
99    }
100
101    #[test]
102    fn service_closed_error() {
103        let err = modkit_http::HttpError::ServiceClosed;
104        let msg = format_http_error(&err, "PREFIX");
105        assert_eq!(msg, "PREFIX service unavailable");
106    }
107
108    #[test]
109    fn prefix_propagated_to_all_variants() {
110        // Verify the prefix appears in output for a sample of variants
111        let cases: Vec<modkit_http::HttpError> = vec![
112            modkit_http::HttpError::Overloaded,
113            modkit_http::HttpError::ServiceClosed,
114            modkit_http::HttpError::Timeout(Duration::from_secs(1)),
115        ];
116        for err in &cases {
117            let msg = format_http_error(err, "CTX");
118            assert!(msg.starts_with("CTX "), "Expected prefix 'CTX' in: {msg}");
119        }
120    }
121}