Skip to main content

origin_http/
response.rs

1use crate::headers::RedactedBody;
2use crate::{Headers, RateLimit};
3use origin_domain::{AppError, Result};
4use serde::de::DeserializeOwned;
5use std::fmt;
6use time::OffsetDateTime;
7
8/// One incoming response.
9#[derive(Clone)]
10pub struct HttpResponse {
11    pub status: u16,
12    pub headers: Headers,
13    pub body: Vec<u8>,
14}
15
16/// Redacting `Debug`: a response body can carry a freshly issued access or refresh
17/// token, and a derived `Debug` would print it verbatim into any log line that formats
18/// a response with `?`.
19impl fmt::Debug for HttpResponse {
20    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
21        f.debug_struct("HttpResponse")
22            .field("status", &self.status)
23            .field("headers", &self.headers)
24            .field("body", &RedactedBody(self.body.len()))
25            .finish()
26    }
27}
28
29impl HttpResponse {
30    pub fn new(status: u16, headers: Headers, body: Vec<u8>) -> Self {
31        Self {
32            status,
33            headers,
34            body,
35        }
36    }
37
38    pub fn is_success(&self) -> bool {
39        (200..300).contains(&self.status)
40    }
41
42    pub fn rate_limit(&self, now: OffsetDateTime) -> RateLimit {
43        RateLimit::from_headers(&self.headers, now)
44    }
45
46    pub fn text(&self) -> Result<String> {
47        String::from_utf8(self.body.clone())
48            .map_err(|error| AppError::ExternalService(format!("response is not utf-8: {error}")))
49    }
50
51    pub fn json<T: DeserializeOwned>(&self) -> Result<T> {
52        serde_json::from_slice(&self.body).map_err(|error| {
53            AppError::ExternalService(format!("unexpected response shape: {error}"))
54        })
55    }
56
57    /// Turn a non-2xx response into the matching [`AppError`].
58    ///
59    /// This is the single place where an HTTP status becomes a domain error, so `401`
60    /// and `429` mean the same thing to the UI no matter which service produced them.
61    /// Callers that treat a specific status as normal — `404` for "not found yet",
62    /// `304` for "not modified" — check [`HttpResponse::status`] first.
63    pub fn error_for_status(self, now: OffsetDateTime) -> Result<Self> {
64        if self.is_success() {
65            return Ok(self);
66        }
67
68        let rate_limit = self.rate_limit(now);
69        let detail = self.error_detail();
70
71        Err(match self.status {
72            401 => AppError::Authentication(detail),
73
74            // A 403 with an exhausted budget is a rate limit, not a permission problem.
75            // Getting this wrong sends the user to re-authenticate for no reason.
76            403 if rate_limit.is_exhausted() || rate_limit.retry_after.is_some() => {
77                AppError::RateLimited {
78                    message: detail,
79                    retry_after_seconds: rate_limit
80                        .wait_for(now)
81                        .map(|wait| wait.whole_seconds().max(0) as u64),
82                }
83            }
84            403 => AppError::Permission(detail),
85
86            429 => AppError::RateLimited {
87                message: detail,
88                retry_after_seconds: rate_limit
89                    .wait_for(now)
90                    .map(|wait| wait.whole_seconds().max(0) as u64),
91            },
92
93            400 | 422 => AppError::Validation(detail),
94            status => AppError::ExternalService(format!("http {status}: {detail}")),
95        })
96    }
97
98    /// A short, safe excerpt of the body for the error message.
99    ///
100    /// Truncated because some services answer with an entire HTML error page, and a
101    /// megabyte of markup in a log line helps nobody.
102    fn error_detail(&self) -> String {
103        const MAX: usize = 200;
104
105        let text = String::from_utf8_lossy(&self.body);
106        let trimmed = text.trim();
107
108        if trimmed.is_empty() {
109            return format!("http {}", self.status);
110        }
111
112        if trimmed.len() <= MAX {
113            return trimmed.to_owned();
114        }
115
116        let cut = trimmed
117            .char_indices()
118            .map(|(index, _)| index)
119            .take_while(|index| *index <= MAX)
120            .last()
121            .unwrap_or(0);
122        format!("{}…", &trimmed[..cut])
123    }
124}
125
126#[cfg(test)]
127mod tests {
128    use super::*;
129    use origin_domain::ErrorKind;
130    use time::macros::datetime;
131
132    const NOW: OffsetDateTime = datetime!(2026-08-23 10:00 UTC);
133
134    fn response(status: u16, headers: Headers) -> HttpResponse {
135        HttpResponse::new(status, headers, b"{}".to_vec())
136    }
137
138    #[test]
139    fn debug_output_never_contains_the_body() {
140        let response = HttpResponse::new(
141            200,
142            Headers::new(),
143            b"{\"access_token\":\"secret\"}".to_vec(),
144        );
145
146        let rendered = format!("{response:?}");
147
148        assert!(!rendered.contains("secret"), "got: {rendered}");
149        assert!(rendered.contains("bytes, redacted"), "got: {rendered}");
150    }
151
152    #[test]
153    fn a_success_passes_through() {
154        let response = response(200, Headers::new()).error_for_status(NOW).unwrap();
155        assert_eq!(response.status, 200);
156    }
157
158    #[test]
159    fn unauthorized_becomes_an_authentication_error() {
160        let error = response(401, Headers::new())
161            .error_for_status(NOW)
162            .unwrap_err();
163        assert_eq!(error.kind(), ErrorKind::Authentication);
164    }
165
166    #[test]
167    fn a_forbidden_response_without_budget_left_is_a_rate_limit() {
168        let headers =
169            Headers::from_iter([("x-ratelimit-remaining", "0"), ("x-ratelimit-reset", "120")]);
170
171        let error = response(403, headers).error_for_status(NOW).unwrap_err();
172
173        assert_eq!(error.kind(), ErrorKind::RateLimited);
174        assert_eq!(error.to_contract().retry_after_seconds, Some(120));
175    }
176
177    #[test]
178    fn a_plain_forbidden_response_stays_a_permission_error() {
179        let error = response(403, Headers::new())
180            .error_for_status(NOW)
181            .unwrap_err();
182        assert_eq!(error.kind(), ErrorKind::Permission);
183    }
184
185    #[test]
186    fn too_many_requests_carries_retry_after() {
187        let headers = Headers::from_iter([("retry-after", "30")]);
188        let error = response(429, headers).error_for_status(NOW).unwrap_err();
189
190        assert_eq!(error.kind(), ErrorKind::RateLimited);
191        assert_eq!(error.to_contract().retry_after_seconds, Some(30));
192    }
193
194    #[test]
195    fn server_errors_are_external_service_errors() {
196        let error = response(503, Headers::new())
197            .error_for_status(NOW)
198            .unwrap_err();
199        assert_eq!(error.kind(), ErrorKind::ExternalService);
200        assert!(error.is_retryable());
201    }
202
203    #[test]
204    fn a_huge_error_body_is_truncated() {
205        let response = HttpResponse::new(500, Headers::new(), "x".repeat(10_000).into_bytes());
206
207        let message = response.error_for_status(NOW).unwrap_err().to_string();
208
209        assert!(message.len() < 300, "message was {} chars", message.len());
210        assert!(message.ends_with('…'));
211    }
212}