1use std::time::Duration;
2
3use backoff::{backoff::Backoff, ExponentialBackoff};
4use reqwest::{Client, RequestBuilder, Response, StatusCode};
5use tokio::time::timeout;
6use tracing::{debug, instrument, warn};
7
8use crate::error::{OdosError, Result};
9
10#[derive(Debug, Clone)]
12pub struct ClientConfig {
13 pub timeout: Duration,
15 pub connect_timeout: Duration,
17 pub max_retries: u32,
19 pub initial_retry_delay: Duration,
21 pub max_retry_delay: Duration,
23 pub max_connections: usize,
25 pub pool_idle_timeout: Duration,
27}
28
29impl Default for ClientConfig {
30 fn default() -> Self {
31 Self {
32 timeout: Duration::from_secs(30),
33 connect_timeout: Duration::from_secs(10),
34 max_retries: 3,
35 initial_retry_delay: Duration::from_millis(100),
36 max_retry_delay: Duration::from_secs(5),
37 max_connections: 20,
38 pool_idle_timeout: Duration::from_secs(90),
39 }
40 }
41}
42
43#[derive(Debug, Clone)]
45pub struct OdosHttpClient {
46 client: Client,
47 config: ClientConfig,
48}
49
50impl OdosHttpClient {
51 pub fn new() -> Result<Self> {
53 Self::with_config(ClientConfig::default())
54 }
55
56 pub fn with_config(config: ClientConfig) -> Result<Self> {
58 let client = Client::builder()
59 .timeout(config.timeout)
60 .connect_timeout(config.connect_timeout)
61 .pool_max_idle_per_host(config.max_connections)
62 .pool_idle_timeout(config.pool_idle_timeout)
63 .build()
64 .map_err(OdosError::Http)?;
65
66 Ok(Self { client, config })
67 }
68
69 #[instrument(skip(self, request_builder_fn), level = "debug")]
71 pub async fn execute_with_retry<F>(&self, request_builder_fn: F) -> Result<Response>
72 where
73 F: Fn() -> RequestBuilder + Clone,
74 {
75 let mut backoff = ExponentialBackoff {
76 initial_interval: self.config.initial_retry_delay,
77 max_interval: self.config.max_retry_delay,
78 max_elapsed_time: Some(self.config.timeout),
79 ..Default::default()
80 };
81
82 let mut attempt = 0;
83
84 loop {
85 attempt += 1;
86 debug!(attempt, "Executing HTTP request");
87
88 let request = match request_builder_fn().build() {
89 Ok(req) => req,
90 Err(e) => return Err(OdosError::Http(e)),
91 };
92
93 let last_error = match timeout(self.config.timeout, self.client.execute(request)).await
94 {
95 Ok(Ok(response)) if response.status().is_success() => {
96 debug!(attempt, status = %response.status(), "Request successful");
97 return Ok(response);
98 }
99 Ok(Ok(response)) => {
100 let status = response.status();
101
102 if status == StatusCode::TOO_MANY_REQUESTS {
103 let retry_after = extract_retry_after(&response);
104
105 let body = response
106 .text()
107 .await
108 .unwrap_or_else(|_| "Unknown error".to_string());
109
110 let error = OdosError::rate_limit_error(body);
111
112 if !error.is_retryable() {
113 return Err(error);
114 }
115
116 warn!(
117 attempt,
118 status = %status,
119 retry_after_secs = ?retry_after.map(|d| d.as_secs()),
120 "Rate limit exceeded (429), will retry after delay"
121 );
122
123 if let Some(delay) = retry_after {
124 debug!(?delay, "Respecting Retry-After header");
125 tokio::time::sleep(delay).await;
126 continue;
127 }
128 error
129 } else {
130 let body = response
131 .text()
132 .await
133 .unwrap_or_else(|_| "Unknown error".to_string());
134
135 let error = OdosError::api_error(status, body);
136
137 if !error.is_retryable() {
138 return Err(error);
139 }
140
141 warn!(attempt, status = %status, "Retryable API error, retrying");
142 error
143 }
144 }
145 Ok(Err(e)) => {
146 let error = OdosError::Http(e);
147
148 if !error.is_retryable() {
149 return Err(error);
150 }
151 warn!(attempt, error = %error, "Retryable HTTP error, retrying");
152 error
153 }
154 Err(_) => {
155 let error = OdosError::timeout_error("Request timed out");
156 warn!(attempt, timeout = ?self.config.timeout, "Request timed out, retrying");
157 error
158 }
159 };
160
161 if attempt >= self.config.max_retries {
163 return Err(last_error);
164 }
165
166 if let Some(delay) = backoff.next_backoff() {
167 debug!(?delay, "Waiting before retry");
168 tokio::time::sleep(delay).await;
169 } else {
170 return Err(last_error);
171 }
172 }
173 }
174
175 pub fn inner(&self) -> &Client {
177 &self.client
178 }
179
180 pub fn config(&self) -> &ClientConfig {
182 &self.config
183 }
184}
185
186fn extract_retry_after(response: &Response) -> Option<Duration> {
188 response
189 .headers()
190 .get("retry-after")
191 .and_then(|v| v.to_str().ok())
192 .and_then(|s| s.parse::<u64>().ok())
193 .map(Duration::from_secs)
194}
195
196impl Default for OdosHttpClient {
197 fn default() -> Self {
198 Self::new().expect("Failed to create default HTTP client")
199 }
200}
201
202#[cfg(test)]
203mod tests {
204 use super::*;
205 use std::sync::{Arc, Mutex};
206 use std::time::Duration;
207 use wiremock::{
208 matchers::{method, path},
209 Mock, MockServer, Request, ResponseTemplate,
210 };
211
212 fn create_retry_mock(
214 first_status: u16,
215 first_body: String,
216 success_after: usize,
217 ) -> impl Fn(&Request) -> ResponseTemplate {
218 let attempt_count = Arc::new(Mutex::new(0));
219 move |_req: &Request| {
220 let mut count = attempt_count.lock().unwrap();
221 *count += 1;
222
223 if *count < success_after {
224 ResponseTemplate::new(first_status).set_body_string(&first_body)
225 } else {
226 ResponseTemplate::new(200).set_body_string("Success")
227 }
228 }
229 }
230
231 fn create_rate_limit_mock(
233 retry_after_secs: Option<u64>,
234 ) -> impl Fn(&Request) -> ResponseTemplate {
235 let attempt_count = Arc::new(Mutex::new(0));
236 move |_req: &Request| {
237 let mut count = attempt_count.lock().unwrap();
238 *count += 1;
239
240 if *count == 1 {
241 let mut response =
242 ResponseTemplate::new(429).set_body_string("Rate limit exceeded");
243 if let Some(secs) = retry_after_secs {
244 response = response.insert_header("retry-after", secs.to_string());
245 }
246 response
247 } else {
248 ResponseTemplate::new(200).set_body_string("Success")
249 }
250 }
251 }
252
253 fn create_test_client(max_retries: u32, timeout_ms: u64) -> OdosHttpClient {
255 let config = ClientConfig {
256 max_retries,
257 timeout: Duration::from_millis(timeout_ms),
258 initial_retry_delay: Duration::from_millis(10),
259 max_retry_delay: Duration::from_millis(50),
260 ..Default::default()
261 };
262 OdosHttpClient::with_config(config).unwrap()
263 }
264
265 #[test]
266 fn test_client_config_default() {
267 let config = ClientConfig::default();
268 assert_eq!(config.timeout, Duration::from_secs(30));
269 assert_eq!(config.max_retries, 3);
270 assert_eq!(config.max_connections, 20);
271 }
272
273 #[tokio::test]
274 async fn test_client_creation() {
275 let client = OdosHttpClient::new();
276 assert!(client.is_ok());
277 }
278
279 #[tokio::test]
280 async fn test_client_with_custom_config() {
281 let config = ClientConfig {
282 timeout: Duration::from_secs(60),
283 max_retries: 5,
284 ..Default::default()
285 };
286 let client = OdosHttpClient::with_config(config.clone());
287 assert!(client.is_ok());
288
289 let client = client.unwrap();
290 assert_eq!(client.config().timeout, Duration::from_secs(60));
291 assert_eq!(client.config().max_retries, 5);
292 }
293
294 #[tokio::test]
295 async fn test_rate_limit_with_retry_after() {
296 let mock_server = MockServer::start().await;
297
298 Mock::given(method("GET"))
299 .and(path("/test"))
300 .respond_with(create_rate_limit_mock(Some(1)))
301 .mount(&mock_server)
302 .await;
303
304 let client = create_test_client(3, 30000);
305 let start = std::time::Instant::now();
306
307 let response = client
308 .execute_with_retry(|| client.inner().get(format!("{}/test", mock_server.uri())))
309 .await;
310
311 assert!(
312 response.is_ok(),
313 "Request should succeed after retry, but got: {response:?}"
314 );
315
316 let elapsed = start.elapsed();
318 assert!(
319 elapsed >= Duration::from_millis(900),
320 "Should respect Retry-After header, elapsed: {elapsed:?}"
321 );
322 }
323
324 #[tokio::test]
325 async fn test_rate_limit_without_retry_after() {
326 let mock_server = MockServer::start().await;
327
328 Mock::given(method("GET"))
329 .and(path("/test"))
330 .respond_with(create_rate_limit_mock(None))
331 .mount(&mock_server)
332 .await;
333
334 let client = create_test_client(3, 30000);
335 let response = client
336 .execute_with_retry(|| client.inner().get(format!("{}/test", mock_server.uri())))
337 .await;
338
339 assert!(
340 response.is_ok(),
341 "Request should succeed after retry, but got: {response:?}"
342 );
343 }
344
345 #[tokio::test]
346 async fn test_non_retryable_error() {
347 let mock_server = MockServer::start().await;
348
349 Mock::given(method("GET"))
351 .and(path("/test"))
352 .respond_with(ResponseTemplate::new(400).set_body_string("Bad request"))
353 .expect(1)
354 .mount(&mock_server)
355 .await;
356
357 let config = ClientConfig {
358 max_retries: 3,
359 ..Default::default()
360 };
361 let client = OdosHttpClient::with_config(config).unwrap();
362
363 let response = client
364 .execute_with_retry(|| client.inner().get(format!("{}/test", mock_server.uri())))
365 .await;
366
367 assert!(response.is_err());
369 if let Err(e) = response {
370 assert!(!e.is_retryable());
371 }
372 }
373
374 #[tokio::test]
375 async fn test_retry_exhaustion_returns_last_error() {
376 let mock_server = MockServer::start().await;
377
378 Mock::given(method("GET"))
380 .and(path("/test"))
381 .respond_with(ResponseTemplate::new(503).set_body_string("Service unavailable"))
382 .mount(&mock_server)
383 .await;
384
385 let config = ClientConfig {
386 max_retries: 2,
387 initial_retry_delay: Duration::from_millis(10),
388 max_retry_delay: Duration::from_millis(50),
389 ..Default::default()
390 };
391 let client = OdosHttpClient::with_config(config).unwrap();
392
393 let response = client
394 .execute_with_retry(|| client.inner().get(format!("{}/test", mock_server.uri())))
395 .await;
396
397 assert!(response.is_err());
399 if let Err(e) = response {
400 assert!(
401 matches!(e, OdosError::Api { status, .. } if status == StatusCode::SERVICE_UNAVAILABLE)
402 );
403 }
404 }
405
406 #[tokio::test]
407 async fn test_timeout_error() {
408 let mock_server = MockServer::start().await;
409
410 Mock::given(method("GET"))
412 .and(path("/test"))
413 .respond_with(
414 ResponseTemplate::new(200)
415 .set_body_string("Success")
416 .set_delay(Duration::from_secs(5)),
417 )
418 .mount(&mock_server)
419 .await;
420
421 let config = ClientConfig {
422 timeout: Duration::from_millis(100),
423 max_retries: 2,
424 initial_retry_delay: Duration::from_millis(10),
425 ..Default::default()
426 };
427 let client = OdosHttpClient::with_config(config).unwrap();
428
429 let response = client
430 .execute_with_retry(|| client.inner().get(format!("{}/test", mock_server.uri())))
431 .await;
432
433 assert!(response.is_err());
435 if let Err(e) = response {
436 let is_timeout = matches!(e, OdosError::Timeout(_))
438 || matches!(e, OdosError::Http(ref err) if err.is_timeout());
439 assert!(is_timeout, "Expected timeout error, got: {e:?}");
440 }
441 }
442
443 #[tokio::test]
444 async fn test_invalid_request_builder_fails_immediately() {
445 let client = OdosHttpClient::default();
446
447 let bad_builder = || {
450 let mut builder = client.inner().get("http://localhost");
451 builder = builder.header("x".repeat(100000), "value");
453 builder
454 };
455
456 let result = client.execute_with_retry(bad_builder).await;
457
458 assert!(result.is_err());
460 if let Err(e) = result {
461 assert!(matches!(e, OdosError::Http(_)));
462 }
463 }
464
465 #[tokio::test]
466 async fn test_retryable_500_error() {
467 let mock_server = MockServer::start().await;
468
469 Mock::given(method("GET"))
470 .and(path("/test"))
471 .respond_with(create_retry_mock(
472 500,
473 "Internal server error".to_string(),
474 2,
475 ))
476 .mount(&mock_server)
477 .await;
478
479 let client = create_test_client(3, 30000);
480 let response = client
481 .execute_with_retry(|| client.inner().get(format!("{}/test", mock_server.uri())))
482 .await;
483
484 assert!(response.is_ok(), "500 error should be retried and succeed");
485 }
486
487 #[tokio::test]
488 async fn test_retryable_502_bad_gateway() {
489 let mock_server = MockServer::start().await;
490
491 Mock::given(method("GET"))
492 .and(path("/test"))
493 .respond_with(create_retry_mock(502, "Bad gateway".to_string(), 2))
494 .mount(&mock_server)
495 .await;
496
497 let client = create_test_client(3, 30000);
498 let response = client
499 .execute_with_retry(|| client.inner().get(format!("{}/test", mock_server.uri())))
500 .await;
501
502 assert!(response.is_ok(), "502 error should be retried and succeed");
503 }
504
505 #[tokio::test]
506 async fn test_retryable_503_service_unavailable() {
507 let mock_server = MockServer::start().await;
508
509 Mock::given(method("GET"))
510 .and(path("/test"))
511 .respond_with(create_retry_mock(503, "Service unavailable".to_string(), 3))
512 .mount(&mock_server)
513 .await;
514
515 let client = create_test_client(3, 30000);
516 let response = client
517 .execute_with_retry(|| client.inner().get(format!("{}/test", mock_server.uri())))
518 .await;
519
520 assert!(response.is_ok(), "503 error should be retried and succeed");
521 }
522
523 #[tokio::test]
524 async fn test_retryable_504_gateway_timeout() {
525 let mock_server = MockServer::start().await;
526
527 Mock::given(method("GET"))
528 .and(path("/test"))
529 .respond_with(create_retry_mock(504, "Gateway timeout".to_string(), 2))
530 .mount(&mock_server)
531 .await;
532
533 let client = create_test_client(3, 30000);
534 let response = client
535 .execute_with_retry(|| client.inner().get(format!("{}/test", mock_server.uri())))
536 .await;
537
538 assert!(response.is_ok(), "504 error should be retried and succeed");
539 }
540
541 #[tokio::test]
542 async fn test_network_error_retryable() {
543 let config = ClientConfig {
545 max_retries: 2,
546 initial_retry_delay: Duration::from_millis(10),
547 timeout: Duration::from_millis(100),
548 ..Default::default()
549 };
550 let client = OdosHttpClient::with_config(config).unwrap();
551
552 let response = client
553 .execute_with_retry(|| client.inner().get("http://localhost:1"))
554 .await;
555
556 assert!(response.is_err());
558 if let Err(e) = response {
559 assert!(matches!(e, OdosError::Http(_)));
560 }
561 }
562
563 #[test]
564 fn test_accessor_methods() {
565 let config = ClientConfig {
566 timeout: Duration::from_secs(45),
567 max_retries: 5,
568 ..Default::default()
569 };
570 let client = OdosHttpClient::with_config(config.clone()).unwrap();
571
572 assert_eq!(client.config().timeout, Duration::from_secs(45));
574 assert_eq!(client.config().max_retries, 5);
575
576 let _inner: &reqwest::Client = client.inner();
578 }
579
580 #[test]
581 fn test_default_client() {
582 let client = OdosHttpClient::default();
583
584 assert_eq!(client.config().timeout, Duration::from_secs(30));
586 assert_eq!(client.config().max_retries, 3);
587 }
588
589 #[test]
590 fn test_extract_retry_after_valid_numeric() {
591 let response = reqwest::Response::from(
592 http::Response::builder()
593 .status(429)
594 .header("retry-after", "30")
595 .body("")
596 .unwrap(),
597 );
598
599 let retry_after = extract_retry_after(&response);
600 assert_eq!(retry_after, Some(Duration::from_secs(30)));
601 }
602
603 #[test]
604 fn test_extract_retry_after_missing_header() {
605 let response =
606 reqwest::Response::from(http::Response::builder().status(429).body("").unwrap());
607
608 let retry_after = extract_retry_after(&response);
609 assert_eq!(retry_after, None);
610 }
611
612 #[test]
613 fn test_extract_retry_after_malformed_value() {
614 let response = reqwest::Response::from(
615 http::Response::builder()
616 .status(429)
617 .header("retry-after", "not-a-number")
618 .body("")
619 .unwrap(),
620 );
621
622 let retry_after = extract_retry_after(&response);
623 assert_eq!(retry_after, None);
624 }
625
626 #[test]
627 fn test_extract_retry_after_zero_value() {
628 let response = reqwest::Response::from(
629 http::Response::builder()
630 .status(429)
631 .header("retry-after", "0")
632 .body("")
633 .unwrap(),
634 );
635
636 let retry_after = extract_retry_after(&response);
637 assert_eq!(retry_after, Some(Duration::from_secs(0)));
638 }
639
640 #[test]
641 fn test_extract_retry_after_large_value() {
642 let response = reqwest::Response::from(
643 http::Response::builder()
644 .status(429)
645 .header("retry-after", "3600")
646 .body("")
647 .unwrap(),
648 );
649
650 let retry_after = extract_retry_after(&response);
651 assert_eq!(retry_after, Some(Duration::from_secs(3600)));
652 }
653
654 #[test]
655 fn test_extract_retry_after_invalid_utf8() {
656 let response = reqwest::Response::from(
657 http::Response::builder()
658 .status(429)
659 .header("retry-after", vec![0xff, 0xfe])
660 .body("")
661 .unwrap(),
662 );
663
664 let retry_after = extract_retry_after(&response);
665 assert_eq!(retry_after, None);
666 }
667
668 #[tokio::test]
669 async fn test_max_retries_zero() {
670 let mock_server = MockServer::start().await;
671
672 Mock::given(method("GET"))
674 .and(path("/test"))
675 .respond_with(ResponseTemplate::new(500).set_body_string("Server error"))
676 .expect(1) .mount(&mock_server)
678 .await;
679
680 let client = create_test_client(0, 30000); let response = client
682 .execute_with_retry(|| client.inner().get(format!("{}/test", mock_server.uri())))
683 .await;
684
685 assert!(response.is_err());
687 if let Err(e) = response {
688 assert!(
689 matches!(e, OdosError::Api { status, .. } if status == StatusCode::INTERNAL_SERVER_ERROR)
690 );
691 }
692 }
693
694 #[tokio::test]
695 async fn test_client_config_failure() {
696 let config = ClientConfig {
699 max_connections: usize::MAX,
700 ..Default::default()
701 };
702
703 let result = OdosHttpClient::with_config(config);
705
706 match result {
709 Ok(_) => {
710 }
712 Err(e) => {
713 assert!(matches!(e, OdosError::Http(_)));
715 }
716 }
717 }
718}