Skip to main content

toolkit_http/
config.rs

1use std::collections::HashSet;
2use std::path::PathBuf;
3use std::time::Duration;
4
5/// Default User-Agent string for HTTP requests
6pub const DEFAULT_USER_AGENT: &str = concat!("toolkit-http/", env!("CARGO_PKG_VERSION"));
7
8/// Standard idempotency key header name (display form)
9pub const IDEMPOTENCY_KEY_HEADER: &str = "Idempotency-Key";
10
11/// Lowercase idempotency key header for `HeaderName` construction
12const IDEMPOTENCY_KEY_HEADER_LOWER: &str = "idempotency-key";
13
14/// Conditions that trigger a retry
15#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
16#[non_exhaustive]
17pub enum RetryTrigger {
18    /// Transport-level errors (connection refused, DNS failure, reset, etc.)
19    TransportError,
20    /// Request timeout
21    Timeout,
22    /// Specific HTTP status code
23    Status(u16),
24    /// Error that is never retryable (e.g., `DeadlineExceeded`, `ServiceClosed`)
25    NonRetryable,
26}
27
28impl RetryTrigger {
29    /// Create a trigger for HTTP 429 Too Many Requests
30    pub const TOO_MANY_REQUESTS: Self = Self::Status(429);
31    /// Create a trigger for HTTP 408 Request Timeout
32    pub const REQUEST_TIMEOUT: Self = Self::Status(408);
33    /// Create a trigger for HTTP 500 Internal Server Error
34    pub const INTERNAL_SERVER_ERROR: Self = Self::Status(500);
35    /// Create a trigger for HTTP 502 Bad Gateway
36    pub const BAD_GATEWAY: Self = Self::Status(502);
37    /// Create a trigger for HTTP 503 Service Unavailable
38    pub const SERVICE_UNAVAILABLE: Self = Self::Status(503);
39    /// Create a trigger for HTTP 504 Gateway Timeout
40    pub const GATEWAY_TIMEOUT: Self = Self::Status(504);
41}
42
43/// Check if HTTP method is idempotent (safe to retry) per RFC 9110.
44///
45/// Idempotent methods: GET, HEAD, PUT, DELETE, OPTIONS, TRACE.
46/// Non-idempotent methods: POST, PATCH.
47#[must_use]
48pub fn is_idempotent_method(method: &http::Method) -> bool {
49    matches!(
50        *method,
51        http::Method::GET
52            | http::Method::HEAD
53            | http::Method::PUT
54            | http::Method::DELETE
55            | http::Method::OPTIONS
56            | http::Method::TRACE
57    )
58}
59
60/// Exponential backoff configuration for retries
61///
62/// Computes delay as: `min(initial * multiplier^attempt, max)` with optional jitter.
63#[derive(Debug, Clone)]
64pub struct ExponentialBackoff {
65    /// Initial backoff duration (default: 100ms)
66    pub initial: Duration,
67
68    /// Maximum backoff duration (default: 10s)
69    pub max: Duration,
70
71    /// Backoff multiplier for exponential growth (default: 2.0)
72    pub multiplier: f64,
73
74    /// Enable jitter to prevent thundering herd (default: true)
75    ///
76    /// When enabled, adds random delay of 0-25% to each backoff.
77    pub jitter: bool,
78}
79
80impl Default for ExponentialBackoff {
81    fn default() -> Self {
82        Self {
83            initial: Duration::from_millis(100),
84            max: Duration::from_secs(10),
85            multiplier: 2.0,
86            jitter: true,
87        }
88    }
89}
90
91impl ExponentialBackoff {
92    /// Create backoff with custom initial and max durations
93    #[must_use]
94    pub fn new(initial: Duration, max: Duration) -> Self {
95        Self {
96            initial,
97            max,
98            ..Default::default()
99        }
100    }
101
102    /// Create fast backoff for testing (1ms initial, 100ms max, no jitter)
103    #[must_use]
104    pub fn fast() -> Self {
105        Self {
106            initial: Duration::from_millis(1),
107            max: Duration::from_millis(100),
108            multiplier: 2.0,
109            jitter: false,
110        }
111    }
112
113    /// Create aggressive backoff (50ms initial, 30s max)
114    #[must_use]
115    pub fn aggressive() -> Self {
116        Self {
117            initial: Duration::from_millis(50),
118            max: Duration::from_secs(30),
119            multiplier: 2.0,
120            jitter: true,
121        }
122    }
123}
124
125/// Retry policy configuration with exponential backoff
126///
127/// Retry decisions are based on two sets of triggers:
128/// - `always_retry`: Conditions that always trigger retry (regardless of HTTP method)
129/// - `idempotent_retry`: Conditions that trigger retry only for idempotent methods (GET, HEAD, PUT, DELETE, OPTIONS, TRACE)
130///   OR when the request has an idempotency key header
131///
132/// **Safety by default**: Non-idempotent methods (POST, PATCH) are only retried on
133/// triggers in `always_retry` unless the request contains an idempotency key header.
134#[derive(Debug, Clone)]
135pub struct RetryConfig {
136    /// Maximum number of retries after the initial attempt (0 = no retries, default: 3)
137    /// Total attempts = 1 (initial) + `max_retries`
138    pub max_retries: usize,
139
140    /// Backoff strategy configuration
141    pub backoff: ExponentialBackoff,
142
143    /// Triggers that always retry regardless of HTTP method
144    /// Default: [Status(429)]
145    ///
146    /// **Note**: `TransportError` and `Timeout` are NOT in `always_retry` by default to avoid
147    /// duplicating non-idempotent requests. They are in `idempotent_retry` instead.
148    pub always_retry: HashSet<RetryTrigger>,
149
150    /// Triggers that only retry for idempotent methods (GET, HEAD, OPTIONS, TRACE)
151    /// OR when the request has an idempotency key header.
152    /// Default: `[TransportError, Timeout, Status(408), Status(500), Status(502), Status(503), Status(504)]`
153    pub idempotent_retry: HashSet<RetryTrigger>,
154
155    /// If true, ignore the `Retry-After` HTTP header and always use backoff policy.
156    /// If false (default), use `Retry-After` value when present for computing retry delay.
157    pub ignore_retry_after: bool,
158
159    /// Maximum bytes to drain from response body before retrying on HTTP status.
160    /// Draining the body allows connection reuse. Default: 64 KiB.
161    /// If the body exceeds this limit, draining stops and the connection may not be reused.
162    ///
163    /// **Note**: This limit applies to **decompressed** bytes. For compressed responses,
164    /// the actual network traffic may be smaller than the configured limit.
165    pub retry_response_drain_limit: usize,
166
167    /// Whether to skip draining response body on retry.
168    ///
169    /// When `true`, the response body is not drained before retrying, meaning
170    /// connections may not be reused after retryable errors. This saves CPU/memory
171    /// by not decompressing error response bodies.
172    ///
173    /// # Performance Tradeoff
174    ///
175    /// Body draining operates on **decompressed** bytes (after `DecompressionLayer`).
176    /// When servers return compressed error responses (e.g., gzip-compressed 503 HTML),
177    /// draining requires CPU to decompress the body even though we discard the content.
178    ///
179    /// **Recommendation:**
180    /// - Set to `true` for high-throughput services where connection reuse is less
181    ///   important than CPU efficiency, or when error responses are typically compressed
182    /// - Keep `false` (default) for low-to-medium throughput services where connection
183    ///   reuse reduces latency and TCP connection overhead
184    ///
185    /// The `Content-Length` header is checked before draining; bodies larger than
186    /// `retry_response_drain_limit` are skipped automatically regardless of this setting.
187    ///
188    /// Default: `false` (drain enabled for connection reuse)
189    pub skip_drain_on_retry: bool,
190
191    /// Header name that, when present on a request, enables retry for non-idempotent methods.
192    /// Default: "Idempotency-Key"
193    ///
194    /// Set to `None` to disable idempotency-key based retry (only `always_retry` triggers
195    /// will apply to non-idempotent methods).
196    ///
197    /// When a request includes this header, triggers in `idempotent_retry` will apply
198    /// regardless of the HTTP method.
199    ///
200    /// Pre-parsed at config construction to avoid runtime parsing overhead.
201    pub idempotency_key_header: Option<http::header::HeaderName>,
202}
203
204/// Default drain limit for response bodies before retry (64 KiB)
205pub const DEFAULT_RETRY_RESPONSE_DRAIN_LIMIT: usize = 64 * 1024;
206
207impl Default for RetryConfig {
208    fn default() -> Self {
209        Self {
210            max_retries: 3,
211            backoff: ExponentialBackoff::default(),
212            // Only 429 always retries - server explicitly requests retry
213            always_retry: HashSet::from([RetryTrigger::TOO_MANY_REQUESTS]),
214            // TransportError and Timeout moved here for safety - only retry idempotent methods
215            // or when idempotency key header is present
216            idempotent_retry: HashSet::from([
217                RetryTrigger::TransportError,
218                RetryTrigger::Timeout,
219                RetryTrigger::REQUEST_TIMEOUT,
220                RetryTrigger::INTERNAL_SERVER_ERROR,
221                RetryTrigger::BAD_GATEWAY,
222                RetryTrigger::SERVICE_UNAVAILABLE,
223                RetryTrigger::GATEWAY_TIMEOUT,
224            ]),
225            ignore_retry_after: false,
226            retry_response_drain_limit: DEFAULT_RETRY_RESPONSE_DRAIN_LIMIT,
227            skip_drain_on_retry: false,
228            idempotency_key_header: Some(http::header::HeaderName::from_static(
229                IDEMPOTENCY_KEY_HEADER_LOWER,
230            )),
231        }
232    }
233}
234
235impl RetryConfig {
236    /// Create config with no retries
237    #[must_use]
238    pub fn disabled() -> Self {
239        Self {
240            max_retries: 0,
241            ..Default::default()
242        }
243    }
244
245    /// Create config with aggressive retry policy (retries all 5xx for any method)
246    ///
247    /// **WARNING**: This policy retries non-idempotent methods on transport errors
248    /// and timeouts, which may cause duplicate side effects. Use with caution.
249    #[must_use]
250    pub fn aggressive() -> Self {
251        Self {
252            max_retries: 5,
253            backoff: ExponentialBackoff::aggressive(),
254            always_retry: HashSet::from([
255                RetryTrigger::TransportError,
256                RetryTrigger::Timeout,
257                RetryTrigger::TOO_MANY_REQUESTS,
258                RetryTrigger::REQUEST_TIMEOUT,
259                RetryTrigger::INTERNAL_SERVER_ERROR,
260                RetryTrigger::BAD_GATEWAY,
261                RetryTrigger::SERVICE_UNAVAILABLE,
262                RetryTrigger::GATEWAY_TIMEOUT,
263            ]),
264            idempotent_retry: HashSet::new(),
265            ignore_retry_after: false,
266            retry_response_drain_limit: DEFAULT_RETRY_RESPONSE_DRAIN_LIMIT,
267            skip_drain_on_retry: false,
268            idempotency_key_header: Some(http::header::HeaderName::from_static(
269                IDEMPOTENCY_KEY_HEADER_LOWER,
270            )),
271        }
272    }
273
274    /// Check if the given trigger should cause a retry for the given HTTP method
275    ///
276    /// # Arguments
277    /// * `trigger` - The condition that triggered the retry consideration
278    /// * `method` - The HTTP method of the request
279    /// * `has_idempotency_key` - Whether the request has an idempotency key header
280    ///
281    /// # Retry Logic
282    /// - Triggers in `always_retry` are always retried regardless of method
283    /// - Triggers in `idempotent_retry` are retried if:
284    ///   - The method is idempotent (GET, HEAD, PUT, DELETE, OPTIONS, TRACE), OR
285    ///   - The request has an idempotency key header
286    #[must_use]
287    pub fn should_retry(
288        &self,
289        trigger: RetryTrigger,
290        method: &http::Method,
291        has_idempotency_key: bool,
292    ) -> bool {
293        if self.always_retry.contains(&trigger) {
294            return true;
295        }
296        if self.idempotent_retry.contains(&trigger)
297            && (is_idempotent_method(method) || has_idempotency_key)
298        {
299            return true;
300        }
301        false
302    }
303}
304
305/// Rate limiting / concurrency limit configuration
306#[derive(Debug, Clone)]
307pub struct RateLimitConfig {
308    /// Maximum concurrent requests (default: 100)
309    pub max_concurrent_requests: usize,
310}
311
312impl Default for RateLimitConfig {
313    fn default() -> Self {
314        Self {
315            max_concurrent_requests: 100,
316        }
317    }
318}
319
320impl RateLimitConfig {
321    /// Create config with unlimited concurrency
322    #[must_use]
323    pub fn unlimited() -> Self {
324        Self {
325            max_concurrent_requests: usize::MAX,
326        }
327    }
328
329    /// Create config with very conservative limit
330    #[must_use]
331    pub fn conservative() -> Self {
332        Self {
333            max_concurrent_requests: 10,
334        }
335    }
336}
337
338/// Configuration for redirect behavior
339///
340/// Controls how the HTTP client handles 3xx redirect responses with security protections.
341///
342/// ## Security Features
343///
344/// - **Same-origin enforcement**: By default, only follows redirects to the same host
345/// - **Header stripping**: Removes `Authorization`, `Cookie` on cross-origin redirects
346/// - **Downgrade protection**: Blocks HTTPS → HTTP redirects
347/// - **Host allow-list**: Configurable list of trusted redirect targets
348///
349/// ## Example
350///
351/// ```rust,ignore
352/// use toolkit_http::RedirectConfig;
353/// use std::collections::HashSet;
354///
355/// // Permissive mode for general-purpose clients
356/// let config = RedirectConfig::permissive();
357///
358/// // Custom allow-list for trusted hosts
359/// let config = RedirectConfig {
360///     same_origin_only: true,
361///     allowed_redirect_hosts: HashSet::from(["cdn.example.com".to_string()]),
362///     ..Default::default()
363/// };
364/// ```
365#[derive(Debug, Clone)]
366pub struct RedirectConfig {
367    /// Maximum number of redirects to follow (default: 10)
368    ///
369    /// Set to `0` to disable redirect following entirely.
370    pub max_redirects: usize,
371
372    /// Only allow same-origin redirects (default: true)
373    ///
374    /// When `true`, redirects to different hosts are blocked unless the target
375    /// host is in `allowed_redirect_hosts`.
376    ///
377    /// **Security**: This is the safest default, preventing SSRF attacks where
378    /// a malicious server redirects requests to internal services.
379    pub same_origin_only: bool,
380
381    /// Hosts that are allowed as redirect targets even when `same_origin_only` is true
382    ///
383    /// Use this to allow redirects to known, trusted hosts (e.g., CDN domains,
384    /// authentication servers).
385    ///
386    /// **Note**: Entries should be hostnames only, without scheme or port.
387    /// Example: `"cdn.example.com"`, not `"https://cdn.example.com"`.
388    pub allowed_redirect_hosts: HashSet<String>,
389
390    /// Strip sensitive headers on cross-origin redirects (default: true)
391    ///
392    /// When a redirect goes to a different origin (even if allowed), this removes:
393    /// - `Authorization` header (prevents credential leakage)
394    /// - `Cookie` header (prevents session hijacking)
395    /// - `Proxy-Authorization` header
396    ///
397    /// **Security**: Always keep this enabled unless you have specific requirements.
398    pub strip_sensitive_headers: bool,
399
400    /// Allow HTTPS → HTTP downgrades (default: false)
401    ///
402    /// When `false`, redirects from HTTPS to HTTP are blocked.
403    ///
404    /// **Security**: Downgrades expose traffic to interception. Only enable
405    /// for testing with local mock servers.
406    pub allow_https_downgrade: bool,
407}
408
409impl Default for RedirectConfig {
410    fn default() -> Self {
411        Self {
412            max_redirects: 10,
413            same_origin_only: true,
414            allowed_redirect_hosts: HashSet::new(),
415            strip_sensitive_headers: true,
416            allow_https_downgrade: false,
417        }
418    }
419}
420
421impl RedirectConfig {
422    /// Create a permissive configuration that allows all redirects with header stripping
423    ///
424    /// This is suitable for general-purpose HTTP clients that need to follow
425    /// redirects to any host, but still want protection against credential leakage.
426    ///
427    /// **Note**: This configuration still blocks HTTPS → HTTP downgrades.
428    #[must_use]
429    pub fn permissive() -> Self {
430        Self {
431            max_redirects: 10,
432            same_origin_only: false,
433            allowed_redirect_hosts: HashSet::new(),
434            strip_sensitive_headers: true,
435            allow_https_downgrade: false,
436        }
437    }
438
439    /// Create a configuration that disables redirect following
440    #[must_use]
441    pub fn disabled() -> Self {
442        Self {
443            max_redirects: 0,
444            ..Default::default()
445        }
446    }
447
448    /// Create a configuration for testing (allows HTTP, permissive)
449    ///
450    /// **WARNING**: Only use for local testing with mock servers.
451    #[must_use]
452    pub fn for_testing() -> Self {
453        Self {
454            max_redirects: 10,
455            same_origin_only: false,
456            allowed_redirect_hosts: HashSet::new(),
457            strip_sensitive_headers: true, // Still strip headers even in tests
458            allow_https_downgrade: true,   // Allow for HTTP mock servers
459        }
460    }
461}
462
463/// TLS root certificate configuration
464#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
465#[non_exhaustive]
466pub enum TlsRootConfig {
467    /// Use Mozilla's root certificates (webpki-roots, no OS dependency)
468    #[default]
469    WebPki,
470    /// Use OS native root certificate store
471    Native,
472}
473
474/// Transport security configuration
475///
476/// Controls whether the client enforces TLS or allows insecure HTTP.
477#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
478#[non_exhaustive]
479pub enum TransportSecurity {
480    /// Require TLS for all connections (HTTPS only)
481    TlsOnly,
482    /// Allow insecure HTTP connections
483    ///
484    /// Use [`HttpClientBuilder::deny_insecure_http`] to switch to `TlsOnly`
485    /// when TLS enforcement is required.
486    ///
487    /// **FIPS**: under `--features fips`, configuring this on a builder causes
488    /// [`HttpClientBuilder::build`] to return [`HttpError::InsecureTransport`].
489    /// Use [`HttpClientConfig::for_testing`] only for non-FIPS local mocks.
490    ///
491    /// [`HttpClientBuilder::deny_insecure_http`]: crate::builder::HttpClientBuilder::deny_insecure_http
492    /// [`HttpClientBuilder::build`]: crate::builder::HttpClientBuilder::build
493    /// [`HttpError::InsecureTransport`]: crate::error::HttpError::InsecureTransport
494    #[default]
495    AllowInsecureHttp,
496}
497
498/// Default transport security for built-in presets.
499///
500/// Under `--features fips` every non-testing preset defaults to
501/// [`TransportSecurity::TlsOnly`] so cleartext HTTP cannot be selected by
502/// accident; otherwise the historical [`TransportSecurity::AllowInsecureHttp`]
503/// default is retained for local development convenience.
504#[cfg(feature = "fips")]
505const DEFAULT_TRANSPORT: TransportSecurity = TransportSecurity::TlsOnly;
506#[cfg(not(feature = "fips"))]
507const DEFAULT_TRANSPORT: TransportSecurity = TransportSecurity::AllowInsecureHttp;
508
509/// Minimum TLS protocol version the client will negotiate.
510///
511/// Maps onto the rustls protocol-version slice passed to
512/// `ClientConfig::builder_with_provider(..).with_protocol_versions(..)`:
513/// - [`TlsVersion::Tls12`] advertises both TLS 1.2 and TLS 1.3 (the historical
514///   `with_safe_default_protocol_versions()` behaviour).
515/// - [`TlsVersion::Tls13`] advertises TLS 1.3 only.
516///
517/// This is a *user* knob; it does not relax FIPS hardening. Under
518/// `--features fips`, `tls::apply_fips_hardening` still asserts
519/// `ClientConfig::fips()`, so a version selection incompatible with the active
520/// FIPS provider surfaces as a [`crate::error::HttpError::Tls`] at build time.
521#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
522#[non_exhaustive]
523pub enum TlsVersion {
524    /// Allow TLS 1.2 and TLS 1.3 (default — matches rustls safe defaults).
525    #[default]
526    Tls12,
527    /// Require TLS 1.3; reject TLS 1.2 handshakes.
528    Tls13,
529}
530
531/// Client-certificate (mutual TLS) identity.
532///
533/// Holds filesystem paths to PEM-encoded material rather than parsed key bytes
534/// so that [`HttpClientConfig`] stays cheaply `Clone`/`Debug` and no private-key
535/// bytes are held in the config. The files are read and parsed lazily in
536/// [`HttpClientBuilder::build`]; IO or parse failures are reported as
537/// [`crate::error::HttpError::Tls`].
538///
539/// [`HttpClientBuilder::build`]: crate::builder::HttpClientBuilder::build
540#[derive(Debug, Clone, PartialEq, Eq)]
541#[non_exhaustive]
542pub struct ClientAuthConfig {
543    /// Path to a PEM file containing the client certificate chain
544    /// (leaf first, then intermediates).
545    pub cert_chain: PathBuf,
546    /// Path to a PEM file containing the client private key
547    /// (PKCS#8, PKCS#1/RSA, or SEC1/EC).
548    pub key: PathBuf,
549}
550
551impl ClientAuthConfig {
552    /// Construct a mutual-TLS identity from PEM cert-chain and key file paths.
553    #[must_use]
554    pub fn new(cert_chain: impl Into<PathBuf>, key: impl Into<PathBuf>) -> Self {
555        Self {
556            cert_chain: cert_chain.into(),
557            key: key.into(),
558        }
559    }
560}
561
562/// TLS handshake configuration for the HTTP client.
563///
564/// Carries knobs that shape the rustls `ClientConfig` beyond the root-trust
565/// strategy (which lives in [`TlsRootConfig`]):
566/// - [`TlsConfig::min_version`] — minimum negotiated protocol version.
567/// - [`TlsConfig::client_auth`] — optional mutual-TLS client identity.
568#[derive(Debug, Clone, Default, PartialEq, Eq)]
569#[non_exhaustive]
570pub struct TlsConfig {
571    /// Minimum TLS protocol version (default: [`TlsVersion::Tls12`]).
572    pub min_version: TlsVersion,
573    /// Optional client-certificate identity for mutual TLS (default: `None`).
574    pub client_auth: Option<ClientAuthConfig>,
575}
576
577/// Overall HTTP client configuration
578#[derive(Debug, Clone)]
579#[non_exhaustive]
580pub struct HttpClientConfig {
581    /// Per-request timeout (default: 30 seconds)
582    ///
583    /// This timeout applies to each individual HTTP request/attempt.
584    /// If retries are enabled, each retry attempt gets its own timeout.
585    pub request_timeout: Duration,
586
587    /// Total timeout spanning all retry attempts (default: None)
588    ///
589    /// When set, the entire operation (including all retries and backoff delays)
590    /// must complete within this duration. If the deadline is exceeded,
591    /// the request fails with `HttpError::DeadlineExceeded(total_timeout)`.
592    ///
593    /// When `None`, there is no total deadline - each attempt can take up to
594    /// `request_timeout`, and retries can continue indefinitely within their limits.
595    pub total_timeout: Option<Duration>,
596
597    /// Maximum response body size in bytes (default: 10 MB)
598    pub max_body_size: usize,
599
600    /// User-Agent header value (default: "toolkit-http/1.0")
601    pub user_agent: String,
602
603    /// Retry policy configuration
604    pub retry: Option<RetryConfig>,
605
606    /// Rate limiting / concurrency configuration
607    pub rate_limit: Option<RateLimitConfig>,
608
609    /// Transport security mode.
610    ///
611    /// Default: `TlsOnly` under `--features fips`, `AllowInsecureHttp` otherwise.
612    /// Only [`HttpClientConfig::for_testing`] keeps `AllowInsecureHttp` regardless
613    /// of features. Under `--features fips`, [`HttpClientBuilder::build`] returns
614    /// [`HttpError::InsecureTransport`] when this is `AllowInsecureHttp`.
615    ///
616    /// Use [`HttpClientBuilder::deny_insecure_http`] to enforce TLS for all connections.
617    ///
618    /// [`HttpClientBuilder::build`]: crate::builder::HttpClientBuilder::build
619    /// [`HttpError::InsecureTransport`]: crate::error::HttpError::InsecureTransport
620    pub transport: TransportSecurity,
621
622    /// TLS root certificate strategy (default: `WebPki`)
623    pub tls_roots: TlsRootConfig,
624
625    /// TLS handshake configuration: minimum protocol version and optional
626    /// mutual-TLS client identity (default: [`TlsConfig::default`] — TLS 1.2
627    /// floor, no client auth).
628    pub tls: TlsConfig,
629
630    /// Enable OpenTelemetry tracing layer (default: false)
631    /// Creates spans for outbound requests and injects trace context headers.
632    pub otel: bool,
633
634    /// Buffer capacity for concurrent request handling (default: 1024)
635    ///
636    /// The HTTP client uses an internal buffer to allow multiple concurrent
637    /// requests without external locking. This sets the maximum number of
638    /// requests that can be queued waiting for processing.
639    pub buffer_capacity: usize,
640
641    /// Redirect policy configuration (default: same-origin only with header stripping)
642    ///
643    /// Controls how 3xx redirect responses are handled with security protections:
644    /// - Same-origin enforcement (SSRF protection)
645    /// - Sensitive header stripping on cross-origin redirects
646    /// - HTTPS downgrade protection
647    ///
648    /// Use `RedirectConfig::permissive()` for general-purpose HTTP client behavior
649    /// that allows cross-origin redirects with header stripping.
650    ///
651    /// Use `RedirectConfig::disabled()` to turn off redirect following entirely.
652    pub redirect: RedirectConfig,
653
654    /// Timeout for idle connections in the pool (default: 90 seconds)
655    ///
656    /// Connections that remain idle (unused) for longer than this duration
657    /// will be closed and removed from the pool. This prevents resource leaks
658    /// and ensures connections don't become stale.
659    ///
660    /// Set to `None` to use hyper-util's default idle timeout.
661    pub pool_idle_timeout: Option<Duration>,
662
663    /// Maximum number of idle connections per host (default: 32)
664    ///
665    /// Limits how many idle connections are kept in the pool for each host.
666    /// Setting this to `0` disables connection reuse entirely.
667    /// Setting this too high may waste resources on rarely-used connections.
668    ///
669    /// **Note**: This only limits *idle* connections. Active connections are
670    /// not limited by this setting.
671    pub pool_max_idle_per_host: usize,
672}
673
674impl Default for HttpClientConfig {
675    fn default() -> Self {
676        Self {
677            request_timeout: Duration::from_secs(30),
678            total_timeout: None,
679            max_body_size: 10 * 1024 * 1024, // 10 MB
680            user_agent: DEFAULT_USER_AGENT.to_owned(),
681            retry: Some(RetryConfig::default()),
682            rate_limit: Some(RateLimitConfig::default()),
683            transport: DEFAULT_TRANSPORT,
684            tls_roots: TlsRootConfig::default(),
685            tls: TlsConfig::default(),
686            otel: false,
687            buffer_capacity: 1024,
688            redirect: RedirectConfig::default(),
689            pool_idle_timeout: Some(Duration::from_secs(90)),
690            pool_max_idle_per_host: 32,
691        }
692    }
693}
694
695impl HttpClientConfig {
696    /// Create minimal configuration (no retry, no rate limit, small timeout)
697    #[must_use]
698    pub fn minimal() -> Self {
699        Self {
700            request_timeout: Duration::from_secs(10),
701            total_timeout: None,
702            max_body_size: 1024 * 1024, // 1 MB
703            user_agent: DEFAULT_USER_AGENT.to_owned(),
704            retry: None,
705            rate_limit: None,
706            transport: DEFAULT_TRANSPORT,
707            tls_roots: TlsRootConfig::default(),
708            tls: TlsConfig::default(),
709            otel: false,
710            buffer_capacity: 256,
711            redirect: RedirectConfig::default(),
712            pool_idle_timeout: Some(Duration::from_secs(30)),
713            pool_max_idle_per_host: 8,
714        }
715    }
716
717    /// Create configuration for infrastructure services (aggressive retry, large timeout)
718    #[must_use]
719    pub fn infra_default() -> Self {
720        Self {
721            request_timeout: Duration::from_mins(1),
722            total_timeout: None,
723            max_body_size: 50 * 1024 * 1024, // 50 MB
724            user_agent: DEFAULT_USER_AGENT.to_owned(),
725            retry: Some(RetryConfig::aggressive()),
726            rate_limit: Some(RateLimitConfig::default()),
727            transport: DEFAULT_TRANSPORT,
728            tls_roots: TlsRootConfig::default(),
729            tls: TlsConfig::default(),
730            otel: false,
731            buffer_capacity: 1024,
732            redirect: RedirectConfig::default(),
733            pool_idle_timeout: Some(Duration::from_mins(2)),
734            pool_max_idle_per_host: 64,
735        }
736    }
737
738    /// Create configuration for `OAuth2` token endpoints (conservative retry)
739    ///
740    /// Token endpoints use POST but are effectively idempotent for retry purposes:
741    /// - Getting a token twice is safe (you'd just use the second one)
742    /// - Transport errors before response mean no token was issued
743    ///
744    /// This config retries on transport errors, timeout, and 429 for all methods.
745    #[must_use]
746    pub fn token_endpoint() -> Self {
747        Self {
748            request_timeout: Duration::from_secs(30),
749            total_timeout: None,
750            max_body_size: 1024 * 1024, // 1 MB
751            user_agent: DEFAULT_USER_AGENT.to_owned(),
752            retry: Some(RetryConfig {
753                max_retries: 3,
754                // For token endpoints: retry transport errors, timeout, and 429
755                // Note: Token requests (POST) are effectively idempotent - getting
756                // a token twice is safe, so we put these in always_retry
757                always_retry: HashSet::from([
758                    RetryTrigger::TransportError,
759                    RetryTrigger::Timeout,
760                    RetryTrigger::TOO_MANY_REQUESTS,
761                ]),
762                idempotent_retry: HashSet::new(), // No additional retries for 5xx
763                ignore_retry_after: false,
764                retry_response_drain_limit: DEFAULT_RETRY_RESPONSE_DRAIN_LIMIT,
765                idempotency_key_header: None, // Not needed - always_retry handles all cases
766                ..RetryConfig::default()
767            }),
768            rate_limit: Some(RateLimitConfig::conservative()),
769            transport: DEFAULT_TRANSPORT,
770            tls_roots: TlsRootConfig::default(),
771            tls: TlsConfig::default(),
772            otel: false,
773            buffer_capacity: 256,
774            redirect: RedirectConfig::default(),
775            pool_idle_timeout: Some(Duration::from_mins(1)),
776            pool_max_idle_per_host: 4,
777        }
778    }
779
780    /// Create configuration for testing with mock servers.
781    ///
782    /// **This is the only built-in preset that sets
783    /// `transport: TransportSecurity::AllowInsecureHttp`** — every other
784    /// preset (`default`, `minimal`, `infra_default`, `token_endpoint`, `sse`)
785    /// uses `DEFAULT_TRANSPORT`, which is `TlsOnly` under `--features fips`.
786    ///
787    /// Under `--features fips`, [`HttpClientBuilder::build`] still rejects
788    /// `AllowInsecureHttp` and returns [`HttpError::InsecureTransport`]; this
789    /// preset is intended for non-FIPS test code that wires up `httpmock` or
790    /// other plaintext mock servers.
791    ///
792    /// [`HttpClientBuilder::build`]: crate::builder::HttpClientBuilder::build
793    /// [`HttpError::InsecureTransport`]: crate::error::HttpError::InsecureTransport
794    #[must_use]
795    pub fn for_testing() -> Self {
796        Self {
797            request_timeout: Duration::from_secs(10),
798            total_timeout: None,
799            max_body_size: 1024 * 1024, // 1 MB
800            user_agent: DEFAULT_USER_AGENT.to_owned(),
801            retry: None,
802            rate_limit: None,
803            transport: TransportSecurity::AllowInsecureHttp,
804            tls_roots: TlsRootConfig::default(),
805            tls: TlsConfig::default(),
806            otel: false,
807            buffer_capacity: 256,
808            redirect: RedirectConfig::for_testing(),
809            pool_idle_timeout: Some(Duration::from_secs(10)),
810            pool_max_idle_per_host: 4,
811        }
812    }
813
814    /// Create configuration optimized for Server-Sent Events (SSE) streaming.
815    ///
816    /// SSE connections are long-lived HTTP requests where the server holds the
817    /// connection open and pushes events. This preset disables retry and rate
818    /// limiting, and sets a permissive request timeout.
819    ///
820    /// # Timeout behavior
821    ///
822    /// `request_timeout` is set to 24 hours rather than truly unlimited,
823    /// because `TimeoutLayer` requires a finite `Duration`. Override if needed:
824    ///
825    /// ```rust,ignore
826    /// let mut config = HttpClientConfig::sse();
827    /// config.request_timeout = Duration::from_secs(3600); // 1 hour
828    /// let client = HttpClientBuilder::with_config(config).build()?;
829    /// ```
830    ///
831    /// # Streaming
832    ///
833    /// Use [`HttpResponse::into_body()`] for streaming — it bypasses the
834    /// `max_body_size` limit. SSE reconnection with `Last-Event-ID` is the
835    /// caller's responsibility.
836    ///
837    /// ```rust,ignore
838    /// let client = HttpClientBuilder::with_config(HttpClientConfig::sse()).build()?;
839    ///
840    /// let response = client
841    ///     .get("https://api.example.com/events")
842    ///     .header("accept", "text/event-stream")
843    ///     .send()
844    ///     .await?;
845    ///
846    /// let mut body = response.into_body();
847    /// while let Some(frame) = body.frame().await {
848    ///     let frame = frame?;
849    ///     if let Some(chunk) = frame.data_ref() {
850    ///         // parse SSE event data
851    ///     }
852    /// }
853    /// ```
854    #[must_use]
855    pub fn sse() -> Self {
856        Self {
857            request_timeout: Duration::from_hours(24), // 24 hours
858            total_timeout: None,
859            max_body_size: 10 * 1024 * 1024, // 10 MB (only for bytes()/json(), not into_body())
860            user_agent: DEFAULT_USER_AGENT.to_owned(),
861            retry: None, // SSE reconnection is protocol-level (Last-Event-ID)
862            rate_limit: None,
863            transport: DEFAULT_TRANSPORT,
864            tls_roots: TlsRootConfig::default(),
865            tls: TlsConfig::default(),
866            otel: false,
867            buffer_capacity: 64,
868            redirect: RedirectConfig::default(),
869            pool_idle_timeout: None, // use hyper-util default
870            pool_max_idle_per_host: 1,
871        }
872    }
873
874    /// Create configuration for a reverse-proxy data plane (e.g. the
875    /// api-gateway edge forwarding to out-of-process gears).
876    ///
877    /// A gateway data plane has different requirements from a general-purpose
878    /// client, because it forwards on behalf of an external caller rather than
879    /// making its own calls:
880    ///
881    /// - **No retries.** The edge must not silently re-send a client's request
882    ///   (duplicating non-idempotent side effects) nor amplify load against an
883    ///   upstream that is already failing. Retry/idempotency is the client's or
884    ///   the upstream's concern.
885    /// - **No client-side rate limit.** A single shared concurrency semaphore
886    ///   across the whole gateway would turn request N+1 into a spurious `503`
887    ///   `Overloaded`; back-pressure belongs to the upstream and the listener.
888    /// - **No response body cap.** [`max_body_size`](Self::max_body_size) is set
889    ///   effectively unbounded so large downloads stream through untruncated
890    ///   (the forwarder streams the body via
891    ///   [`HttpResponse::into_limited_body`](crate::HttpResponse::into_limited_body)).
892    ///   The upstream gear owns its own size limits.
893    /// - **No blanket request timeout.** Set to 24h (the `TimeoutLayer` floor is
894    ///   a finite `Duration`) so long-lived responses — SSE, chat streaming —
895    ///   are not cut off mid-stream at 30s.
896    /// - **No redirect following.** A reverse proxy returns `3xx` to the client
897    ///   verbatim rather than resolving it server-side.
898    #[must_use]
899    pub fn proxy() -> Self {
900        Self {
901            request_timeout: Duration::from_hours(24), // effectively "no blanket timeout"
902            total_timeout: None,
903            max_body_size: usize::MAX, // no cap: stream large downloads untruncated
904            user_agent: DEFAULT_USER_AGENT.to_owned(),
905            retry: None,      // the edge must not re-send or amplify load
906            rate_limit: None, // no gateway-wide concurrency semaphore
907            transport: DEFAULT_TRANSPORT,
908            tls_roots: TlsRootConfig::default(),
909            tls: TlsConfig::default(),
910            otel: false,
911            buffer_capacity: 1024,
912            redirect: RedirectConfig::disabled(), // pass 3xx back to the client
913            pool_idle_timeout: Some(Duration::from_secs(90)),
914            pool_max_idle_per_host: 64, // fan out to many upstream gears
915        }
916    }
917}
918
919#[cfg(test)]
920#[cfg_attr(coverage_nightly, coverage(off))]
921mod tests {
922    use super::*;
923
924    #[test]
925    fn test_retry_trigger_constants() {
926        assert_eq!(RetryTrigger::TOO_MANY_REQUESTS, RetryTrigger::Status(429));
927        assert_eq!(RetryTrigger::REQUEST_TIMEOUT, RetryTrigger::Status(408));
928        assert_eq!(
929            RetryTrigger::INTERNAL_SERVER_ERROR,
930            RetryTrigger::Status(500)
931        );
932        assert_eq!(RetryTrigger::BAD_GATEWAY, RetryTrigger::Status(502));
933        assert_eq!(RetryTrigger::SERVICE_UNAVAILABLE, RetryTrigger::Status(503));
934        assert_eq!(RetryTrigger::GATEWAY_TIMEOUT, RetryTrigger::Status(504));
935    }
936
937    #[test]
938    fn test_is_idempotent_method() {
939        // Idempotent per RFC 9110
940        assert!(is_idempotent_method(&http::Method::GET));
941        assert!(is_idempotent_method(&http::Method::HEAD));
942        assert!(is_idempotent_method(&http::Method::PUT));
943        assert!(is_idempotent_method(&http::Method::DELETE));
944        assert!(is_idempotent_method(&http::Method::OPTIONS));
945        assert!(is_idempotent_method(&http::Method::TRACE));
946        // Non-idempotent
947        assert!(!is_idempotent_method(&http::Method::POST));
948        assert!(!is_idempotent_method(&http::Method::PATCH));
949    }
950
951    #[test]
952    fn test_retry_config_defaults() {
953        let config = RetryConfig::default();
954        assert_eq!(config.max_retries, 3);
955        assert_eq!(config.backoff.initial, Duration::from_millis(100));
956        assert_eq!(config.backoff.max, Duration::from_secs(10));
957        assert!((config.backoff.multiplier - 2.0).abs() < f64::EPSILON);
958        assert!(config.backoff.jitter);
959
960        // Check always_retry defaults - only 429 is always retried
961        assert!(
962            config
963                .always_retry
964                .contains(&RetryTrigger::TOO_MANY_REQUESTS)
965        );
966        assert_eq!(config.always_retry.len(), 1);
967
968        // Check idempotent_retry defaults - includes TransportError and Timeout for safety
969        assert!(
970            config
971                .idempotent_retry
972                .contains(&RetryTrigger::TransportError)
973        );
974        assert!(config.idempotent_retry.contains(&RetryTrigger::Timeout));
975        assert!(
976            config
977                .idempotent_retry
978                .contains(&RetryTrigger::REQUEST_TIMEOUT)
979        );
980        assert!(
981            config
982                .idempotent_retry
983                .contains(&RetryTrigger::INTERNAL_SERVER_ERROR)
984        );
985        assert!(config.idempotent_retry.contains(&RetryTrigger::BAD_GATEWAY));
986        assert!(
987            config
988                .idempotent_retry
989                .contains(&RetryTrigger::SERVICE_UNAVAILABLE)
990        );
991        assert!(
992            config
993                .idempotent_retry
994                .contains(&RetryTrigger::GATEWAY_TIMEOUT)
995        );
996        assert_eq!(config.idempotent_retry.len(), 7);
997
998        // Default respects Retry-After header
999        assert!(!config.ignore_retry_after);
1000
1001        // Default drain limit
1002        assert_eq!(
1003            config.retry_response_drain_limit,
1004            DEFAULT_RETRY_RESPONSE_DRAIN_LIMIT
1005        );
1006
1007        // Default idempotency key header
1008        assert_eq!(
1009            config.idempotency_key_header,
1010            Some(http::header::HeaderName::from_static(
1011                IDEMPOTENCY_KEY_HEADER_LOWER
1012            ))
1013        );
1014    }
1015
1016    #[test]
1017    fn test_retry_config_disabled() {
1018        let config = RetryConfig::disabled();
1019        assert_eq!(config.max_retries, 0);
1020    }
1021
1022    #[test]
1023    fn test_retry_config_aggressive() {
1024        let config = RetryConfig::aggressive();
1025        assert_eq!(config.max_retries, 5);
1026        assert_eq!(config.backoff.initial, Duration::from_millis(50));
1027        assert_eq!(config.backoff.max, Duration::from_secs(30));
1028        // Aggressive moves all 5xx to always_retry
1029        assert!(
1030            config
1031                .always_retry
1032                .contains(&RetryTrigger::INTERNAL_SERVER_ERROR)
1033        );
1034        assert!(config.idempotent_retry.is_empty());
1035    }
1036
1037    #[test]
1038    fn test_should_retry_always() {
1039        let config = RetryConfig::default();
1040
1041        // 429 always retries regardless of method or idempotency key
1042        assert!(config.should_retry(RetryTrigger::TOO_MANY_REQUESTS, &http::Method::GET, false));
1043        assert!(config.should_retry(RetryTrigger::TOO_MANY_REQUESTS, &http::Method::POST, false));
1044        assert!(config.should_retry(RetryTrigger::TOO_MANY_REQUESTS, &http::Method::POST, true));
1045    }
1046
1047    #[test]
1048    fn test_should_retry_idempotent_only() {
1049        let config = RetryConfig::default();
1050
1051        // TransportError retries for idempotent methods only (by default)
1052        assert!(config.should_retry(RetryTrigger::TransportError, &http::Method::GET, false));
1053        assert!(!config.should_retry(RetryTrigger::TransportError, &http::Method::POST, false));
1054
1055        // 500 only retries for idempotent methods
1056        assert!(config.should_retry(
1057            RetryTrigger::INTERNAL_SERVER_ERROR,
1058            &http::Method::GET,
1059            false
1060        ));
1061        assert!(!config.should_retry(
1062            RetryTrigger::INTERNAL_SERVER_ERROR,
1063            &http::Method::POST,
1064            false
1065        ));
1066
1067        // 503 only retries for idempotent methods
1068        assert!(config.should_retry(
1069            RetryTrigger::SERVICE_UNAVAILABLE,
1070            &http::Method::HEAD,
1071            false
1072        ));
1073        assert!(!config.should_retry(
1074            RetryTrigger::SERVICE_UNAVAILABLE,
1075            &http::Method::POST,
1076            false
1077        ));
1078
1079        // Timeout only retries for idempotent methods
1080        assert!(config.should_retry(RetryTrigger::Timeout, &http::Method::GET, false));
1081        assert!(!config.should_retry(RetryTrigger::Timeout, &http::Method::POST, false));
1082    }
1083
1084    #[test]
1085    fn test_should_retry_with_idempotency_key() {
1086        let config = RetryConfig::default();
1087
1088        // TransportError retries for non-idempotent methods when idempotency key is present
1089        assert!(config.should_retry(RetryTrigger::TransportError, &http::Method::POST, true));
1090        assert!(config.should_retry(RetryTrigger::TransportError, &http::Method::PUT, true));
1091        assert!(config.should_retry(RetryTrigger::TransportError, &http::Method::DELETE, true));
1092        assert!(config.should_retry(RetryTrigger::TransportError, &http::Method::PATCH, true));
1093
1094        // Timeout retries for non-idempotent methods when idempotency key is present
1095        assert!(config.should_retry(RetryTrigger::Timeout, &http::Method::POST, true));
1096
1097        // 500 retries for non-idempotent methods when idempotency key is present
1098        assert!(config.should_retry(
1099            RetryTrigger::INTERNAL_SERVER_ERROR,
1100            &http::Method::POST,
1101            true
1102        ));
1103    }
1104
1105    #[test]
1106    fn test_should_retry_not_configured() {
1107        let config = RetryConfig::default();
1108
1109        // 400 Bad Request is not in any retry set
1110        assert!(!config.should_retry(RetryTrigger::Status(400), &http::Method::GET, false));
1111        assert!(!config.should_retry(RetryTrigger::Status(400), &http::Method::POST, false));
1112        assert!(!config.should_retry(RetryTrigger::Status(400), &http::Method::POST, true)); // Even with idempotency key
1113
1114        // 404 Not Found is not in any retry set
1115        assert!(!config.should_retry(RetryTrigger::Status(404), &http::Method::GET, false));
1116    }
1117
1118    #[test]
1119    fn test_rate_limit_config_defaults() {
1120        let config = RateLimitConfig::default();
1121        assert_eq!(config.max_concurrent_requests, 100);
1122    }
1123
1124    #[test]
1125    fn test_rate_limit_config_unlimited() {
1126        let config = RateLimitConfig::unlimited();
1127        assert_eq!(config.max_concurrent_requests, usize::MAX);
1128    }
1129
1130    #[test]
1131    fn test_rate_limit_config_conservative() {
1132        let config = RateLimitConfig::conservative();
1133        assert_eq!(config.max_concurrent_requests, 10);
1134    }
1135
1136    #[test]
1137    fn test_http_client_config_defaults() {
1138        let config = HttpClientConfig::default();
1139        assert_eq!(config.request_timeout, Duration::from_secs(30));
1140        assert_eq!(config.max_body_size, 10 * 1024 * 1024);
1141        assert_eq!(config.user_agent, DEFAULT_USER_AGENT);
1142        assert!(config.retry.is_some());
1143        assert!(config.rate_limit.is_some());
1144        #[cfg(not(feature = "fips"))]
1145        assert_eq!(config.transport, TransportSecurity::AllowInsecureHttp);
1146        #[cfg(feature = "fips")]
1147        assert_eq!(config.transport, TransportSecurity::TlsOnly);
1148        // TLS knobs default to a TLS 1.2 floor with no mutual-TLS identity.
1149        assert_eq!(config.tls.min_version, TlsVersion::Tls12);
1150        assert!(config.tls.client_auth.is_none());
1151        assert!(!config.otel);
1152        assert_eq!(config.buffer_capacity, 1024);
1153    }
1154
1155    #[test]
1156    fn test_http_client_config_minimal() {
1157        let config = HttpClientConfig::minimal();
1158        assert_eq!(config.request_timeout, Duration::from_secs(10));
1159        assert_eq!(config.max_body_size, 1024 * 1024);
1160        assert!(config.retry.is_none());
1161        assert!(config.rate_limit.is_none());
1162    }
1163
1164    #[test]
1165    fn test_http_client_config_infra_default() {
1166        let config = HttpClientConfig::infra_default();
1167        assert_eq!(config.request_timeout, Duration::from_mins(1));
1168        assert_eq!(config.max_body_size, 50 * 1024 * 1024);
1169        assert!(config.retry.is_some());
1170        assert_eq!(config.retry.unwrap().max_retries, 5);
1171    }
1172
1173    #[test]
1174    fn test_http_client_config_token_endpoint() {
1175        let config = HttpClientConfig::token_endpoint();
1176        assert_eq!(config.request_timeout, Duration::from_secs(30));
1177
1178        let retry = config.retry.unwrap();
1179        // Token endpoint: no idempotent-only retries (conservative for auth)
1180        assert!(retry.idempotent_retry.is_empty());
1181        // But still retry transport errors and 429
1182        assert!(retry.always_retry.contains(&RetryTrigger::TransportError));
1183        assert!(
1184            retry
1185                .always_retry
1186                .contains(&RetryTrigger::TOO_MANY_REQUESTS)
1187        );
1188
1189        let rate_limit = config.rate_limit.unwrap();
1190        assert_eq!(rate_limit.max_concurrent_requests, 10); // Conservative
1191    }
1192
1193    #[test]
1194    fn test_http_client_config_for_testing() {
1195        let config = HttpClientConfig::for_testing();
1196        assert_eq!(config.transport, TransportSecurity::AllowInsecureHttp);
1197        assert!(config.retry.is_none());
1198    }
1199
1200    #[test]
1201    fn test_http_client_config_sse() {
1202        let config = HttpClientConfig::sse();
1203        assert_eq!(config.request_timeout, Duration::from_hours(24));
1204        assert!(config.total_timeout.is_none());
1205        assert!(config.retry.is_none());
1206        assert!(config.rate_limit.is_none());
1207        assert!(!config.otel);
1208        assert_eq!(config.buffer_capacity, 64);
1209        assert!(config.pool_idle_timeout.is_none());
1210        assert_eq!(config.pool_max_idle_per_host, 1);
1211    }
1212
1213    #[test]
1214    fn test_http_client_config_proxy() {
1215        let config = HttpClientConfig::proxy();
1216        // No retries, no client-side rate limit: the edge must not re-send or
1217        // impose a gateway-wide concurrency ceiling.
1218        assert!(config.retry.is_none());
1219        assert!(config.rate_limit.is_none());
1220        // No response body cap: large downloads stream untruncated.
1221        assert_eq!(config.max_body_size, usize::MAX);
1222        // No blanket request timeout that would cut SSE/streaming at 30s.
1223        assert_eq!(config.request_timeout, Duration::from_hours(24));
1224        assert!(config.total_timeout.is_none());
1225        // A reverse proxy returns 3xx to the client rather than following it.
1226        assert_eq!(config.redirect.max_redirects, 0);
1227    }
1228}