odos_sdk/
client.rs

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