Skip to main content

toolkit_http/
config.rs

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