odos_sdk/
client.rs

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/// Configuration for the HTTP client
11#[derive(Debug, Clone)]
12pub struct ClientConfig {
13    /// Request timeout duration
14    pub timeout: Duration,
15    /// Connection timeout duration
16    pub connect_timeout: Duration,
17    /// Maximum number of retry attempts
18    pub max_retries: u32,
19    /// Initial retry delay
20    pub initial_retry_delay: Duration,
21    /// Maximum retry delay
22    pub max_retry_delay: Duration,
23    /// Maximum concurrent connections
24    pub max_connections: usize,
25    /// Connection pool idle timeout
26    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/// Enhanced HTTP client with retry logic and timeouts
44#[derive(Debug, Clone)]
45pub struct OdosHttpClient {
46    client: Client,
47    config: ClientConfig,
48}
49
50impl OdosHttpClient {
51    /// Create a new HTTP client with default configuration
52    pub fn new() -> Result<Self> {
53        Self::with_config(ClientConfig::default())
54    }
55
56    /// Create a new HTTP client with custom configuration
57    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    /// Execute a request with retry logic
70    #[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            // Check if we've exhausted retries
162            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    /// Get a reference to the underlying reqwest client
176    pub fn inner(&self) -> &Client {
177        &self.client
178    }
179
180    /// Get the client configuration
181    pub fn config(&self) -> &ClientConfig {
182        &self.config
183    }
184}
185
186/// Extract the retry-after header from the response
187fn 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    /// Helper to create a mock that returns different responses based on attempt count
213    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    /// Helper to create a mock with retry-after header
232    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    /// Helper to create a test client with custom config
254    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        // Should have waited at least 1 second (from Retry-After header)
317        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        // Returns 400 Bad Request (non-retryable)
350        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        // Should fail immediately without retrying
368        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        // Always returns 503 Service Unavailable (retryable)
379        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        // Should fail after exhausting retries
398        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        // Delays response longer than timeout
411        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        // Should fail with timeout error (could be either Http timeout or our Timeout wrapper)
434        assert!(response.is_err());
435        if let Err(e) = response {
436            // Accept either OdosError::Http with timeout or OdosError::Timeout
437            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        // Create a request builder that will fail on .build()
448        // Use an absurdly long header name that will fail validation
449        let bad_builder = || {
450            let mut builder = client.inner().get("http://localhost");
451            // Add an invalid header that will cause build to fail
452            builder = builder.header("x".repeat(100000), "value");
453            builder
454        };
455
456        let result = client.execute_with_retry(bad_builder).await;
457
458        // Should fail immediately without retrying
459        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        // Test with an invalid URL that will cause a connection error
544        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        // Should fail after retries
557        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        // Test config() accessor
573        assert_eq!(client.config().timeout, Duration::from_secs(45));
574        assert_eq!(client.config().max_retries, 5);
575
576        // Test inner() accessor - just verify it returns a Client
577        let _inner: &reqwest::Client = client.inner();
578    }
579
580    #[test]
581    fn test_default_client() {
582        let client = OdosHttpClient::default();
583
584        // Should use default config
585        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 that would normally trigger retries
673        Mock::given(method("GET"))
674            .and(path("/test"))
675            .respond_with(ResponseTemplate::new(500).set_body_string("Server error"))
676            .expect(1) // Should only be called once
677            .mount(&mock_server)
678            .await;
679
680        let client = create_test_client(0, 30000); // max_retries = 0
681        let response = client
682            .execute_with_retry(|| client.inner().get(format!("{}/test", mock_server.uri())))
683            .await;
684
685        // Should fail immediately without retrying
686        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        // Test creating a client with an invalid configuration
697        // Using an extremely high connection limit that might cause issues
698        let config = ClientConfig {
699            max_connections: usize::MAX,
700            ..Default::default()
701        };
702
703        // This might not actually fail with reqwest, but we test the error handling path
704        let result = OdosHttpClient::with_config(config);
705
706        // If it succeeds, that's fine - reqwest is quite permissive
707        // If it fails, we verify proper error wrapping
708        match result {
709            Ok(_) => {
710                // Client creation succeeded - this is actually normal
711            }
712            Err(e) => {
713                // If it fails, should be wrapped as Http error
714                assert!(matches!(e, OdosError::Http(_)));
715            }
716        }
717    }
718}