odos_sdk/
client.rs

1use std::time::Duration;
2
3use backoff::{backoff::Backoff, ExponentialBackoff};
4use reqwest::{Client, RequestBuilder, Response, StatusCode, Url};
5use tokio::time::timeout;
6use tracing::{debug, instrument};
7
8use crate::{
9    api::OdosApiErrorResponse,
10    api_key::ApiKey,
11    error::{OdosError, Result},
12    error_code::OdosErrorCode,
13};
14
15/// Configuration for retry behavior
16///
17/// Controls which errors should be retried and how retries are executed.
18///
19/// # Examples
20///
21/// ```rust
22/// use odos_sdk::RetryConfig;
23///
24/// // No retries - all errors return immediately
25/// let config = RetryConfig::no_retries();
26///
27/// // Conservative retries - only network errors
28/// let config = RetryConfig::conservative();
29///
30/// // Default retries - network errors and server errors
31/// let config = RetryConfig::default();
32///
33/// // Custom retry logic
34/// let config = RetryConfig {
35///     max_retries: 2,
36///     retry_server_errors: false,
37///     retry_predicate: Some(|err| {
38///         // Custom logic to determine if error should be retried
39///         err.is_retryable()
40///     }),
41///     ..Default::default()
42/// };
43/// ```
44#[derive(Debug, Clone)]
45pub struct RetryConfig {
46    /// Maximum retry attempts for retryable errors
47    pub max_retries: u32,
48
49    /// Initial backoff duration in milliseconds
50    pub initial_backoff_ms: u64,
51
52    /// Whether to retry server errors (5xx)
53    pub retry_server_errors: bool,
54
55    /// Custom retry predicate (advanced use)
56    ///
57    /// When provided, this function overrides the default retry logic.
58    /// Return `true` to retry the error, `false` to return it immediately.
59    pub retry_predicate: Option<fn(&OdosError) -> bool>,
60}
61
62impl Default for RetryConfig {
63    fn default() -> Self {
64        Self {
65            max_retries: 3,
66            initial_backoff_ms: 100,
67            retry_server_errors: true,
68            retry_predicate: None,
69        }
70    }
71}
72
73impl RetryConfig {
74    /// No retries - return errors immediately
75    ///
76    /// Use this when you want to handle all errors at the application level,
77    /// or when implementing your own retry logic.
78    pub fn no_retries() -> Self {
79        Self {
80            max_retries: 0,
81            ..Default::default()
82        }
83    }
84
85    /// Conservative retries - only network errors
86    ///
87    /// This configuration retries only transient network failures
88    /// (timeouts, connection errors) but not server errors (5xx).
89    /// Use this when you want to be cautious about retry behavior.
90    pub fn conservative() -> Self {
91        Self {
92            max_retries: 2,
93            retry_server_errors: false,
94            ..Default::default()
95        }
96    }
97}
98
99/// Configuration for the HTTP client
100///
101/// Combines connection settings with retry behavior configuration.
102#[derive(Clone)]
103pub struct ClientConfig {
104    /// Request timeout duration
105    pub timeout: Duration,
106    /// Connection timeout duration
107    pub connect_timeout: Duration,
108    /// Retry behavior configuration
109    pub retry_config: RetryConfig,
110    /// Maximum concurrent connections
111    pub max_connections: usize,
112    /// Connection pool idle timeout
113    pub pool_idle_timeout: Duration,
114    /// Optional API key for authenticated requests
115    pub api_key: Option<ApiKey>,
116    /// URL for the Odos quote API endpoint
117    pub quote_url: Url,
118    /// URL for the Odos assemble API endpoint
119    pub assemble_url: Url,
120}
121
122impl Default for ClientConfig {
123    fn default() -> Self {
124        Self {
125            timeout: Duration::from_secs(30),
126            connect_timeout: Duration::from_secs(10),
127            retry_config: RetryConfig::default(),
128            max_connections: 20,
129            pool_idle_timeout: Duration::from_secs(90),
130            api_key: None,
131            quote_url: Url::parse("https://api.odos.xyz/sor/quote/v2")
132                .expect("Invalid default quote URL"),
133            assemble_url: Url::parse("https://api.odos.xyz/sor/assemble")
134                .expect("Invalid default assemble URL"),
135        }
136    }
137}
138
139impl std::fmt::Debug for ClientConfig {
140    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
141        f.debug_struct("ClientConfig")
142            .field("timeout", &self.timeout)
143            .field("connect_timeout", &self.connect_timeout)
144            .field("retry_config", &self.retry_config)
145            .field("max_connections", &self.max_connections)
146            .field("pool_idle_timeout", &self.pool_idle_timeout)
147            .field("api_key", &self.api_key)
148            .field("quote_url", &self.quote_url)
149            .field("assemble_url", &self.assemble_url)
150            .finish()
151    }
152}
153
154impl ClientConfig {
155    /// Create a configuration with no retries
156    ///
157    /// Useful when you want to handle all errors at the application level.
158    pub fn no_retries() -> Self {
159        Self {
160            retry_config: RetryConfig::no_retries(),
161            ..Default::default()
162        }
163    }
164
165    /// Create a configuration with conservative retry behavior
166    ///
167    /// Only retries transient network failures, not server errors or rate limits.
168    pub fn conservative() -> Self {
169        Self {
170            retry_config: RetryConfig::conservative(),
171            ..Default::default()
172        }
173    }
174}
175
176/// Enhanced HTTP client with retry logic and timeouts
177#[derive(Debug, Clone)]
178pub struct OdosHttpClient {
179    client: Client,
180    config: ClientConfig,
181}
182
183impl OdosHttpClient {
184    /// Create a new HTTP client with default configuration
185    pub fn new() -> Result<Self> {
186        Self::with_config(ClientConfig::default())
187    }
188
189    /// Create a new HTTP client with custom configuration
190    pub fn with_config(config: ClientConfig) -> Result<Self> {
191        let client = Client::builder()
192            .timeout(config.timeout)
193            .connect_timeout(config.connect_timeout)
194            .pool_max_idle_per_host(config.max_connections)
195            .pool_idle_timeout(config.pool_idle_timeout)
196            .build()
197            .map_err(OdosError::Http)?;
198
199        Ok(Self { client, config })
200    }
201
202    /// Execute a request with retry logic
203    #[instrument(skip(self, request_builder_fn), level = "debug")]
204    pub async fn execute_with_retry<F>(&self, request_builder_fn: F) -> Result<Response>
205    where
206        F: Fn() -> RequestBuilder + Clone,
207    {
208        let initial_backoff_duration =
209            Duration::from_millis(self.config.retry_config.initial_backoff_ms);
210        let mut backoff = ExponentialBackoff {
211            initial_interval: initial_backoff_duration,
212            max_interval: Duration::from_secs(30), // Max backoff of 30 seconds
213            max_elapsed_time: Some(self.config.timeout),
214            ..Default::default()
215        };
216
217        let mut attempt = 0;
218
219        loop {
220            attempt += 1;
221
222            let request = match request_builder_fn().build() {
223                Ok(req) => req,
224                Err(e) => return Err(OdosError::Http(e)),
225            };
226
227            let last_error = match timeout(self.config.timeout, self.client.execute(request)).await
228            {
229                Ok(Ok(response)) if response.status().is_success() => {
230                    return Ok(response);
231                }
232                Ok(Ok(response)) => {
233                    let status = response.status();
234
235                    if status == StatusCode::TOO_MANY_REQUESTS {
236                        let retry_after = extract_retry_after(&response);
237
238                        // Parse structured error response
239                        let parsed = parse_error_response(response).await;
240
241                        let error = OdosError::rate_limit_error_with_retry_after_and_trace(
242                            parsed.message,
243                            retry_after,
244                            parsed.code,
245                            parsed.trace_id,
246                        );
247
248                        // Rate limits are never retried - return immediately
249                        if !self.should_retry(&error, attempt) {
250                            return Err(error);
251                        }
252
253                        if let Some(delay) = retry_after {
254                            // If retry-after is 0, use exponential backoff instead
255                            if !delay.is_zero() {
256                                debug!(
257                                    error_type = "rate_limit",
258                                    attempt,
259                                    retry_after_secs = delay.as_secs(),
260                                    action = "sleeping",
261                                    "Rate limit hit, sleeping before retry"
262                                );
263                                tokio::time::sleep(delay).await;
264                                continue;
265                            }
266                        }
267                        error
268                    } else {
269                        // Parse structured error response
270                        let parsed = parse_error_response(response).await;
271
272                        let error = OdosError::api_error_with_code(
273                            status,
274                            parsed.message,
275                            parsed.code,
276                            parsed.trace_id,
277                        );
278
279                        if !self.should_retry(&error, attempt) {
280                            return Err(error);
281                        }
282
283                        error
284                    }
285                }
286                Ok(Err(e)) => {
287                    let is_timeout = e.is_timeout();
288                    let is_connect = e.is_connect();
289                    let error = OdosError::Http(e);
290
291                    if !self.should_retry(&error, attempt) {
292                        return Err(error);
293                    }
294                    debug!(
295                        error_type = "http_error",
296                        attempt,
297                        error = %error,
298                        is_timeout,
299                        is_connect,
300                        "HTTP error occurred, will retry with backoff"
301                    );
302                    error
303                }
304                Err(_) => {
305                    let error = OdosError::timeout_error("Request timed out");
306                    debug!(
307                        error_type = "timeout",
308                        attempt,
309                        timeout_secs = self.config.timeout.as_secs(),
310                        "Request timed out, will retry with backoff"
311                    );
312                    error
313                }
314            };
315
316            // Check if we've exhausted retries
317            if attempt >= self.config.retry_config.max_retries {
318                return Err(last_error);
319            }
320
321            if let Some(delay) = backoff.next_backoff() {
322                tokio::time::sleep(delay).await;
323            } else {
324                return Err(last_error);
325            }
326        }
327    }
328
329    /// Get a reference to the underlying reqwest client
330    pub fn inner(&self) -> &Client {
331        &self.client
332    }
333
334    /// Get the client configuration
335    pub fn config(&self) -> &ClientConfig {
336        &self.config
337    }
338
339    /// Determine if an error should be retried based on retry configuration
340    ///
341    /// Uses the retry configuration to decide whether a specific error warrants
342    /// another attempt. This implements smart retry logic that:
343    /// - NEVER retries rate limits (must be handled globally)
344    /// - NEVER retries client errors (4xx - invalid input)
345    /// - CONDITIONALLY retries server errors (5xx - based on config)
346    /// - ALWAYS retries network/timeout errors (transient failures)
347    ///
348    /// # Arguments
349    ///
350    /// * `error` - The error to evaluate
351    /// * `attempts` - Number of attempts made so far
352    ///
353    /// # Returns
354    ///
355    /// `true` if the error should be retried, `false` otherwise
356    fn should_retry(&self, error: &OdosError, attempts: u32) -> bool {
357        let retry_config = &self.config.retry_config;
358
359        // Check attempt limit
360        if attempts >= retry_config.max_retries {
361            return false;
362        }
363
364        // Check custom predicate first
365        if let Some(predicate) = retry_config.retry_predicate {
366            return predicate(error);
367        }
368
369        // Default retry logic
370        match error {
371            // NEVER retry rate limits - application must handle globally
372            OdosError::RateLimit { .. } => false,
373
374            // NEVER retry client errors - invalid input
375            OdosError::Api { status, .. } if status.is_client_error() => false,
376
377            // MAYBE retry server errors - configurable
378            OdosError::Api { status, .. } if status.is_server_error() => {
379                retry_config.retry_server_errors
380            }
381
382            // ALWAYS retry network errors - transient
383            OdosError::Http(err) => err.is_timeout() || err.is_connect() || err.is_request(),
384
385            // ALWAYS retry timeout errors
386            OdosError::Timeout(_) => true,
387
388            // Don't retry anything else by default
389            _ => false,
390        }
391    }
392}
393
394/// Extract the retry-after header from the response
395fn extract_retry_after(response: &Response) -> Option<Duration> {
396    response
397        .headers()
398        .get("retry-after")
399        .and_then(|v| v.to_str().ok())
400        .and_then(|s| s.parse::<u64>().ok())
401        .map(Duration::from_secs)
402}
403
404/// Parsed error response from Odos API
405#[derive(Debug, Clone)]
406struct ParsedErrorResponse {
407    /// Human-readable error message
408    message: String,
409    /// Odos API error code
410    code: OdosErrorCode,
411    /// Optional trace ID for debugging
412    trace_id: Option<crate::error_code::TraceId>,
413}
414
415/// Parse structured error response from Odos API
416///
417/// Attempts to parse the response body as a structured error JSON.
418/// Returns the parsed error response with message, error code, and optional trace ID.
419/// Falls back to the raw body text with an Unknown error code if JSON parsing fails.
420async fn parse_error_response(response: Response) -> ParsedErrorResponse {
421    // Get the response body as text
422    let body_text = match response.text().await {
423        Ok(text) => text,
424        Err(e) => {
425            return ParsedErrorResponse {
426                message: format!("Failed to read response body: {}", e),
427                code: OdosErrorCode::Unknown(0),
428                trace_id: None,
429            }
430        }
431    };
432
433    // Try to parse as structured error JSON
434    match serde_json::from_str::<OdosApiErrorResponse>(&body_text) {
435        Ok(error_response) => {
436            // Successfully parsed structured error
437            let error_code = OdosErrorCode::from(error_response.error_code);
438            ParsedErrorResponse {
439                message: error_response.detail,
440                code: error_code,
441                trace_id: Some(error_response.trace_id),
442            }
443        }
444        Err(_) => {
445            // Failed to parse as structured error, return raw body with Unknown code
446            ParsedErrorResponse {
447                message: body_text,
448                code: OdosErrorCode::Unknown(0),
449                trace_id: None,
450            }
451        }
452    }
453}
454
455impl Default for OdosHttpClient {
456    /// Creates a default HTTP client with standard configuration.
457    ///
458    /// # Panics
459    ///
460    /// Panics if the underlying HTTP client cannot be initialized.
461    /// This should only fail in extremely rare cases such as:
462    /// - TLS initialization failure
463    /// - System resource exhaustion
464    /// - Invalid system configuration
465    ///
466    /// In practice, this almost never fails and is safe for most use cases.
467    fn default() -> Self {
468        Self::new().expect("Failed to create default HTTP client")
469    }
470}
471
472#[cfg(test)]
473mod tests {
474    use super::*;
475    use std::sync::{Arc, Mutex};
476    use std::time::Duration;
477    use wiremock::{
478        matchers::{method, path},
479        Mock, MockServer, Request, ResponseTemplate,
480    };
481
482    /// Helper to create a mock that returns different responses based on attempt count
483    fn create_retry_mock(
484        first_status: u16,
485        first_body: String,
486        success_after: usize,
487    ) -> impl Fn(&Request) -> ResponseTemplate {
488        let attempt_count = Arc::new(Mutex::new(0));
489        move |_req: &Request| {
490            let mut count = attempt_count.lock().unwrap();
491            *count += 1;
492
493            if *count < success_after {
494                ResponseTemplate::new(first_status).set_body_string(&first_body)
495            } else {
496                ResponseTemplate::new(200).set_body_string("Success")
497            }
498        }
499    }
500
501    /// Helper to create a test client with custom config
502    fn create_test_client(max_retries: u32, timeout_ms: u64) -> OdosHttpClient {
503        let config = ClientConfig {
504            timeout: Duration::from_millis(timeout_ms),
505            retry_config: RetryConfig {
506                max_retries,
507                initial_backoff_ms: 10,
508                ..Default::default()
509            },
510            ..Default::default()
511        };
512        OdosHttpClient::with_config(config).unwrap()
513    }
514
515    #[test]
516    fn test_client_config_default() {
517        let config = ClientConfig::default();
518        assert_eq!(config.timeout, Duration::from_secs(30));
519        assert_eq!(config.retry_config.max_retries, 3);
520        assert_eq!(config.max_connections, 20);
521    }
522
523    #[tokio::test]
524    async fn test_client_creation() {
525        let client = OdosHttpClient::new();
526        assert!(client.is_ok());
527    }
528
529    #[tokio::test]
530    async fn test_client_with_custom_config() {
531        let config = ClientConfig {
532            timeout: Duration::from_secs(60),
533            retry_config: RetryConfig {
534                max_retries: 5,
535                ..Default::default()
536            },
537            ..Default::default()
538        };
539        let client = OdosHttpClient::with_config(config.clone());
540        assert!(client.is_ok());
541
542        let client = client.unwrap();
543        assert_eq!(client.config().timeout, Duration::from_secs(60));
544        assert_eq!(client.config().retry_config.max_retries, 5);
545    }
546
547    #[tokio::test]
548    async fn test_rate_limit_with_retry_after() {
549        let mock_server = MockServer::start().await;
550
551        // Mock returns 429 with Retry-After: 1 second
552        Mock::given(method("GET"))
553            .and(path("/test"))
554            .respond_with(
555                ResponseTemplate::new(429)
556                    .set_body_string("Rate limit exceeded")
557                    .insert_header("retry-after", "1"),
558            )
559            .expect(1) // Should only be called once (no retries)
560            .mount(&mock_server)
561            .await;
562
563        let client = create_test_client(3, 30000);
564        let response = client
565            .execute_with_retry(|| client.inner().get(format!("{}/test", mock_server.uri())))
566            .await;
567
568        // Rate limits should return immediately without retry
569        assert!(
570            response.is_err(),
571            "Rate limit should return error immediately"
572        );
573
574        if let Err(OdosError::RateLimit {
575            message,
576            retry_after,
577            ..
578        }) = response
579        {
580            assert!(message.contains("Rate limit"));
581            assert_eq!(retry_after, Some(Duration::from_secs(1)));
582        } else {
583            panic!("Expected RateLimit error, got: {response:?}");
584        }
585    }
586
587    #[tokio::test]
588    async fn test_rate_limit_without_retry_after() {
589        let mock_server = MockServer::start().await;
590
591        // Mock returns 429 without Retry-After header
592        Mock::given(method("GET"))
593            .and(path("/test"))
594            .respond_with(ResponseTemplate::new(429).set_body_string("Rate limit exceeded"))
595            .expect(1) // Should only be called once (no retries)
596            .mount(&mock_server)
597            .await;
598
599        let client = create_test_client(3, 30000);
600        let response = client
601            .execute_with_retry(|| client.inner().get(format!("{}/test", mock_server.uri())))
602            .await;
603
604        // Rate limits should return immediately without retry
605        assert!(
606            response.is_err(),
607            "Rate limit should return error immediately"
608        );
609
610        if let Err(OdosError::RateLimit {
611            message,
612            retry_after,
613            ..
614        }) = response
615        {
616            assert!(message.contains("Rate limit"));
617            assert_eq!(retry_after, None);
618        } else {
619            panic!("Expected RateLimit error, got: {response:?}");
620        }
621    }
622
623    #[tokio::test]
624    async fn test_non_retryable_error() {
625        let mock_server = MockServer::start().await;
626
627        // Returns 400 Bad Request (non-retryable)
628        Mock::given(method("GET"))
629            .and(path("/test"))
630            .respond_with(ResponseTemplate::new(400).set_body_string("Bad request"))
631            .expect(1)
632            .mount(&mock_server)
633            .await;
634
635        let client = OdosHttpClient::with_config(ClientConfig::default()).unwrap();
636
637        let response = client
638            .execute_with_retry(|| client.inner().get(format!("{}/test", mock_server.uri())))
639            .await;
640
641        // Should fail immediately without retrying
642        assert!(response.is_err());
643        if let Err(e) = response {
644            assert!(!e.is_retryable());
645        }
646    }
647
648    #[tokio::test]
649    async fn test_retry_exhaustion_returns_last_error() {
650        let mock_server = MockServer::start().await;
651
652        // Always returns 503 Service Unavailable (retryable)
653        Mock::given(method("GET"))
654            .and(path("/test"))
655            .respond_with(ResponseTemplate::new(503).set_body_string("Service unavailable"))
656            .mount(&mock_server)
657            .await;
658
659        let client = create_test_client(2, 30000);
660
661        let response = client
662            .execute_with_retry(|| client.inner().get(format!("{}/test", mock_server.uri())))
663            .await;
664
665        // Should fail after exhausting retries
666        assert!(response.is_err());
667        if let Err(e) = response {
668            assert!(
669                matches!(e, OdosError::Api { status, .. } if status == StatusCode::SERVICE_UNAVAILABLE)
670            );
671        }
672    }
673
674    #[tokio::test]
675    async fn test_timeout_error() {
676        let mock_server = MockServer::start().await;
677
678        // Delays response longer than timeout
679        Mock::given(method("GET"))
680            .and(path("/test"))
681            .respond_with(
682                ResponseTemplate::new(200)
683                    .set_body_string("Success")
684                    .set_delay(Duration::from_secs(5)),
685            )
686            .mount(&mock_server)
687            .await;
688
689        let client = create_test_client(2, 100);
690
691        let response = client
692            .execute_with_retry(|| client.inner().get(format!("{}/test", mock_server.uri())))
693            .await;
694
695        // Should fail with timeout error (could be either Http timeout or our Timeout wrapper)
696        assert!(response.is_err());
697        if let Err(e) = response {
698            // Accept either OdosError::Http with timeout or OdosError::Timeout
699            let is_timeout = matches!(e, OdosError::Timeout(_))
700                || matches!(e, OdosError::Http(ref err) if err.is_timeout());
701            assert!(is_timeout, "Expected timeout error, got: {e:?}");
702        }
703    }
704
705    #[tokio::test]
706    async fn test_invalid_request_builder_fails_immediately() {
707        let client = OdosHttpClient::default();
708
709        // Create a request builder that will fail on .build()
710        // Use an absurdly long header name that will fail validation
711        let bad_builder = || {
712            let mut builder = client.inner().get("http://localhost");
713            // Add an invalid header that will cause build to fail
714            builder = builder.header("x".repeat(100000), "value");
715            builder
716        };
717
718        let result = client.execute_with_retry(bad_builder).await;
719
720        // Should fail immediately without retrying
721        assert!(result.is_err());
722        if let Err(e) = result {
723            assert!(matches!(e, OdosError::Http(_)));
724        }
725    }
726
727    #[tokio::test]
728    async fn test_retryable_500_error() {
729        let mock_server = MockServer::start().await;
730
731        Mock::given(method("GET"))
732            .and(path("/test"))
733            .respond_with(create_retry_mock(
734                500,
735                "Internal server error".to_string(),
736                2,
737            ))
738            .mount(&mock_server)
739            .await;
740
741        let client = create_test_client(3, 30000);
742        let response = client
743            .execute_with_retry(|| client.inner().get(format!("{}/test", mock_server.uri())))
744            .await;
745
746        assert!(response.is_ok(), "500 error should be retried and succeed");
747    }
748
749    #[tokio::test]
750    async fn test_retryable_502_bad_gateway() {
751        let mock_server = MockServer::start().await;
752
753        Mock::given(method("GET"))
754            .and(path("/test"))
755            .respond_with(create_retry_mock(502, "Bad gateway".to_string(), 2))
756            .mount(&mock_server)
757            .await;
758
759        let client = create_test_client(3, 30000);
760        let response = client
761            .execute_with_retry(|| client.inner().get(format!("{}/test", mock_server.uri())))
762            .await;
763
764        assert!(response.is_ok(), "502 error should be retried and succeed");
765    }
766
767    #[tokio::test]
768    async fn test_retryable_503_service_unavailable() {
769        let mock_server = MockServer::start().await;
770
771        Mock::given(method("GET"))
772            .and(path("/test"))
773            .respond_with(create_retry_mock(503, "Service unavailable".to_string(), 3))
774            .mount(&mock_server)
775            .await;
776
777        let client = create_test_client(3, 30000);
778        let response = client
779            .execute_with_retry(|| client.inner().get(format!("{}/test", mock_server.uri())))
780            .await;
781
782        assert!(response.is_ok(), "503 error should be retried and succeed");
783    }
784
785    #[tokio::test]
786    async fn test_retryable_504_gateway_timeout() {
787        let mock_server = MockServer::start().await;
788
789        Mock::given(method("GET"))
790            .and(path("/test"))
791            .respond_with(create_retry_mock(504, "Gateway timeout".to_string(), 2))
792            .mount(&mock_server)
793            .await;
794
795        let client = create_test_client(3, 30000);
796        let response = client
797            .execute_with_retry(|| client.inner().get(format!("{}/test", mock_server.uri())))
798            .await;
799
800        assert!(response.is_ok(), "504 error should be retried and succeed");
801    }
802
803    #[tokio::test]
804    async fn test_network_error_retryable() {
805        // Test with an invalid URL that will cause a connection error
806        let client = create_test_client(2, 100);
807
808        let response = client
809            .execute_with_retry(|| client.inner().get("http://localhost:1"))
810            .await;
811
812        // Should fail after retries
813        assert!(response.is_err());
814        if let Err(e) = response {
815            assert!(matches!(e, OdosError::Http(_)));
816        }
817    }
818
819    #[test]
820    fn test_accessor_methods() {
821        let config = ClientConfig {
822            timeout: Duration::from_secs(45),
823            retry_config: RetryConfig {
824                max_retries: 5,
825                ..Default::default()
826            },
827            ..Default::default()
828        };
829        let client = OdosHttpClient::with_config(config.clone()).unwrap();
830
831        // Test config() accessor
832        assert_eq!(client.config().timeout, Duration::from_secs(45));
833        assert_eq!(client.config().retry_config.max_retries, 5);
834
835        // Test inner() accessor - just verify it returns a Client
836        let _inner: &reqwest::Client = client.inner();
837    }
838
839    #[test]
840    fn test_default_client() {
841        let client = OdosHttpClient::default();
842
843        // Should use default config
844        assert_eq!(client.config().timeout, Duration::from_secs(30));
845        assert_eq!(client.config().retry_config.max_retries, 3);
846    }
847
848    #[test]
849    fn test_extract_retry_after_valid_numeric() {
850        let response = reqwest::Response::from(
851            http::Response::builder()
852                .status(429)
853                .header("retry-after", "30")
854                .body("")
855                .unwrap(),
856        );
857
858        let retry_after = extract_retry_after(&response);
859        assert_eq!(retry_after, Some(Duration::from_secs(30)));
860    }
861
862    #[test]
863    fn test_extract_retry_after_missing_header() {
864        let response =
865            reqwest::Response::from(http::Response::builder().status(429).body("").unwrap());
866
867        let retry_after = extract_retry_after(&response);
868        assert_eq!(retry_after, None);
869    }
870
871    #[test]
872    fn test_extract_retry_after_malformed_value() {
873        let response = reqwest::Response::from(
874            http::Response::builder()
875                .status(429)
876                .header("retry-after", "not-a-number")
877                .body("")
878                .unwrap(),
879        );
880
881        let retry_after = extract_retry_after(&response);
882        assert_eq!(retry_after, None);
883    }
884
885    #[test]
886    fn test_extract_retry_after_zero_value() {
887        let response = reqwest::Response::from(
888            http::Response::builder()
889                .status(429)
890                .header("retry-after", "0")
891                .body("")
892                .unwrap(),
893        );
894
895        let retry_after = extract_retry_after(&response);
896        assert_eq!(retry_after, Some(Duration::from_secs(0)));
897    }
898
899    #[tokio::test]
900    async fn test_rate_limit_with_retry_after_zero() {
901        let mock_server = MockServer::start().await;
902
903        // Mock returns 429 with Retry-After: 0
904        Mock::given(method("GET"))
905            .and(path("/test"))
906            .respond_with(
907                ResponseTemplate::new(429)
908                    .set_body_string("Rate limit exceeded")
909                    .insert_header("retry-after", "0"),
910            )
911            .expect(1) // Should only be called once (no retries)
912            .mount(&mock_server)
913            .await;
914
915        let client = create_test_client(3, 30000);
916        let response = client
917            .execute_with_retry(|| client.inner().get(format!("{}/test", mock_server.uri())))
918            .await;
919
920        // Rate limits should return immediately without retry (even with Retry-After: 0)
921        assert!(
922            response.is_err(),
923            "Rate limit should return error immediately"
924        );
925
926        if let Err(OdosError::RateLimit {
927            message,
928            retry_after,
929            ..
930        }) = response
931        {
932            assert!(message.contains("Rate limit"));
933            assert_eq!(retry_after, Some(Duration::from_secs(0)));
934        } else {
935            panic!("Expected RateLimit error, got: {response:?}");
936        }
937    }
938
939    #[test]
940    fn test_extract_retry_after_large_value() {
941        let response = reqwest::Response::from(
942            http::Response::builder()
943                .status(429)
944                .header("retry-after", "3600")
945                .body("")
946                .unwrap(),
947        );
948
949        let retry_after = extract_retry_after(&response);
950        assert_eq!(retry_after, Some(Duration::from_secs(3600)));
951    }
952
953    #[test]
954    fn test_extract_retry_after_invalid_utf8() {
955        let response = reqwest::Response::from(
956            http::Response::builder()
957                .status(429)
958                .header("retry-after", vec![0xff, 0xfe])
959                .body("")
960                .unwrap(),
961        );
962
963        let retry_after = extract_retry_after(&response);
964        assert_eq!(retry_after, None);
965    }
966
967    #[test]
968    fn test_client_config_debug_redacts_api_key() {
969        use crate::ApiKey;
970        use uuid::Uuid;
971
972        let uuid = Uuid::new_v4();
973        let uuid_str = uuid.to_string();
974        let api_key = ApiKey::new(uuid);
975
976        let config = ClientConfig {
977            api_key: Some(api_key),
978            ..Default::default()
979        };
980
981        let debug_output = format!("{:?}", config);
982
983        // Verify the debug output contains "REDACTED"
984        assert!(debug_output.contains("[REDACTED]"));
985
986        // Verify the actual UUID is NOT in the debug output
987        assert!(
988            !debug_output.contains(&uuid_str),
989            "API key UUID should not appear in debug output, but found: {}",
990            uuid_str
991        );
992    }
993
994    #[tokio::test]
995    async fn test_max_retries_zero() {
996        let mock_server = MockServer::start().await;
997
998        // Mock that would normally trigger retries
999        Mock::given(method("GET"))
1000            .and(path("/test"))
1001            .respond_with(ResponseTemplate::new(500).set_body_string("Server error"))
1002            .expect(1) // Should only be called once
1003            .mount(&mock_server)
1004            .await;
1005
1006        let client = create_test_client(0, 30000); // max_retries = 0
1007        let response = client
1008            .execute_with_retry(|| client.inner().get(format!("{}/test", mock_server.uri())))
1009            .await;
1010
1011        // Should fail immediately without retrying
1012        assert!(response.is_err());
1013        if let Err(e) = response {
1014            assert!(
1015                matches!(e, OdosError::Api { status, .. } if status == StatusCode::INTERNAL_SERVER_ERROR)
1016            );
1017        }
1018    }
1019
1020    #[tokio::test]
1021    async fn test_parse_structured_error_response() {
1022        use crate::error_code::OdosErrorCode;
1023
1024        // Create a mock response with structured error
1025        let error_json = r#"{
1026            "detail": "Error getting quote, please try again",
1027            "traceId": "10becdc8-a021-4491-8201-a17b657204e0",
1028            "errorCode": 2999
1029        }"#;
1030
1031        let http_response = http::Response::builder()
1032            .status(500)
1033            .body(error_json)
1034            .unwrap();
1035        let response = reqwest::Response::from(http_response);
1036
1037        let parsed = parse_error_response(response).await;
1038
1039        assert_eq!(parsed.message, "Error getting quote, please try again");
1040        assert_eq!(parsed.code, OdosErrorCode::AlgoInternal);
1041        assert!(parsed.trace_id.is_some());
1042        assert_eq!(
1043            parsed.trace_id.unwrap().to_string(),
1044            "10becdc8-a021-4491-8201-a17b657204e0"
1045        );
1046    }
1047
1048    #[tokio::test]
1049    async fn test_parse_unstructured_error_response() {
1050        // Create a mock response with plain text error
1051        let http_response = http::Response::builder()
1052            .status(500)
1053            .body("Internal server error")
1054            .unwrap();
1055        let response = reqwest::Response::from(http_response);
1056
1057        let parsed = parse_error_response(response).await;
1058
1059        assert_eq!(parsed.message, "Internal server error");
1060        assert_eq!(parsed.code, OdosErrorCode::Unknown(0));
1061        assert!(parsed.trace_id.is_none());
1062    }
1063
1064    #[tokio::test]
1065    async fn test_api_error_with_structured_response() {
1066        let mock_server = MockServer::start().await;
1067
1068        let error_json = r#"{
1069            "detail": "Invalid chain ID",
1070            "traceId": "a0b1c2d3-e4f5-6789-0abc-def123456789",
1071            "errorCode": 4001
1072        }"#;
1073
1074        Mock::given(method("GET"))
1075            .and(path("/test"))
1076            .respond_with(ResponseTemplate::new(400).set_body_string(error_json))
1077            .expect(1)
1078            .mount(&mock_server)
1079            .await;
1080
1081        let client = create_test_client(0, 30000);
1082        let response = client
1083            .execute_with_retry(|| client.inner().get(format!("{}/test", mock_server.uri())))
1084            .await;
1085
1086        assert!(response.is_err());
1087        if let Err(e) = response {
1088            // Check that it's an API error
1089            assert!(matches!(e, OdosError::Api { .. }));
1090
1091            // Check error code
1092            let error_code = e.error_code();
1093            assert!(error_code.is_some());
1094            assert!(error_code.unwrap().is_invalid_chain_id());
1095
1096            // Check trace ID
1097            let trace_id = e.trace_id();
1098            assert!(trace_id.is_some());
1099        } else {
1100            panic!("Expected error, got success");
1101        }
1102    }
1103
1104    #[tokio::test]
1105    async fn test_client_config_failure() {
1106        // Test that invalid configs are handled gracefully
1107        // Using an extremely high connection limit
1108        let config = ClientConfig {
1109            max_connections: usize::MAX,
1110            ..Default::default()
1111        };
1112
1113        // This might not actually fail with reqwest, but we test the error handling path
1114        let result = OdosHttpClient::with_config(config);
1115
1116        // If it succeeds, that's fine - reqwest is quite permissive
1117        // If it fails, we verify proper error wrapping
1118        match result {
1119            Ok(_) => {
1120                // Client creation succeeded - this is actually normal
1121            }
1122            Err(e) => {
1123                // If it fails, should be wrapped as Http error
1124                assert!(matches!(e, OdosError::Http(_)));
1125            }
1126        }
1127    }
1128
1129    #[tokio::test]
1130    async fn test_rate_limit_with_trace_id() {
1131        let mock_server = MockServer::start().await;
1132
1133        let error_json = r#"{
1134            "detail": "Rate limit exceeded",
1135            "traceId": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
1136            "errorCode": 4299
1137        }"#;
1138
1139        Mock::given(method("GET"))
1140            .and(path("/test"))
1141            .respond_with(
1142                ResponseTemplate::new(429)
1143                    .set_body_string(error_json)
1144                    .insert_header("retry-after", "30"),
1145            )
1146            .expect(1)
1147            .mount(&mock_server)
1148            .await;
1149
1150        let client = create_test_client(0, 30000);
1151        let response = client
1152            .execute_with_retry(|| client.inner().get(format!("{}/test", mock_server.uri())))
1153            .await;
1154
1155        assert!(response.is_err());
1156        if let Err(e) = response {
1157            // Verify it's a rate limit error
1158            assert!(e.is_rate_limit());
1159
1160            // Verify trace_id is present
1161            let trace_id = e.trace_id();
1162            assert!(trace_id.is_some());
1163            assert_eq!(
1164                trace_id.unwrap().to_string(),
1165                "a1b2c3d4-e5f6-7890-abcd-ef1234567890"
1166            );
1167
1168            // Verify the error message includes the trace ID
1169            let error_msg = e.to_string();
1170            assert!(error_msg.contains("a1b2c3d4-e5f6-7890-abcd-ef1234567890"));
1171            assert!(error_msg.contains("[trace:"));
1172        } else {
1173            panic!("Expected error, got success");
1174        }
1175    }
1176}