1use std::time::{Duration, Instant};
2
3const DEFAULT_RESOLVE_TIMEOUT: Duration = Duration::from_secs(5);
4const DEFAULT_CONNECT_TIMEOUT: Duration = Duration::from_secs(10);
5const DEFAULT_RESPONSE_TIMEOUT: Duration = Duration::from_secs(30);
6const DEGRADED_MULTIPLIER: u32 = 2;
7pub(crate) const MAX_RESPONSE_BODY_BYTES: u64 = 10 * 1024 * 1024;
8
9#[cfg(any(test, feature = "test-support"))]
10#[allow(clippy::unwrap_used)]
11pub mod redirect_support;
12#[cfg(test)]
13pub(crate) mod redirect_tests;
14
15#[derive(Debug, Clone)]
16pub enum HttpOutcome {
17 Success {
18 status: u16,
19 body: String,
20 elapsed_ms: u64,
21 },
22 Timeout {
23 elapsed_ms: u64,
24 phase: String,
25 },
26 NetworkError {
27 message: String,
28 elapsed_ms: u64,
29 },
30 HttpError {
31 status: u16,
32 body: String,
33 elapsed_ms: u64,
34 },
35}
36
37pub struct HardenedClient {
38 agent: ureq::Agent,
39 provider_id: String,
40}
41
42pub fn hardened_agent() -> ureq::Agent {
43 if let Some(remaining) = super::provenance::reuse_time_remaining() {
44 return ureq::config::Config::builder()
45 .tls_config(crate::core::http_client::platform_tls_config())
46 .max_redirects(0)
47 .timeout_global(Some(remaining.max(Duration::from_nanos(1))))
48 .build()
49 .into();
50 }
51 let multiplier = if crate::core::io_health::environment()
52 == crate::core::io_health::IoEnvironment::Degraded
53 {
54 DEGRADED_MULTIPLIER
55 } else {
56 1
57 };
58
59 crate::core::http_client::ureq_agent_without_redirects(
63 Some(DEFAULT_RESOLVE_TIMEOUT * multiplier),
64 Some(DEFAULT_CONNECT_TIMEOUT * multiplier),
65 Some(DEFAULT_RESPONSE_TIMEOUT * multiplier),
66 )
67}
68
69impl HardenedClient {
70 pub fn new(provider_id: &str) -> Self {
71 Self {
72 agent: hardened_agent(),
73 provider_id: provider_id.to_owned(),
74 }
75 }
76
77 pub fn get(&self, url: &str) -> HttpOutcome {
78 let request = self.agent.get(url);
79 self.execute_request(|| request.call())
80 }
81
82 pub fn get_with_headers(&self, url: &str, headers: &[(&str, &str)]) -> HttpOutcome {
83 let mut request = self.agent.get(url);
84 for &(key, value) in headers {
85 request = request.header(key, value);
86 }
87 self.execute_request(|| request.call())
88 }
89
90 pub fn post(&self, url: &str, body: &str) -> HttpOutcome {
91 let request = self.agent.post(url);
92 self.execute_request(|| request.send(body))
93 }
94
95 pub fn post_with_headers(
96 &self,
97 url: &str,
98 body: &str,
99 headers: &[(&str, &str)],
100 ) -> HttpOutcome {
101 let mut request = self.agent.post(url);
102 for &(key, value) in headers {
103 request = request.header(key, value);
104 }
105 self.execute_request(|| request.send(body))
106 }
107
108 fn execute_request<F>(&self, request: F) -> HttpOutcome
109 where
110 F: FnOnce() -> Result<ureq::http::Response<ureq::Body>, ureq::Error>,
111 {
112 let started = Instant::now();
113 let response = match request() {
114 Ok(response) => response,
115 Err(error) => return self.error_outcome(error, elapsed_ms(started)),
116 };
117 let status = response.status().as_u16();
118 if !(200..300).contains(&status) {
119 return HttpOutcome::HttpError {
122 status,
123 body: String::new(),
124 elapsed_ms: elapsed_ms(started),
125 };
126 }
127 let body = match response
128 .into_body()
129 .into_with_config()
130 .limit(MAX_RESPONSE_BODY_BYTES)
131 .read_to_string()
132 {
133 Ok(body) => body,
134 Err(error) => return self.error_outcome(error, elapsed_ms(started)),
135 };
136 let elapsed_ms = elapsed_ms(started);
137
138 HttpOutcome::Success {
139 status,
140 body,
141 elapsed_ms,
142 }
143 }
144
145 fn error_outcome(&self, error: ureq::Error, elapsed_ms: u64) -> HttpOutcome {
146 match error {
147 ureq::Error::Timeout(timeout) => HttpOutcome::Timeout {
148 elapsed_ms,
149 phase: timeout.to_string(),
150 },
151 ureq::Error::StatusCode(status) => HttpOutcome::HttpError {
152 status,
153 body: String::new(),
154 elapsed_ms,
155 },
156 error => HttpOutcome::NetworkError {
157 message: format!("{}: {error}", self.provider_id),
158 elapsed_ms,
159 },
160 }
161 }
162}
163
164impl HttpOutcome {
165 pub fn into_body(self) -> Result<String, String> {
166 match self {
167 Self::Success { body, .. } => Ok(body),
168 Self::Timeout { phase, .. } => Err(format!("timeout during {phase}")),
169 Self::NetworkError { message, .. } => Err(message),
170 Self::HttpError { status, body, .. } => Err(format!("HTTP {status}: {body}")),
171 }
172 }
173
174 pub fn is_success(&self) -> bool {
175 matches!(self, Self::Success { .. })
176 }
177
178 pub fn body(&self) -> Option<&str> {
179 match self {
180 Self::Success { body, .. } => Some(body),
181 _ => None,
182 }
183 }
184
185 pub fn elapsed_ms(&self) -> u64 {
186 match self {
187 Self::Success { elapsed_ms, .. }
188 | Self::Timeout { elapsed_ms, .. }
189 | Self::NetworkError { elapsed_ms, .. }
190 | Self::HttpError { elapsed_ms, .. } => *elapsed_ms,
191 }
192 }
193
194 pub fn into_result(self) -> Result<String, String> {
195 match self {
196 Self::Success { body, .. } => Ok(body),
197 Self::Timeout { phase, .. } => Err(format!("HTTP request timed out during {phase}")),
198 Self::NetworkError { message, .. } => Err(message),
199 Self::HttpError { status, body, .. } => {
200 if body.is_empty() {
201 Err(format!("HTTP request failed with status {status}"))
202 } else {
203 Err(format!("HTTP request failed with status {status}: {body}"))
204 }
205 }
206 }
207 }
208}
209
210pub fn provider_get(provider_id: &str, url: &str) -> HttpOutcome {
211 HardenedClient::new(provider_id).get(url)
212}
213
214pub fn provider_get_with_headers(
215 provider_id: &str,
216 url: &str,
217 headers: &[(&str, &str)],
218) -> HttpOutcome {
219 HardenedClient::new(provider_id).get_with_headers(url, headers)
220}
221
222pub fn provider_post(provider_id: &str, url: &str, body: &str) -> HttpOutcome {
223 HardenedClient::new(provider_id).post(url, body)
224}
225
226fn elapsed_ms(started: Instant) -> u64 {
227 u64::try_from(started.elapsed().as_millis()).unwrap_or(u64::MAX)
228}
229
230#[cfg(test)]
231mod tests {
232 use super::{
233 DEFAULT_CONNECT_TIMEOUT, DEFAULT_RESOLVE_TIMEOUT, DEFAULT_RESPONSE_TIMEOUT,
234 DEGRADED_MULTIPLIER, HardenedClient, HttpOutcome, hardened_agent,
235 };
236 use std::io::{Read, Write};
237 use std::net::TcpListener;
238 use std::sync::mpsc::{self, Receiver};
239 use std::time::Duration;
240
241 #[test]
242 fn test_hardened_agent_creates_agent() {
243 let _agent = hardened_agent();
244 }
245
246 #[test]
247 fn test_http_outcome_success_is_success() {
248 let outcome = success_outcome(12);
249 assert!(outcome.is_success());
250 }
251
252 #[test]
253 fn test_http_outcome_timeout_not_success() {
254 let outcome = timeout_outcome(12);
255 assert!(!outcome.is_success());
256 }
257
258 #[test]
259 fn test_http_outcome_network_error_not_success() {
260 let outcome = HttpOutcome::NetworkError {
261 message: "offline".to_owned(),
262 elapsed_ms: 12,
263 };
264 assert!(!outcome.is_success());
265 }
266
267 #[test]
268 fn test_http_outcome_into_result_success() {
269 assert_eq!(success_outcome(12).into_result(), Ok("payload".to_owned()));
270 }
271
272 #[test]
273 fn test_http_outcome_into_result_timeout() {
274 let error = timeout_outcome(12)
275 .into_result()
276 .expect_err("timeout expected");
277 assert!(error.contains("timed out"));
278 assert!(error.contains("connect"));
279 }
280
281 #[test]
282 fn test_http_outcome_into_body_variants() {
283 assert_eq!(success_outcome(12).into_body(), Ok("payload".to_owned()));
284 assert_eq!(
285 timeout_outcome(12).into_body(),
286 Err("timeout during connect".to_owned())
287 );
288 assert_eq!(
289 HttpOutcome::NetworkError {
290 message: "offline".to_owned(),
291 elapsed_ms: 12,
292 }
293 .into_body(),
294 Err("offline".to_owned())
295 );
296 assert_eq!(
297 HttpOutcome::HttpError {
298 status: 503,
299 body: "unavailable".to_owned(),
300 elapsed_ms: 12,
301 }
302 .into_body(),
303 Err("HTTP 503: unavailable".to_owned())
304 );
305 }
306
307 #[test]
308 fn test_get_with_headers() {
309 let (get_url, get_request) = serve_once();
310 let client = HardenedClient::new("test");
311 assert!(
312 client
313 .get_with_headers(&get_url, &[("X-Test-Header", "get-value")])
314 .is_success()
315 );
316 assert!(
317 get_request
318 .recv()
319 .expect("GET request expected")
320 .to_ascii_lowercase()
321 .contains("x-test-header: get-value")
322 );
323 }
324
325 #[test]
326 fn test_http_outcome_body_extraction() {
327 let success = success_outcome(12);
328 let error = timeout_outcome(12);
329 assert_eq!(success.body(), Some("payload"));
330 assert_eq!(error.body(), None);
331 }
332
333 #[test]
334 fn test_http_outcome_elapsed_tracking() {
335 let outcomes = [
336 success_outcome(1),
337 timeout_outcome(2),
338 HttpOutcome::NetworkError {
339 message: "offline".to_owned(),
340 elapsed_ms: 3,
341 },
342 HttpOutcome::HttpError {
343 status: 503,
344 body: "unavailable".to_owned(),
345 elapsed_ms: 4,
346 },
347 ];
348 let elapsed: Vec<u64> = outcomes.iter().map(HttpOutcome::elapsed_ms).collect();
349 assert_eq!(elapsed, [1, 2, 3, 4]);
350 }
351
352 #[test]
353 fn test_degraded_multiplier_value() {
354 assert_eq!(DEGRADED_MULTIPLIER, 2);
355 }
356
357 #[test]
358 fn test_default_timeouts_reasonable() {
359 assert_eq!(DEFAULT_RESOLVE_TIMEOUT, Duration::from_secs(5));
360 assert_eq!(DEFAULT_CONNECT_TIMEOUT, Duration::from_secs(10));
361 assert_eq!(DEFAULT_RESPONSE_TIMEOUT, Duration::from_secs(30));
362 }
363
364 fn success_outcome(elapsed_ms: u64) -> HttpOutcome {
365 HttpOutcome::Success {
366 status: 200,
367 body: "payload".to_owned(),
368 elapsed_ms,
369 }
370 }
371
372 fn timeout_outcome(elapsed_ms: u64) -> HttpOutcome {
373 HttpOutcome::Timeout {
374 elapsed_ms,
375 phase: "connect".to_owned(),
376 }
377 }
378
379 fn serve_once() -> (String, Receiver<String>) {
380 let listener = TcpListener::bind("127.0.0.1:0").expect("test listener should bind");
381 let address = listener
382 .local_addr()
383 .expect("test listener address should exist");
384 let (sender, receiver) = mpsc::channel();
385 std::thread::spawn(move || {
386 let (mut stream, _) = listener.accept().expect("request should connect");
387 let mut buffer = [0_u8; 4096];
388 let length = stream
389 .read(&mut buffer)
390 .expect("request should be readable");
391 sender
392 .send(String::from_utf8_lossy(&buffer[..length]).into_owned())
393 .expect("request should be received");
394 stream
395 .write_all(b"HTTP/1.1 200 OK\r\nContent-Length: 2\r\nConnection: close\r\n\r\nok")
396 .expect("response should be writable");
397 });
398 (format!("http://{address}/test"), receiver)
399 }
400}