Skip to main content

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::{ApiErrorBody, OdosError, Result},
16    error_code::OdosErrorCode,
17};
18
19/// How a caller-supplied predicate composes with the SDK's default retry
20/// decision tree.
21///
22/// The default decision tree is [`OdosError::is_retryable`] gated by
23/// [`RetryConfig::retry_server_errors`]. Each variant chooses how a custom
24/// predicate interacts with that tree:
25///
26/// - [`RetryPredicate::Default`] uses only the built-in tree.
27/// - [`RetryPredicate::Replace`] replaces the tree entirely; the predicate is
28///   the sole authority on whether to retry.
29/// - [`RetryPredicate::DefaultExcept`] runs the built-in tree but vetoes
30///   retries when the predicate returns `true`. Useful for blacklisting
31///   specific error shapes without reimplementing the default policy.
32///
33/// `max_retries` and the rate-limit / 429 hard-gate apply to every variant.
34#[derive(Debug, Clone, Copy, Default)]
35pub enum RetryPredicate {
36    /// Use the SDK's built-in decision tree.
37    #[default]
38    Default,
39
40    /// Replace the default decision tree entirely. The predicate is the sole
41    /// authority on whether to retry. The [`RetryConfig::retry_server_errors`]
42    /// flag is bypassed under this variant.
43    Replace(fn(&OdosError) -> bool),
44
45    /// Run the default decision tree, but veto retries when the predicate
46    /// returns `true`. Equivalent to
47    /// `!veto(err) && default_should_retry(err)`.
48    DefaultExcept(fn(&OdosError) -> bool),
49}
50
51/// Configuration for retry behavior
52///
53/// Controls which errors should be retried and how retries are executed.
54///
55/// # Examples
56///
57/// ```rust
58/// use odos_sdk::{RetryConfig, RetryPredicate};
59///
60/// // No retries - all errors return immediately
61/// let config = RetryConfig::no_retries();
62///
63/// // Conservative retries - only network errors
64/// let config = RetryConfig::conservative();
65///
66/// // Default retries - network errors and server errors
67/// let config = RetryConfig::default();
68///
69/// // Replace the default policy with custom logic
70/// let config = RetryConfig {
71///     max_retries: 2,
72///     retry_server_errors: false,
73///     retry_predicate: RetryPredicate::Replace(|err| {
74///         // Custom logic to determine if error should be retried
75///         err.is_retryable()
76///     }),
77///     ..Default::default()
78/// };
79///
80/// // Keep the default policy but veto a specific error shape
81/// let config = RetryConfig {
82///     retry_predicate: RetryPredicate::DefaultExcept(|err| err.is_rate_limit()),
83///     ..Default::default()
84/// };
85/// ```
86#[derive(Debug, Clone)]
87pub struct RetryConfig {
88    /// Maximum retry attempts for retryable errors
89    pub max_retries: u32,
90
91    /// Initial backoff duration in milliseconds
92    pub initial_backoff_ms: u64,
93
94    /// Whether to retry server errors (5xx)
95    pub retry_server_errors: bool,
96
97    /// How a caller-supplied predicate composes with the default decision
98    /// tree. See [`RetryPredicate`] for the semantics of each variant.
99    pub retry_predicate: RetryPredicate,
100}
101
102impl Default for RetryConfig {
103    fn default() -> Self {
104        Self {
105            max_retries: 3,
106            initial_backoff_ms: 100,
107            retry_server_errors: true,
108            retry_predicate: RetryPredicate::Default,
109        }
110    }
111}
112
113impl RetryConfig {
114    /// No retries - return errors immediately
115    ///
116    /// Use this when you want to handle all errors at the application level,
117    /// or when implementing your own retry logic.
118    pub fn no_retries() -> Self {
119        Self {
120            max_retries: 0,
121            ..Default::default()
122        }
123    }
124
125    /// Conservative retries - only network errors
126    ///
127    /// This configuration retries only transient network failures
128    /// (timeouts, connection errors) but not server errors (5xx).
129    /// Use this when you want to be cautious about retry behavior.
130    pub fn conservative() -> Self {
131        Self {
132            max_retries: 2,
133            retry_server_errors: false,
134            ..Default::default()
135        }
136    }
137}
138
139/// Configuration for the HTTP client
140///
141/// Combines connection settings, retry behavior, and endpoint configuration
142/// for the Odos API client.
143///
144/// # Architecture
145///
146/// The configuration separates concerns into three main areas:
147/// 1. **Connection settings**: Timeouts, connection pooling
148/// 2. **Retry behavior**: How errors are handled and retried
149/// 3. **Endpoint configuration**: Which API endpoint and version to use
150///
151/// # Examples
152///
153/// ## Basic configuration with defaults
154/// ```rust
155/// use odos_sdk::ClientConfig;
156///
157/// let config = ClientConfig::default();
158/// ```
159///
160/// ## Custom endpoint configuration
161/// ```rust
162/// use odos_sdk::{ClientConfig, Endpoint};
163///
164/// let config = ClientConfig {
165///     endpoint: Endpoint::enterprise_v3(),
166///     ..Default::default()
167/// };
168/// ```
169///
170/// ## Conservative retry behavior
171/// ```rust
172/// use odos_sdk::ClientConfig;
173///
174/// let config = ClientConfig::conservative();
175/// ```
176///
177/// ## Full custom configuration
178/// ```rust
179/// use std::time::Duration;
180/// use odos_sdk::{ClientConfig, RetryConfig, Endpoint};
181///
182/// let config = ClientConfig {
183///     timeout: Duration::from_secs(60),
184///     connect_timeout: Duration::from_secs(15),
185///     retry_config: RetryConfig {
186///         max_retries: 5,
187///         retry_server_errors: true,
188///         ..Default::default()
189///     },
190///     max_connections: 50,
191///     endpoint: Endpoint::public_v2(),
192///     ..Default::default()
193/// };
194/// ```
195#[derive(Clone)]
196pub struct ClientConfig {
197    /// Request timeout duration
198    ///
199    /// Maximum time to wait for a complete request/response cycle.
200    /// Includes connection time, request transmission, server processing,
201    /// and response reception.
202    ///
203    /// Default: 30 seconds
204    pub timeout: Duration,
205
206    /// Connection timeout duration
207    ///
208    /// Maximum time to wait when establishing a TCP connection to the server.
209    /// Should be shorter than `timeout`.
210    ///
211    /// Default: 10 seconds
212    pub connect_timeout: Duration,
213
214    /// Retry behavior configuration
215    ///
216    /// Controls which errors trigger retries and how retries are executed.
217    /// See [`RetryConfig`] for detailed retry configuration options.
218    ///
219    /// Default: 3 retries with exponential backoff
220    pub retry_config: RetryConfig,
221
222    /// Maximum concurrent connections per host
223    ///
224    /// Limits the number of simultaneous connections in the connection pool.
225    /// Higher values allow more concurrent requests but consume more resources.
226    ///
227    /// Default: 20
228    pub max_connections: usize,
229
230    /// Connection pool idle timeout
231    ///
232    /// How long to keep idle connections alive in the pool before closing them.
233    /// Longer timeouts reduce connection overhead but consume resources.
234    ///
235    /// Default: 90 seconds
236    pub pool_idle_timeout: Duration,
237
238    /// Optional API key for authenticated requests
239    ///
240    /// Required for Enterprise endpoints and rate limit increases.
241    /// Obtain from the Odos dashboard or Enterprise program.
242    ///
243    /// Default: None (unauthenticated requests)
244    pub api_key: Option<ApiKey>,
245
246    /// API endpoint configuration (host + version)
247    ///
248    /// Combines the API host tier (Public/Enterprise) and version (V2/V3)
249    /// into a single ergonomic configuration.
250    ///
251    /// Use convenience constructors like [`crate::Endpoint::public_v2()`] or
252    /// [`crate::Endpoint::enterprise_v3()`] for easy configuration.
253    ///
254    /// Default: [`crate::Endpoint::public_v2()`]
255    ///
256    /// # Examples
257    ///
258    /// ```rust
259    /// use odos_sdk::{ClientConfig, Endpoint};
260    ///
261    /// // Use Public API V2 (recommended)
262    /// let config = ClientConfig {
263    ///     endpoint: Endpoint::public_v2(),
264    ///     ..Default::default()
265    /// };
266    ///
267    /// // Use Enterprise API V3
268    /// let config = ClientConfig {
269    ///     endpoint: Endpoint::enterprise_v3(),
270    ///     ..Default::default()
271    /// };
272    /// ```
273    pub endpoint: crate::Endpoint,
274}
275
276impl Default for ClientConfig {
277    fn default() -> Self {
278        Self {
279            timeout: Duration::from_secs(30),
280            connect_timeout: Duration::from_secs(10),
281            retry_config: RetryConfig::default(),
282            max_connections: 20,
283            pool_idle_timeout: Duration::from_secs(90),
284            api_key: None,
285            endpoint: crate::Endpoint::public_v2(),
286        }
287    }
288}
289
290impl std::fmt::Debug for ClientConfig {
291    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
292        f.debug_struct("ClientConfig")
293            .field("timeout", &self.timeout)
294            .field("connect_timeout", &self.connect_timeout)
295            .field("retry_config", &self.retry_config)
296            .field("max_connections", &self.max_connections)
297            .field("pool_idle_timeout", &self.pool_idle_timeout)
298            .field("api_key", &self.api_key)
299            .field("endpoint", &self.endpoint)
300            .finish()
301    }
302}
303
304impl ClientConfig {
305    /// Create a configuration with no retries
306    ///
307    /// Useful when you want to handle all errors at the application level.
308    pub fn no_retries() -> Self {
309        Self {
310            retry_config: RetryConfig::no_retries(),
311            ..Default::default()
312        }
313    }
314
315    /// Create a configuration with conservative retry behavior
316    ///
317    /// Only retries transient network failures, not server errors or rate limits.
318    pub fn conservative() -> Self {
319        Self {
320            retry_config: RetryConfig::conservative(),
321            ..Default::default()
322        }
323    }
324}
325
326/// Enhanced HTTP client with retry logic and timeouts
327#[derive(Debug, Clone)]
328pub struct OdosHttpClient {
329    client: Client,
330    config: ClientConfig,
331}
332
333impl OdosHttpClient {
334    /// Create a new HTTP client with default configuration
335    pub fn new() -> Result<Self> {
336        Self::with_config(ClientConfig::default())
337    }
338
339    /// Create a new HTTP client with custom configuration
340    pub fn with_config(config: ClientConfig) -> Result<Self> {
341        let client = Client::builder()
342            .timeout(config.timeout)
343            .connect_timeout(config.connect_timeout)
344            .pool_max_idle_per_host(config.max_connections)
345            .pool_idle_timeout(config.pool_idle_timeout)
346            .build()
347            .map_err(OdosError::Http)?;
348
349        Ok(Self { client, config })
350    }
351
352    /// Execute a request with retry logic
353    #[instrument(skip(self, request_builder_fn), level = "debug")]
354    pub async fn execute_with_retry<F>(&self, request_builder_fn: F) -> Result<Response>
355    where
356        F: Fn() -> RequestBuilder + Clone,
357    {
358        let initial_backoff_duration =
359            Duration::from_millis(self.config.retry_config.initial_backoff_ms);
360
361        // +1 because backon counts total attempts, not retries
362        let backoff = ExponentialBuilder::default()
363            .with_min_delay(initial_backoff_duration)
364            .with_max_delay(Duration::from_secs(30))
365            .with_max_times(self.config.retry_config.max_retries as usize + 1);
366
367        let mut backoff_iter = backoff.build();
368        let mut attempt = 0;
369
370        loop {
371            attempt += 1;
372
373            let request = match request_builder_fn().build() {
374                Ok(req) => req,
375                Err(e) => return Err(OdosError::Http(e)),
376            };
377
378            let last_error = match timeout(self.config.timeout, self.client.execute(request)).await
379            {
380                Ok(Ok(response)) if response.status().is_success() => {
381                    return Ok(response);
382                }
383                Ok(Ok(response)) => {
384                    let status = response.status();
385
386                    if status == StatusCode::TOO_MANY_REQUESTS {
387                        // Rate limits are never retried - application must handle globally
388                        let retry_after = extract_retry_after(&response);
389                        let body = parse_error_response(response).await;
390                        return Err(OdosError::RateLimit { retry_after, body });
391                    } else {
392                        let body = parse_error_response(response).await;
393                        let error = OdosError::Api { status, body };
394
395                        if !self.should_retry(&error, attempt) {
396                            return Err(error);
397                        }
398
399                        error
400                    }
401                }
402                Ok(Err(e)) => {
403                    let is_timeout = e.is_timeout();
404                    let is_connect = e.is_connect();
405                    let error = OdosError::Http(e);
406
407                    if !self.should_retry(&error, attempt) {
408                        return Err(error);
409                    }
410                    debug!(
411                        error_type = "http_error",
412                        attempt,
413                        error = %error,
414                        is_timeout,
415                        is_connect,
416                        "HTTP error occurred, will retry with backoff"
417                    );
418                    error
419                }
420                Err(_) => {
421                    let error = OdosError::timeout_error("Request timed out");
422
423                    if !self.should_retry(&error, attempt) {
424                        return Err(error);
425                    }
426                    debug!(
427                        error_type = "timeout",
428                        attempt,
429                        timeout_secs = self.config.timeout.as_secs(),
430                        "Request timed out, will retry with backoff"
431                    );
432                    error
433                }
434            };
435
436            if attempt >= self.config.retry_config.max_retries {
437                return Err(last_error);
438            }
439
440            if let Some(delay) = backoff_iter.next() {
441                tokio::time::sleep(delay).await;
442            } else {
443                return Err(last_error);
444            }
445        }
446    }
447
448    /// Get a reference to the underlying reqwest client
449    pub fn inner(&self) -> &Client {
450        &self.client
451    }
452
453    /// Get the client configuration
454    pub fn config(&self) -> &ClientConfig {
455        &self.config
456    }
457
458    /// Determine if an error should be retried based on retry configuration
459    ///
460    /// Delegates to [`OdosError::is_retryable`] so that the typed
461    /// [`OdosErrorCode`](crate::error_code::OdosErrorCode) classification is
462    /// the single source of truth for whether an API error warrants another
463    /// attempt. The retry configuration adds these gates on top:
464    /// - NEVER retry past `max_retries` attempts.
465    /// - The [`RetryPredicate`] in `retry_predicate` chooses how a caller
466    ///   predicate composes with the default tree:
467    ///   - [`RetryPredicate::Default`] runs only the default tree.
468    ///   - [`RetryPredicate::Replace`] is the sole authority and bypasses the
469    ///     `retry_server_errors` flag and `is_retryable`.
470    ///   - [`RetryPredicate::DefaultExcept`] vetoes retries when the predicate
471    ///     returns `true`, otherwise falls through to the default tree.
472    /// - When `retry_server_errors` is `false`, no `OdosError::Api` 5xx is
473    ///   retried regardless of the typed classification (honoured for
474    ///   `Default` and `DefaultExcept`; bypassed by `Replace`).
475    ///
476    /// For `OdosError::Api` errors with a known `OdosErrorCode`, retryability
477    /// is determined by `OdosErrorCode::is_retryable`. For `OdosErrorCode::Unknown(_)`,
478    /// `OdosError::is_retryable` falls back to checking the HTTP status (500/502/503/504).
479    ///
480    /// # Arguments
481    ///
482    /// * `error` - The error to evaluate
483    /// * `attempts` - Number of attempts made so far
484    ///
485    /// # Returns
486    ///
487    /// `true` if the error should be retried, `false` otherwise
488    fn should_retry(&self, error: &OdosError, attempts: u32) -> bool {
489        let retry_config = &self.config.retry_config;
490
491        if attempts >= retry_config.max_retries {
492            return false;
493        }
494
495        match retry_config.retry_predicate {
496            RetryPredicate::Replace(p) => return p(error),
497            RetryPredicate::DefaultExcept(veto) if veto(error) => return false,
498            RetryPredicate::Default | RetryPredicate::DefaultExcept(_) => {}
499        }
500
501        // `retry_server_errors=false` is an unconditional opt-out for any
502        // API 5xx retry. Apply it before delegating to `is_retryable`, which
503        // would otherwise honour the typed classification regardless.
504        if !retry_config.retry_server_errors && error.is_server_error() {
505            return false;
506        }
507
508        error.is_retryable()
509    }
510}
511
512/// Extract the retry-after header from the response
513fn extract_retry_after(response: &Response) -> Option<Duration> {
514    response
515        .headers()
516        .get("retry-after")
517        .and_then(|v| v.to_str().ok())
518        .and_then(|s| s.parse::<u64>().ok())
519        .map(Duration::from_secs)
520}
521
522/// Parse structured error response from Odos API into an [`ApiErrorBody`].
523///
524/// Attempts to parse the response body as a structured error JSON. Returns the
525/// shared body with message, error code, and optional trace ID populated, or
526/// falls back to the raw body text with an `Unknown` error code if JSON
527/// parsing fails.
528pub(crate) async fn parse_error_response(response: Response) -> ApiErrorBody {
529    let body_text = match response.text().await {
530        Ok(text) => text,
531        Err(e) => {
532            return ApiErrorBody {
533                message: format!("Failed to read response body: {e}"),
534                code: OdosErrorCode::Unknown(0),
535                trace_id: None,
536            };
537        }
538    };
539
540    match serde_json::from_str::<OdosApiErrorResponse>(&body_text) {
541        Ok(error_response) => ApiErrorBody {
542            message: error_response.detail,
543            code: OdosErrorCode::from(error_response.error_code),
544            trace_id: error_response.trace_id,
545        },
546        Err(_) => ApiErrorBody {
547            message: body_text,
548            code: OdosErrorCode::Unknown(0),
549            trace_id: None,
550        },
551    }
552}
553
554impl Default for OdosHttpClient {
555    /// Creates a default HTTP client with standard configuration.
556    ///
557    /// # Panics
558    ///
559    /// Panics if the underlying HTTP client cannot be initialized.
560    /// This should only fail in extremely rare cases such as:
561    /// - TLS initialization failure
562    /// - System resource exhaustion
563    /// - Invalid system configuration
564    ///
565    /// In practice, this almost never fails and is safe for most use cases.
566    fn default() -> Self {
567        Self::new().expect("Failed to create default HTTP client")
568    }
569}
570
571#[cfg(test)]
572mod tests {
573    use super::*;
574    use crate::error_code::OdosErrorCode;
575    use std::sync::{Arc, Mutex};
576    use std::time::Duration;
577    use wiremock::{
578        matchers::{method, path},
579        Mock, MockServer, Request, ResponseTemplate,
580    };
581
582    /// Helper to create a mock that returns different responses based on attempt count
583    fn create_retry_mock(
584        first_status: u16,
585        first_body: String,
586        success_after: usize,
587    ) -> impl Fn(&Request) -> ResponseTemplate {
588        let attempt_count = Arc::new(Mutex::new(0));
589        move |_req: &Request| {
590            let mut count = attempt_count.lock().unwrap();
591            *count += 1;
592
593            if *count < success_after {
594                ResponseTemplate::new(first_status).set_body_string(&first_body)
595            } else {
596                ResponseTemplate::new(200).set_body_string("Success")
597            }
598        }
599    }
600
601    /// Helper to create a test client with custom config and an explicit
602    /// [`RetryPredicate`].
603    fn create_test_client_with_predicate(
604        max_retries: u32,
605        timeout_ms: u64,
606        retry_predicate: RetryPredicate,
607    ) -> OdosHttpClient {
608        let config = ClientConfig {
609            timeout: Duration::from_millis(timeout_ms),
610            retry_config: RetryConfig {
611                max_retries,
612                initial_backoff_ms: 10,
613                retry_predicate,
614                ..Default::default()
615            },
616            ..Default::default()
617        };
618        OdosHttpClient::with_config(config).unwrap()
619    }
620
621    /// Helper to create a test client with the default retry predicate.
622    fn create_test_client(max_retries: u32, timeout_ms: u64) -> OdosHttpClient {
623        create_test_client_with_predicate(max_retries, timeout_ms, RetryPredicate::Default)
624    }
625
626    #[test]
627    fn test_client_config_default() {
628        let config = ClientConfig::default();
629        assert_eq!(config.timeout, Duration::from_secs(30));
630        assert_eq!(config.retry_config.max_retries, 3);
631        assert_eq!(config.max_connections, 20);
632    }
633
634    #[tokio::test]
635    async fn test_client_creation() {
636        let client = OdosHttpClient::new();
637        assert!(client.is_ok());
638    }
639
640    #[tokio::test]
641    async fn test_client_with_custom_config() {
642        let config = ClientConfig {
643            timeout: Duration::from_secs(60),
644            retry_config: RetryConfig {
645                max_retries: 5,
646                ..Default::default()
647            },
648            ..Default::default()
649        };
650        let client = OdosHttpClient::with_config(config.clone());
651        assert!(client.is_ok());
652
653        let client = client.unwrap();
654        assert_eq!(client.config().timeout, Duration::from_secs(60));
655        assert_eq!(client.config().retry_config.max_retries, 5);
656    }
657
658    #[tokio::test]
659    async fn test_rate_limit_with_retry_after() {
660        let mock_server = MockServer::start().await;
661
662        // Mock returns 429 with Retry-After: 1 second
663        Mock::given(method("GET"))
664            .and(path("/test"))
665            .respond_with(
666                ResponseTemplate::new(429)
667                    .set_body_string("Rate limit exceeded")
668                    .insert_header("retry-after", "1"),
669            )
670            .expect(1) // Should only be called once (no retries)
671            .mount(&mock_server)
672            .await;
673
674        let client = create_test_client(3, 30000);
675        let response = client
676            .execute_with_retry(|| client.inner().get(format!("{}/test", mock_server.uri())))
677            .await;
678
679        // Rate limits should return immediately without retry
680        assert!(
681            response.is_err(),
682            "Rate limit should return error immediately"
683        );
684
685        if let Err(OdosError::RateLimit {
686            retry_after, body, ..
687        }) = response
688        {
689            assert!(body.message.contains("Rate limit"));
690            assert_eq!(retry_after, Some(Duration::from_secs(1)));
691        } else {
692            panic!("Expected RateLimit error, got: {response:?}");
693        }
694    }
695
696    #[tokio::test]
697    async fn test_rate_limit_without_retry_after() {
698        let mock_server = MockServer::start().await;
699
700        // Mock returns 429 without Retry-After header
701        Mock::given(method("GET"))
702            .and(path("/test"))
703            .respond_with(ResponseTemplate::new(429).set_body_string("Rate limit exceeded"))
704            .expect(1) // Should only be called once (no retries)
705            .mount(&mock_server)
706            .await;
707
708        let client = create_test_client(3, 30000);
709        let response = client
710            .execute_with_retry(|| client.inner().get(format!("{}/test", mock_server.uri())))
711            .await;
712
713        // Rate limits should return immediately without retry
714        assert!(
715            response.is_err(),
716            "Rate limit should return error immediately"
717        );
718
719        if let Err(OdosError::RateLimit {
720            retry_after, body, ..
721        }) = response
722        {
723            assert!(body.message.contains("Rate limit"));
724            assert_eq!(retry_after, None);
725        } else {
726            panic!("Expected RateLimit error, got: {response:?}");
727        }
728    }
729
730    #[tokio::test]
731    async fn test_non_retryable_error() {
732        let mock_server = MockServer::start().await;
733
734        // Returns 400 Bad Request (non-retryable)
735        Mock::given(method("GET"))
736            .and(path("/test"))
737            .respond_with(ResponseTemplate::new(400).set_body_string("Bad request"))
738            .expect(1)
739            .mount(&mock_server)
740            .await;
741
742        let client = OdosHttpClient::with_config(ClientConfig::default()).unwrap();
743
744        let response = client
745            .execute_with_retry(|| client.inner().get(format!("{}/test", mock_server.uri())))
746            .await;
747
748        // Should fail immediately without retrying
749        assert!(response.is_err());
750        if let Err(e) = response {
751            assert!(!e.is_retryable());
752        }
753    }
754
755    #[tokio::test]
756    async fn test_retry_exhaustion_returns_last_error() {
757        let mock_server = MockServer::start().await;
758
759        // Always returns 503 Service Unavailable (retryable)
760        Mock::given(method("GET"))
761            .and(path("/test"))
762            .respond_with(ResponseTemplate::new(503).set_body_string("Service unavailable"))
763            .mount(&mock_server)
764            .await;
765
766        let client = create_test_client(2, 30000);
767
768        let response = client
769            .execute_with_retry(|| client.inner().get(format!("{}/test", mock_server.uri())))
770            .await;
771
772        // Should fail after exhausting retries
773        assert!(response.is_err());
774        if let Err(e) = response {
775            assert!(
776                matches!(e, OdosError::Api { status, .. } if status == StatusCode::SERVICE_UNAVAILABLE)
777            );
778        }
779    }
780
781    #[tokio::test]
782    async fn test_timeout_error() {
783        let mock_server = MockServer::start().await;
784
785        // Delays response longer than timeout
786        Mock::given(method("GET"))
787            .and(path("/test"))
788            .respond_with(
789                ResponseTemplate::new(200)
790                    .set_body_string("Success")
791                    .set_delay(Duration::from_secs(5)),
792            )
793            .mount(&mock_server)
794            .await;
795
796        let client = create_test_client(2, 100);
797
798        let response = client
799            .execute_with_retry(|| client.inner().get(format!("{}/test", mock_server.uri())))
800            .await;
801
802        // Should fail with timeout error (could be either Http timeout or our Timeout wrapper)
803        assert!(response.is_err());
804        if let Err(e) = response {
805            // Accept either OdosError::Http with timeout or OdosError::Timeout
806            let is_timeout = matches!(e, OdosError::Timeout(_))
807                || matches!(e, OdosError::Http(ref err) if err.is_timeout());
808            assert!(is_timeout, "Expected timeout error, got: {e:?}");
809        }
810    }
811
812    #[tokio::test]
813    async fn test_invalid_request_builder_fails_immediately() {
814        let client = OdosHttpClient::default();
815
816        // Create a request builder that will fail on .build()
817        // Use an absurdly long header name that will fail validation
818        let bad_builder = || {
819            let mut builder = client.inner().get("http://localhost");
820            // Add an invalid header that will cause build to fail
821            builder = builder.header("x".repeat(100000), "value");
822            builder
823        };
824
825        let result = client.execute_with_retry(bad_builder).await;
826
827        // Should fail immediately without retrying
828        assert!(result.is_err());
829        if let Err(e) = result {
830            assert!(matches!(e, OdosError::Http(_)));
831        }
832    }
833
834    #[tokio::test]
835    async fn test_retryable_500_error() {
836        let mock_server = MockServer::start().await;
837
838        Mock::given(method("GET"))
839            .and(path("/test"))
840            .respond_with(create_retry_mock(
841                500,
842                "Internal server error".to_string(),
843                2,
844            ))
845            .mount(&mock_server)
846            .await;
847
848        let client = create_test_client(3, 30000);
849        let response = client
850            .execute_with_retry(|| client.inner().get(format!("{}/test", mock_server.uri())))
851            .await;
852
853        assert!(response.is_ok(), "500 error should be retried and succeed");
854    }
855
856    #[tokio::test]
857    async fn test_retryable_502_bad_gateway() {
858        let mock_server = MockServer::start().await;
859
860        Mock::given(method("GET"))
861            .and(path("/test"))
862            .respond_with(create_retry_mock(502, "Bad gateway".to_string(), 2))
863            .mount(&mock_server)
864            .await;
865
866        let client = create_test_client(3, 30000);
867        let response = client
868            .execute_with_retry(|| client.inner().get(format!("{}/test", mock_server.uri())))
869            .await;
870
871        assert!(response.is_ok(), "502 error should be retried and succeed");
872    }
873
874    #[tokio::test]
875    async fn test_retryable_503_service_unavailable() {
876        let mock_server = MockServer::start().await;
877
878        Mock::given(method("GET"))
879            .and(path("/test"))
880            .respond_with(create_retry_mock(503, "Service unavailable".to_string(), 3))
881            .mount(&mock_server)
882            .await;
883
884        let client = create_test_client(3, 30000);
885        let response = client
886            .execute_with_retry(|| client.inner().get(format!("{}/test", mock_server.uri())))
887            .await;
888
889        assert!(response.is_ok(), "503 error should be retried and succeed");
890    }
891
892    #[tokio::test]
893    async fn test_retryable_504_gateway_timeout() {
894        let mock_server = MockServer::start().await;
895
896        Mock::given(method("GET"))
897            .and(path("/test"))
898            .respond_with(create_retry_mock(504, "Gateway timeout".to_string(), 2))
899            .mount(&mock_server)
900            .await;
901
902        let client = create_test_client(3, 30000);
903        let response = client
904            .execute_with_retry(|| client.inner().get(format!("{}/test", mock_server.uri())))
905            .await;
906
907        assert!(response.is_ok(), "504 error should be retried and succeed");
908    }
909
910    #[tokio::test]
911    async fn test_algo_internal_2999_not_retried() {
912        let mock_server = MockServer::start().await;
913
914        let error_json = r#"{
915            "detail": "Error getting quote, please try again",
916            "traceId": "10becdc8-a021-4491-8201-a17b657204e0",
917            "errorCode": 2999
918        }"#;
919
920        Mock::given(method("GET"))
921            .and(path("/test"))
922            .respond_with(ResponseTemplate::new(500).set_body_string(error_json))
923            .expect(1)
924            .mount(&mock_server)
925            .await;
926
927        let client = create_test_client(3, 30000);
928        let response = client
929            .execute_with_retry(|| client.inner().get(format!("{}/test", mock_server.uri())))
930            .await;
931
932        assert!(response.is_err());
933        match response {
934            Err(OdosError::Api { status, body }) => {
935                assert_eq!(body.code, OdosErrorCode::AlgoInternal);
936                assert_eq!(status, StatusCode::INTERNAL_SERVER_ERROR);
937            }
938            other => panic!("Expected OdosError::Api with AlgoInternal, got: {other:?}"),
939        }
940    }
941
942    #[tokio::test]
943    async fn test_typed_retryable_code_still_retried() {
944        let mock_server = MockServer::start().await;
945
946        // 2998 = AlgoTimeout, classified as retryable via is_timeout()
947        let error_json = r#"{
948            "detail": "Algorithm timeout",
949            "traceId": "20becdc8-a021-4491-8201-a17b657204e0",
950            "errorCode": 2998
951        }"#;
952
953        Mock::given(method("GET"))
954            .and(path("/test"))
955            .respond_with(ResponseTemplate::new(500).set_body_string(error_json))
956            .expect(3) // max_retries gates total attempts; see should_retry
957            .mount(&mock_server)
958            .await;
959
960        let client = create_test_client(3, 30000);
961        let response = client
962            .execute_with_retry(|| client.inner().get(format!("{}/test", mock_server.uri())))
963            .await;
964
965        assert!(response.is_err());
966        match response {
967            Err(OdosError::Api { body, .. }) => {
968                assert_eq!(body.code, OdosErrorCode::AlgoTimeout);
969            }
970            other => panic!("Expected OdosError::Api with AlgoTimeout, got: {other:?}"),
971        }
972    }
973
974    #[tokio::test]
975    async fn test_retry_predicate_default_matches_built_in_tree() {
976        // Explicit `RetryPredicate::Default` must behave identically to the
977        // implicit default: 502 retries, 2999 does not.
978        let mock_server = MockServer::start().await;
979
980        Mock::given(method("GET"))
981            .and(path("/test"))
982            .respond_with(create_retry_mock(502, "Bad gateway".to_string(), 2))
983            .mount(&mock_server)
984            .await;
985
986        let client = create_test_client_with_predicate(3, 30000, RetryPredicate::Default);
987        let response = client
988            .execute_with_retry(|| client.inner().get(format!("{}/test", mock_server.uri())))
989            .await;
990
991        assert!(
992            response.is_ok(),
993            "502 should still be retried under RetryPredicate::Default"
994        );
995    }
996
997    #[tokio::test]
998    async fn test_retry_predicate_replace_can_disable_retries() {
999        // `Replace(|_| false)` makes a normally-retryable 500 not retry.
1000        let mock_server = MockServer::start().await;
1001
1002        Mock::given(method("GET"))
1003            .and(path("/test"))
1004            .respond_with(ResponseTemplate::new(500).set_body_string("Internal error"))
1005            .expect(1) // No retries despite max_retries=3 and 500 being retryable by default
1006            .mount(&mock_server)
1007            .await;
1008
1009        let client =
1010            create_test_client_with_predicate(3, 30000, RetryPredicate::Replace(|_err| false));
1011        let response = client
1012            .execute_with_retry(|| client.inner().get(format!("{}/test", mock_server.uri())))
1013            .await;
1014
1015        assert!(response.is_err());
1016    }
1017
1018    #[tokio::test]
1019    async fn test_retry_predicate_applies_to_timeouts() {
1020        // Regression: the `Err(_)` (tokio timeout) arm previously bypassed
1021        // `should_retry`, so `Replace(|_| false)` would still retry timeouts.
1022        let mock_server = MockServer::start().await;
1023
1024        Mock::given(method("GET"))
1025            .and(path("/test"))
1026            .respond_with(
1027                ResponseTemplate::new(200)
1028                    .set_body_string("Success")
1029                    .set_delay(Duration::from_secs(5)),
1030            )
1031            .expect(1) // Replace(|_| false) must veto the retry
1032            .mount(&mock_server)
1033            .await;
1034
1035        let client =
1036            create_test_client_with_predicate(3, 100, RetryPredicate::Replace(|_err| false));
1037        let response = client
1038            .execute_with_retry(|| client.inner().get(format!("{}/test", mock_server.uri())))
1039            .await;
1040
1041        assert!(response.is_err());
1042    }
1043
1044    #[tokio::test]
1045    async fn test_retry_predicate_replace_can_force_retries() {
1046        // `Replace(|_| true)` makes a normally-non-retryable 400 retry, proving
1047        // Replace bypasses the default `is_retryable` decision tree.
1048        let mock_server = MockServer::start().await;
1049
1050        Mock::given(method("GET"))
1051            .and(path("/test"))
1052            .respond_with(ResponseTemplate::new(400).set_body_string("Bad request"))
1053            .expect(3) // max_retries gates total attempts; see should_retry
1054            .mount(&mock_server)
1055            .await;
1056
1057        let client =
1058            create_test_client_with_predicate(3, 30000, RetryPredicate::Replace(|_err| true));
1059        let response = client
1060            .execute_with_retry(|| client.inner().get(format!("{}/test", mock_server.uri())))
1061            .await;
1062
1063        assert!(response.is_err());
1064    }
1065
1066    #[tokio::test]
1067    async fn test_retry_predicate_replace_bypasses_retry_server_errors_gate() {
1068        // `Replace` is the sole authority on whether to retry — it must
1069        // bypass `retry_server_errors=false`, which would otherwise hard-stop
1070        // any 5xx retry under `Default` / `DefaultExcept`.
1071        let mock_server = MockServer::start().await;
1072
1073        Mock::given(method("GET"))
1074            .and(path("/test"))
1075            .respond_with(ResponseTemplate::new(500).set_body_string("Internal error"))
1076            .expect(3)
1077            .mount(&mock_server)
1078            .await;
1079
1080        let config = ClientConfig {
1081            timeout: Duration::from_millis(30000),
1082            retry_config: RetryConfig {
1083                max_retries: 3,
1084                initial_backoff_ms: 10,
1085                retry_server_errors: false,
1086                retry_predicate: RetryPredicate::Replace(|_err| true),
1087            },
1088            ..Default::default()
1089        };
1090        let client = OdosHttpClient::with_config(config).unwrap();
1091        let response = client
1092            .execute_with_retry(|| client.inner().get(format!("{}/test", mock_server.uri())))
1093            .await;
1094
1095        match response {
1096            Err(OdosError::Api { status, .. }) => {
1097                assert_eq!(status, StatusCode::INTERNAL_SERVER_ERROR);
1098            }
1099            other => panic!("Expected OdosError::Api with 500 status, got: {other:?}"),
1100        }
1101    }
1102
1103    #[tokio::test]
1104    async fn test_retry_predicate_default_except_vetoes_retryable_code() {
1105        // `DefaultExcept` vetoes a code that the default tree would retry.
1106        // 3130 = PricingInternal is classified as retryable by
1107        // `OdosErrorCode::is_retryable`, so without the veto it would retry.
1108        let mock_server = MockServer::start().await;
1109
1110        let error_json = r#"{
1111            "detail": "Pricing service internal error",
1112            "traceId": "30becdc8-a021-4491-8201-a17b657204e0",
1113            "errorCode": 3130
1114        }"#;
1115
1116        Mock::given(method("GET"))
1117            .and(path("/test"))
1118            .respond_with(ResponseTemplate::new(500).set_body_string(error_json))
1119            .expect(1) // Vetoed → no retries
1120            .mount(&mock_server)
1121            .await;
1122
1123        let client = create_test_client_with_predicate(
1124            3,
1125            30000,
1126            RetryPredicate::DefaultExcept(|err| {
1127                err.error_code() == Some(&OdosErrorCode::PricingInternal)
1128            }),
1129        );
1130        let response = client
1131            .execute_with_retry(|| client.inner().get(format!("{}/test", mock_server.uri())))
1132            .await;
1133
1134        assert!(response.is_err());
1135        match response {
1136            Err(OdosError::Api { body, .. }) => {
1137                assert_eq!(body.code, OdosErrorCode::PricingInternal);
1138            }
1139            other => panic!("Expected OdosError::Api with PricingInternal, got: {other:?}"),
1140        }
1141    }
1142
1143    #[tokio::test]
1144    async fn test_retry_predicate_default_except_falls_through_when_not_matched() {
1145        // When the veto returns `false`, behaviour must match the default tree:
1146        // 502 still retries to success.
1147        let mock_server = MockServer::start().await;
1148
1149        Mock::given(method("GET"))
1150            .and(path("/test"))
1151            .respond_with(create_retry_mock(502, "Bad gateway".to_string(), 2))
1152            .mount(&mock_server)
1153            .await;
1154
1155        let client = create_test_client_with_predicate(
1156            3,
1157            30000,
1158            RetryPredicate::DefaultExcept(|_err| false),
1159        );
1160        let response = client
1161            .execute_with_retry(|| client.inner().get(format!("{}/test", mock_server.uri())))
1162            .await;
1163
1164        assert!(
1165            response.is_ok(),
1166            "502 should still be retried when DefaultExcept veto returns false"
1167        );
1168    }
1169
1170    #[tokio::test]
1171    async fn test_network_error_retryable() {
1172        // Test with an invalid URL that will cause a connection error
1173        let client = create_test_client(2, 100);
1174
1175        let response = client
1176            .execute_with_retry(|| client.inner().get("http://localhost:1"))
1177            .await;
1178
1179        // Should fail after retries
1180        assert!(response.is_err());
1181        if let Err(e) = response {
1182            assert!(matches!(e, OdosError::Http(_)));
1183        }
1184    }
1185
1186    #[test]
1187    fn test_accessor_methods() {
1188        let config = ClientConfig {
1189            timeout: Duration::from_secs(45),
1190            retry_config: RetryConfig {
1191                max_retries: 5,
1192                ..Default::default()
1193            },
1194            ..Default::default()
1195        };
1196        let client = OdosHttpClient::with_config(config.clone()).unwrap();
1197
1198        // Test config() accessor
1199        assert_eq!(client.config().timeout, Duration::from_secs(45));
1200        assert_eq!(client.config().retry_config.max_retries, 5);
1201
1202        // Test inner() accessor - just verify it returns a Client
1203        let _inner: &reqwest::Client = client.inner();
1204    }
1205
1206    #[test]
1207    fn test_default_client() {
1208        let client = OdosHttpClient::default();
1209
1210        // Should use default config
1211        assert_eq!(client.config().timeout, Duration::from_secs(30));
1212        assert_eq!(client.config().retry_config.max_retries, 3);
1213    }
1214
1215    #[test]
1216    fn test_extract_retry_after_valid_numeric() {
1217        let response = reqwest::Response::from(
1218            http::Response::builder()
1219                .status(429)
1220                .header("retry-after", "30")
1221                .body("")
1222                .unwrap(),
1223        );
1224
1225        let retry_after = extract_retry_after(&response);
1226        assert_eq!(retry_after, Some(Duration::from_secs(30)));
1227    }
1228
1229    #[test]
1230    fn test_extract_retry_after_missing_header() {
1231        let response =
1232            reqwest::Response::from(http::Response::builder().status(429).body("").unwrap());
1233
1234        let retry_after = extract_retry_after(&response);
1235        assert_eq!(retry_after, None);
1236    }
1237
1238    #[test]
1239    fn test_extract_retry_after_malformed_value() {
1240        let response = reqwest::Response::from(
1241            http::Response::builder()
1242                .status(429)
1243                .header("retry-after", "not-a-number")
1244                .body("")
1245                .unwrap(),
1246        );
1247
1248        let retry_after = extract_retry_after(&response);
1249        assert_eq!(retry_after, None);
1250    }
1251
1252    #[test]
1253    fn test_extract_retry_after_zero_value() {
1254        let response = reqwest::Response::from(
1255            http::Response::builder()
1256                .status(429)
1257                .header("retry-after", "0")
1258                .body("")
1259                .unwrap(),
1260        );
1261
1262        let retry_after = extract_retry_after(&response);
1263        assert_eq!(retry_after, Some(Duration::from_secs(0)));
1264    }
1265
1266    #[tokio::test]
1267    async fn test_rate_limit_with_retry_after_zero() {
1268        let mock_server = MockServer::start().await;
1269
1270        // Mock returns 429 with Retry-After: 0
1271        Mock::given(method("GET"))
1272            .and(path("/test"))
1273            .respond_with(
1274                ResponseTemplate::new(429)
1275                    .set_body_string("Rate limit exceeded")
1276                    .insert_header("retry-after", "0"),
1277            )
1278            .expect(1) // Should only be called once (no retries)
1279            .mount(&mock_server)
1280            .await;
1281
1282        let client = create_test_client(3, 30000);
1283        let response = client
1284            .execute_with_retry(|| client.inner().get(format!("{}/test", mock_server.uri())))
1285            .await;
1286
1287        // Rate limits should return immediately without retry (even with Retry-After: 0)
1288        assert!(
1289            response.is_err(),
1290            "Rate limit should return error immediately"
1291        );
1292
1293        if let Err(OdosError::RateLimit {
1294            retry_after, body, ..
1295        }) = response
1296        {
1297            assert!(body.message.contains("Rate limit"));
1298            assert_eq!(retry_after, Some(Duration::from_secs(0)));
1299        } else {
1300            panic!("Expected RateLimit error, got: {response:?}");
1301        }
1302    }
1303
1304    #[test]
1305    fn test_extract_retry_after_large_value() {
1306        let response = reqwest::Response::from(
1307            http::Response::builder()
1308                .status(429)
1309                .header("retry-after", "3600")
1310                .body("")
1311                .unwrap(),
1312        );
1313
1314        let retry_after = extract_retry_after(&response);
1315        assert_eq!(retry_after, Some(Duration::from_secs(3600)));
1316    }
1317
1318    #[test]
1319    fn test_extract_retry_after_invalid_utf8() {
1320        let response = reqwest::Response::from(
1321            http::Response::builder()
1322                .status(429)
1323                .header("retry-after", vec![0xff, 0xfe])
1324                .body("")
1325                .unwrap(),
1326        );
1327
1328        let retry_after = extract_retry_after(&response);
1329        assert_eq!(retry_after, None);
1330    }
1331
1332    #[test]
1333    fn test_client_config_debug_redacts_api_key() {
1334        use crate::ApiKey;
1335        use uuid::Uuid;
1336
1337        let uuid = Uuid::new_v4();
1338        let uuid_str = uuid.to_string();
1339        let api_key = ApiKey::new(uuid);
1340
1341        let config = ClientConfig {
1342            api_key: Some(api_key),
1343            ..Default::default()
1344        };
1345
1346        let debug_output = format!("{:?}", config);
1347
1348        // Verify the debug output contains "REDACTED"
1349        assert!(debug_output.contains("[REDACTED]"));
1350
1351        // Verify the actual UUID is NOT in the debug output
1352        assert!(
1353            !debug_output.contains(&uuid_str),
1354            "API key UUID should not appear in debug output, but found: {}",
1355            uuid_str
1356        );
1357    }
1358
1359    #[tokio::test]
1360    async fn test_max_retries_zero() {
1361        let mock_server = MockServer::start().await;
1362
1363        // Mock that would normally trigger retries
1364        Mock::given(method("GET"))
1365            .and(path("/test"))
1366            .respond_with(ResponseTemplate::new(500).set_body_string("Server error"))
1367            .expect(1) // Should only be called once
1368            .mount(&mock_server)
1369            .await;
1370
1371        let client = create_test_client(0, 30000); // max_retries = 0
1372        let response = client
1373            .execute_with_retry(|| client.inner().get(format!("{}/test", mock_server.uri())))
1374            .await;
1375
1376        // Should fail immediately without retrying
1377        assert!(response.is_err());
1378        if let Err(e) = response {
1379            assert!(
1380                matches!(e, OdosError::Api { status, .. } if status == StatusCode::INTERNAL_SERVER_ERROR)
1381            );
1382        }
1383    }
1384
1385    #[tokio::test]
1386    async fn test_parse_structured_error_response() {
1387        // Create a mock response with structured error
1388        let error_json = r#"{
1389            "detail": "Error getting quote, please try again",
1390            "traceId": "10becdc8-a021-4491-8201-a17b657204e0",
1391            "errorCode": 2999
1392        }"#;
1393
1394        let http_response = http::Response::builder()
1395            .status(500)
1396            .body(error_json)
1397            .unwrap();
1398        let response = reqwest::Response::from(http_response);
1399
1400        let parsed = parse_error_response(response).await;
1401
1402        assert_eq!(parsed.message, "Error getting quote, please try again");
1403        assert_eq!(parsed.code, OdosErrorCode::AlgoInternal);
1404        assert!(parsed.trace_id.is_some());
1405        assert_eq!(
1406            parsed.trace_id.unwrap().to_string(),
1407            "10becdc8-a021-4491-8201-a17b657204e0"
1408        );
1409    }
1410
1411    #[tokio::test]
1412    async fn test_parse_unstructured_error_response() {
1413        // Create a mock response with plain text error
1414        let http_response = http::Response::builder()
1415            .status(500)
1416            .body("Internal server error")
1417            .unwrap();
1418        let response = reqwest::Response::from(http_response);
1419
1420        let parsed = parse_error_response(response).await;
1421
1422        assert_eq!(parsed.message, "Internal server error");
1423        assert_eq!(parsed.code, OdosErrorCode::Unknown(0));
1424        assert!(parsed.trace_id.is_none());
1425    }
1426
1427    #[tokio::test]
1428    async fn test_api_error_with_structured_response() {
1429        let mock_server = MockServer::start().await;
1430
1431        let error_json = r#"{
1432            "detail": "Invalid chain ID",
1433            "traceId": "a0b1c2d3-e4f5-6789-0abc-def123456789",
1434            "errorCode": 4001
1435        }"#;
1436
1437        Mock::given(method("GET"))
1438            .and(path("/test"))
1439            .respond_with(ResponseTemplate::new(400).set_body_string(error_json))
1440            .expect(1)
1441            .mount(&mock_server)
1442            .await;
1443
1444        let client = create_test_client(0, 30000);
1445        let response = client
1446            .execute_with_retry(|| client.inner().get(format!("{}/test", mock_server.uri())))
1447            .await;
1448
1449        assert!(response.is_err());
1450        if let Err(e) = response {
1451            // Check that it's an API error
1452            assert!(matches!(e, OdosError::Api { .. }));
1453
1454            // Check error code
1455            let error_code = e.error_code();
1456            assert!(error_code.is_some());
1457            assert!(error_code.unwrap().is_invalid_chain_id());
1458
1459            // Check trace ID
1460            let trace_id = e.trace_id();
1461            assert!(trace_id.is_some());
1462        } else {
1463            panic!("Expected error, got success");
1464        }
1465    }
1466
1467    #[tokio::test]
1468    async fn test_api_error_with_null_trace_id_preserves_error_code() {
1469        let mock_server = MockServer::start().await;
1470
1471        let error_json = r#"{
1472            "detail": "Error getting quote, please try again",
1473            "traceId": null,
1474            "errorCode": 2999
1475        }"#;
1476
1477        Mock::given(method("GET"))
1478            .and(path("/test"))
1479            .respond_with(ResponseTemplate::new(500).set_body_string(error_json))
1480            .expect(1)
1481            .mount(&mock_server)
1482            .await;
1483
1484        let client = create_test_client(0, 30000);
1485        let response = client
1486            .execute_with_retry(|| client.inner().get(format!("{}/test", mock_server.uri())))
1487            .await;
1488
1489        let err = response.expect_err("expected API error");
1490        assert!(matches!(err, OdosError::Api { .. }));
1491        assert_eq!(err.error_code(), Some(&OdosErrorCode::AlgoInternal));
1492        assert_eq!(err.trace_id(), None);
1493    }
1494
1495    #[tokio::test]
1496    async fn test_client_config_failure() {
1497        // Test that invalid configs are handled gracefully
1498        // Using an extremely high connection limit
1499        let config = ClientConfig {
1500            max_connections: usize::MAX,
1501            ..Default::default()
1502        };
1503
1504        // This might not actually fail with reqwest, but we test the error handling path
1505        let result = OdosHttpClient::with_config(config);
1506
1507        // If it succeeds, that's fine - reqwest is quite permissive
1508        // If it fails, we verify proper error wrapping
1509        match result {
1510            Ok(_) => {
1511                // Client creation succeeded - this is actually normal
1512            }
1513            Err(e) => {
1514                // If it fails, should be wrapped as Http error
1515                assert!(matches!(e, OdosError::Http(_)));
1516            }
1517        }
1518    }
1519
1520    #[tokio::test]
1521    async fn test_rate_limit_with_trace_id() {
1522        let mock_server = MockServer::start().await;
1523
1524        let error_json = r#"{
1525            "detail": "Rate limit exceeded",
1526            "traceId": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
1527            "errorCode": 4299
1528        }"#;
1529
1530        Mock::given(method("GET"))
1531            .and(path("/test"))
1532            .respond_with(
1533                ResponseTemplate::new(429)
1534                    .set_body_string(error_json)
1535                    .insert_header("retry-after", "30"),
1536            )
1537            .expect(1)
1538            .mount(&mock_server)
1539            .await;
1540
1541        let client = create_test_client(0, 30000);
1542        let response = client
1543            .execute_with_retry(|| client.inner().get(format!("{}/test", mock_server.uri())))
1544            .await;
1545
1546        assert!(response.is_err());
1547        if let Err(e) = response {
1548            // Verify it's a rate limit error
1549            assert!(e.is_rate_limit());
1550
1551            // Verify trace_id is present
1552            let trace_id = e.trace_id();
1553            assert!(trace_id.is_some());
1554            assert_eq!(
1555                trace_id.unwrap().to_string(),
1556                "a1b2c3d4-e5f6-7890-abcd-ef1234567890"
1557            );
1558
1559            // Verify the error message includes the trace ID
1560            let error_msg = e.to_string();
1561            assert!(error_msg.contains("a1b2c3d4-e5f6-7890-abcd-ef1234567890"));
1562            assert!(error_msg.contains("[trace:"));
1563        } else {
1564            panic!("Expected error, got success");
1565        }
1566    }
1567}