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#[derive(Clone)]
10pub struct HttpResponse {
11 pub status: u16,
12 pub headers: Headers,
13 pub body: Vec<u8>,
14}
15
16impl 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 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 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 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}