odos_sdk/
client.rs

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