Skip to main content

modkit_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!("modkit-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 modkit_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) - default and recommended
480    #[default]
481    TlsOnly,
482    /// Allow insecure HTTP connections (for testing with mock servers only)
483    ///
484    /// **WARNING**: This should only be used for local testing with mock servers.
485    /// Never use in production as it exposes traffic to interception.
486    AllowInsecureHttp,
487}
488
489/// Overall HTTP client configuration
490#[derive(Debug, Clone)]
491pub struct HttpClientConfig {
492    /// Per-request timeout (default: 30 seconds)
493    ///
494    /// This timeout applies to each individual HTTP request/attempt.
495    /// If retries are enabled, each retry attempt gets its own timeout.
496    pub request_timeout: Duration,
497
498    /// Total timeout spanning all retry attempts (default: None)
499    ///
500    /// When set, the entire operation (including all retries and backoff delays)
501    /// must complete within this duration. If the deadline is exceeded,
502    /// the request fails with `HttpError::DeadlineExceeded(total_timeout)`.
503    ///
504    /// When `None`, there is no total deadline - each attempt can take up to
505    /// `request_timeout`, and retries can continue indefinitely within their limits.
506    pub total_timeout: Option<Duration>,
507
508    /// Maximum response body size in bytes (default: 10 MB)
509    pub max_body_size: usize,
510
511    /// User-Agent header value (default: "modkit-http/1.0")
512    pub user_agent: String,
513
514    /// Retry policy configuration
515    pub retry: Option<RetryConfig>,
516
517    /// Rate limiting / concurrency configuration
518    pub rate_limit: Option<RateLimitConfig>,
519
520    /// Transport security mode (default: `TlsOnly`)
521    ///
522    /// Use `AllowInsecureHttp` only for testing with local mock servers.
523    pub transport: TransportSecurity,
524
525    /// TLS root certificate strategy (default: `WebPki`)
526    pub tls_roots: TlsRootConfig,
527
528    /// Enable OpenTelemetry tracing layer (default: false)
529    /// Creates spans for outbound requests and injects trace context headers.
530    pub otel: bool,
531
532    /// Buffer capacity for concurrent request handling (default: 1024)
533    ///
534    /// The HTTP client uses an internal buffer to allow multiple concurrent
535    /// requests without external locking. This sets the maximum number of
536    /// requests that can be queued waiting for processing.
537    pub buffer_capacity: usize,
538
539    /// Redirect policy configuration (default: same-origin only with header stripping)
540    ///
541    /// Controls how 3xx redirect responses are handled with security protections:
542    /// - Same-origin enforcement (SSRF protection)
543    /// - Sensitive header stripping on cross-origin redirects
544    /// - HTTPS downgrade protection
545    ///
546    /// Use `RedirectConfig::permissive()` for general-purpose HTTP client behavior
547    /// that allows cross-origin redirects with header stripping.
548    ///
549    /// Use `RedirectConfig::disabled()` to turn off redirect following entirely.
550    pub redirect: RedirectConfig,
551
552    /// Timeout for idle connections in the pool (default: 90 seconds)
553    ///
554    /// Connections that remain idle (unused) for longer than this duration
555    /// will be closed and removed from the pool. This prevents resource leaks
556    /// and ensures connections don't become stale.
557    ///
558    /// Set to `None` to use hyper-util's default idle timeout.
559    pub pool_idle_timeout: Option<Duration>,
560
561    /// Maximum number of idle connections per host (default: 32)
562    ///
563    /// Limits how many idle connections are kept in the pool for each host.
564    /// Setting this to `0` disables connection reuse entirely.
565    /// Setting this too high may waste resources on rarely-used connections.
566    ///
567    /// **Note**: This only limits *idle* connections. Active connections are
568    /// not limited by this setting.
569    pub pool_max_idle_per_host: usize,
570}
571
572impl Default for HttpClientConfig {
573    fn default() -> Self {
574        Self {
575            request_timeout: Duration::from_secs(30),
576            total_timeout: None,
577            max_body_size: 10 * 1024 * 1024, // 10 MB
578            user_agent: DEFAULT_USER_AGENT.to_owned(),
579            retry: Some(RetryConfig::default()),
580            rate_limit: Some(RateLimitConfig::default()),
581            transport: TransportSecurity::TlsOnly,
582            tls_roots: TlsRootConfig::default(),
583            otel: false,
584            buffer_capacity: 1024,
585            redirect: RedirectConfig::default(),
586            pool_idle_timeout: Some(Duration::from_secs(90)),
587            pool_max_idle_per_host: 32,
588        }
589    }
590}
591
592impl HttpClientConfig {
593    /// Create minimal configuration (no retry, no rate limit, small timeout)
594    #[must_use]
595    pub fn minimal() -> Self {
596        Self {
597            request_timeout: Duration::from_secs(10),
598            total_timeout: None,
599            max_body_size: 1024 * 1024, // 1 MB
600            user_agent: DEFAULT_USER_AGENT.to_owned(),
601            retry: None,
602            rate_limit: None,
603            transport: TransportSecurity::TlsOnly,
604            tls_roots: TlsRootConfig::default(),
605            otel: false,
606            buffer_capacity: 256,
607            redirect: RedirectConfig::default(),
608            pool_idle_timeout: Some(Duration::from_secs(30)),
609            pool_max_idle_per_host: 8,
610        }
611    }
612
613    /// Create configuration for infrastructure services (aggressive retry, large timeout)
614    #[must_use]
615    pub fn infra_default() -> Self {
616        Self {
617            request_timeout: Duration::from_secs(60),
618            total_timeout: None,
619            max_body_size: 50 * 1024 * 1024, // 50 MB
620            user_agent: DEFAULT_USER_AGENT.to_owned(),
621            retry: Some(RetryConfig::aggressive()),
622            rate_limit: Some(RateLimitConfig::default()),
623            transport: TransportSecurity::TlsOnly,
624            tls_roots: TlsRootConfig::default(),
625            otel: false,
626            buffer_capacity: 1024,
627            redirect: RedirectConfig::default(),
628            pool_idle_timeout: Some(Duration::from_secs(120)),
629            pool_max_idle_per_host: 64,
630        }
631    }
632
633    /// Create configuration for `OAuth2` token endpoints (conservative retry)
634    ///
635    /// Token endpoints use POST but are effectively idempotent for retry purposes:
636    /// - Getting a token twice is safe (you'd just use the second one)
637    /// - Transport errors before response mean no token was issued
638    ///
639    /// This config retries on transport errors, timeout, and 429 for all methods.
640    #[must_use]
641    pub fn token_endpoint() -> Self {
642        Self {
643            request_timeout: Duration::from_secs(30),
644            total_timeout: None,
645            max_body_size: 1024 * 1024, // 1 MB
646            user_agent: DEFAULT_USER_AGENT.to_owned(),
647            retry: Some(RetryConfig {
648                max_retries: 3,
649                // For token endpoints: retry transport errors, timeout, and 429
650                // Note: Token requests (POST) are effectively idempotent - getting
651                // a token twice is safe, so we put these in always_retry
652                always_retry: HashSet::from([
653                    RetryTrigger::TransportError,
654                    RetryTrigger::Timeout,
655                    RetryTrigger::TOO_MANY_REQUESTS,
656                ]),
657                idempotent_retry: HashSet::new(), // No additional retries for 5xx
658                ignore_retry_after: false,
659                retry_response_drain_limit: DEFAULT_RETRY_RESPONSE_DRAIN_LIMIT,
660                idempotency_key_header: None, // Not needed - always_retry handles all cases
661                ..RetryConfig::default()
662            }),
663            rate_limit: Some(RateLimitConfig::conservative()),
664            transport: TransportSecurity::TlsOnly,
665            tls_roots: TlsRootConfig::default(),
666            otel: false,
667            buffer_capacity: 256,
668            redirect: RedirectConfig::default(),
669            pool_idle_timeout: Some(Duration::from_secs(60)),
670            pool_max_idle_per_host: 4,
671        }
672    }
673
674    /// Create configuration for testing with mock servers (allows insecure HTTP)
675    ///
676    /// **WARNING**: This configuration allows plain HTTP connections.
677    /// Use only for local testing with mock servers, never in production.
678    #[must_use]
679    pub fn for_testing() -> Self {
680        Self {
681            request_timeout: Duration::from_secs(10),
682            total_timeout: None,
683            max_body_size: 1024 * 1024, // 1 MB
684            user_agent: DEFAULT_USER_AGENT.to_owned(),
685            retry: None,
686            rate_limit: None,
687            transport: TransportSecurity::AllowInsecureHttp,
688            tls_roots: TlsRootConfig::default(),
689            otel: false,
690            buffer_capacity: 256,
691            redirect: RedirectConfig::for_testing(),
692            pool_idle_timeout: Some(Duration::from_secs(10)),
693            pool_max_idle_per_host: 4,
694        }
695    }
696
697    /// Create configuration optimized for Server-Sent Events (SSE) streaming.
698    ///
699    /// SSE connections are long-lived HTTP requests where the server holds the
700    /// connection open and pushes events. This preset disables retry and rate
701    /// limiting, and sets a permissive request timeout.
702    ///
703    /// # Timeout behavior
704    ///
705    /// `request_timeout` is set to 24 hours rather than truly unlimited,
706    /// because `TimeoutLayer` requires a finite `Duration`. Override if needed:
707    ///
708    /// ```rust,ignore
709    /// let mut config = HttpClientConfig::sse();
710    /// config.request_timeout = Duration::from_secs(3600); // 1 hour
711    /// let client = HttpClientBuilder::with_config(config).build()?;
712    /// ```
713    ///
714    /// # Streaming
715    ///
716    /// Use [`HttpResponse::into_body()`] for streaming — it bypasses the
717    /// `max_body_size` limit. SSE reconnection with `Last-Event-ID` is the
718    /// caller's responsibility.
719    ///
720    /// ```rust,ignore
721    /// let client = HttpClientBuilder::with_config(HttpClientConfig::sse()).build()?;
722    ///
723    /// let response = client
724    ///     .get("https://api.example.com/events")
725    ///     .header("accept", "text/event-stream")
726    ///     .send()
727    ///     .await?;
728    ///
729    /// let mut body = response.into_body();
730    /// while let Some(frame) = body.frame().await {
731    ///     let frame = frame?;
732    ///     if let Some(chunk) = frame.data_ref() {
733    ///         // parse SSE event data
734    ///     }
735    /// }
736    /// ```
737    #[must_use]
738    pub fn sse() -> Self {
739        Self {
740            request_timeout: Duration::from_secs(86_400), // 24 hours
741            total_timeout: None,
742            max_body_size: 10 * 1024 * 1024, // 10 MB (only for bytes()/json(), not into_body())
743            user_agent: DEFAULT_USER_AGENT.to_owned(),
744            retry: None, // SSE reconnection is protocol-level (Last-Event-ID)
745            rate_limit: None,
746            transport: TransportSecurity::TlsOnly,
747            tls_roots: TlsRootConfig::default(),
748            otel: false,
749            buffer_capacity: 64,
750            redirect: RedirectConfig::default(),
751            pool_idle_timeout: None, // use hyper-util default
752            pool_max_idle_per_host: 1,
753        }
754    }
755}
756
757#[cfg(test)]
758#[cfg_attr(coverage_nightly, coverage(off))]
759mod tests {
760    use super::*;
761
762    #[test]
763    fn test_retry_trigger_constants() {
764        assert_eq!(RetryTrigger::TOO_MANY_REQUESTS, RetryTrigger::Status(429));
765        assert_eq!(RetryTrigger::REQUEST_TIMEOUT, RetryTrigger::Status(408));
766        assert_eq!(
767            RetryTrigger::INTERNAL_SERVER_ERROR,
768            RetryTrigger::Status(500)
769        );
770        assert_eq!(RetryTrigger::BAD_GATEWAY, RetryTrigger::Status(502));
771        assert_eq!(RetryTrigger::SERVICE_UNAVAILABLE, RetryTrigger::Status(503));
772        assert_eq!(RetryTrigger::GATEWAY_TIMEOUT, RetryTrigger::Status(504));
773    }
774
775    #[test]
776    fn test_is_idempotent_method() {
777        // Idempotent per RFC 9110
778        assert!(is_idempotent_method(&http::Method::GET));
779        assert!(is_idempotent_method(&http::Method::HEAD));
780        assert!(is_idempotent_method(&http::Method::PUT));
781        assert!(is_idempotent_method(&http::Method::DELETE));
782        assert!(is_idempotent_method(&http::Method::OPTIONS));
783        assert!(is_idempotent_method(&http::Method::TRACE));
784        // Non-idempotent
785        assert!(!is_idempotent_method(&http::Method::POST));
786        assert!(!is_idempotent_method(&http::Method::PATCH));
787    }
788
789    #[test]
790    fn test_retry_config_defaults() {
791        let config = RetryConfig::default();
792        assert_eq!(config.max_retries, 3);
793        assert_eq!(config.backoff.initial, Duration::from_millis(100));
794        assert_eq!(config.backoff.max, Duration::from_secs(10));
795        assert!((config.backoff.multiplier - 2.0).abs() < f64::EPSILON);
796        assert!(config.backoff.jitter);
797
798        // Check always_retry defaults - only 429 is always retried
799        assert!(
800            config
801                .always_retry
802                .contains(&RetryTrigger::TOO_MANY_REQUESTS)
803        );
804        assert_eq!(config.always_retry.len(), 1);
805
806        // Check idempotent_retry defaults - includes TransportError and Timeout for safety
807        assert!(
808            config
809                .idempotent_retry
810                .contains(&RetryTrigger::TransportError)
811        );
812        assert!(config.idempotent_retry.contains(&RetryTrigger::Timeout));
813        assert!(
814            config
815                .idempotent_retry
816                .contains(&RetryTrigger::REQUEST_TIMEOUT)
817        );
818        assert!(
819            config
820                .idempotent_retry
821                .contains(&RetryTrigger::INTERNAL_SERVER_ERROR)
822        );
823        assert!(config.idempotent_retry.contains(&RetryTrigger::BAD_GATEWAY));
824        assert!(
825            config
826                .idempotent_retry
827                .contains(&RetryTrigger::SERVICE_UNAVAILABLE)
828        );
829        assert!(
830            config
831                .idempotent_retry
832                .contains(&RetryTrigger::GATEWAY_TIMEOUT)
833        );
834        assert_eq!(config.idempotent_retry.len(), 7);
835
836        // Default respects Retry-After header
837        assert!(!config.ignore_retry_after);
838
839        // Default drain limit
840        assert_eq!(
841            config.retry_response_drain_limit,
842            DEFAULT_RETRY_RESPONSE_DRAIN_LIMIT
843        );
844
845        // Default idempotency key header
846        assert_eq!(
847            config.idempotency_key_header,
848            Some(http::header::HeaderName::from_static(
849                IDEMPOTENCY_KEY_HEADER_LOWER
850            ))
851        );
852    }
853
854    #[test]
855    fn test_retry_config_disabled() {
856        let config = RetryConfig::disabled();
857        assert_eq!(config.max_retries, 0);
858    }
859
860    #[test]
861    fn test_retry_config_aggressive() {
862        let config = RetryConfig::aggressive();
863        assert_eq!(config.max_retries, 5);
864        assert_eq!(config.backoff.initial, Duration::from_millis(50));
865        assert_eq!(config.backoff.max, Duration::from_secs(30));
866        // Aggressive moves all 5xx to always_retry
867        assert!(
868            config
869                .always_retry
870                .contains(&RetryTrigger::INTERNAL_SERVER_ERROR)
871        );
872        assert!(config.idempotent_retry.is_empty());
873    }
874
875    #[test]
876    fn test_should_retry_always() {
877        let config = RetryConfig::default();
878
879        // 429 always retries regardless of method or idempotency key
880        assert!(config.should_retry(RetryTrigger::TOO_MANY_REQUESTS, &http::Method::GET, false));
881        assert!(config.should_retry(RetryTrigger::TOO_MANY_REQUESTS, &http::Method::POST, false));
882        assert!(config.should_retry(RetryTrigger::TOO_MANY_REQUESTS, &http::Method::POST, true));
883    }
884
885    #[test]
886    fn test_should_retry_idempotent_only() {
887        let config = RetryConfig::default();
888
889        // TransportError retries for idempotent methods only (by default)
890        assert!(config.should_retry(RetryTrigger::TransportError, &http::Method::GET, false));
891        assert!(!config.should_retry(RetryTrigger::TransportError, &http::Method::POST, false));
892
893        // 500 only retries for idempotent methods
894        assert!(config.should_retry(
895            RetryTrigger::INTERNAL_SERVER_ERROR,
896            &http::Method::GET,
897            false
898        ));
899        assert!(!config.should_retry(
900            RetryTrigger::INTERNAL_SERVER_ERROR,
901            &http::Method::POST,
902            false
903        ));
904
905        // 503 only retries for idempotent methods
906        assert!(config.should_retry(
907            RetryTrigger::SERVICE_UNAVAILABLE,
908            &http::Method::HEAD,
909            false
910        ));
911        assert!(!config.should_retry(
912            RetryTrigger::SERVICE_UNAVAILABLE,
913            &http::Method::POST,
914            false
915        ));
916
917        // Timeout only retries for idempotent methods
918        assert!(config.should_retry(RetryTrigger::Timeout, &http::Method::GET, false));
919        assert!(!config.should_retry(RetryTrigger::Timeout, &http::Method::POST, false));
920    }
921
922    #[test]
923    fn test_should_retry_with_idempotency_key() {
924        let config = RetryConfig::default();
925
926        // TransportError retries for non-idempotent methods when idempotency key is present
927        assert!(config.should_retry(RetryTrigger::TransportError, &http::Method::POST, true));
928        assert!(config.should_retry(RetryTrigger::TransportError, &http::Method::PUT, true));
929        assert!(config.should_retry(RetryTrigger::TransportError, &http::Method::DELETE, true));
930        assert!(config.should_retry(RetryTrigger::TransportError, &http::Method::PATCH, true));
931
932        // Timeout retries for non-idempotent methods when idempotency key is present
933        assert!(config.should_retry(RetryTrigger::Timeout, &http::Method::POST, true));
934
935        // 500 retries for non-idempotent methods when idempotency key is present
936        assert!(config.should_retry(
937            RetryTrigger::INTERNAL_SERVER_ERROR,
938            &http::Method::POST,
939            true
940        ));
941    }
942
943    #[test]
944    fn test_should_retry_not_configured() {
945        let config = RetryConfig::default();
946
947        // 400 Bad Request is not in any retry set
948        assert!(!config.should_retry(RetryTrigger::Status(400), &http::Method::GET, false));
949        assert!(!config.should_retry(RetryTrigger::Status(400), &http::Method::POST, false));
950        assert!(!config.should_retry(RetryTrigger::Status(400), &http::Method::POST, true)); // Even with idempotency key
951
952        // 404 Not Found is not in any retry set
953        assert!(!config.should_retry(RetryTrigger::Status(404), &http::Method::GET, false));
954    }
955
956    #[test]
957    fn test_rate_limit_config_defaults() {
958        let config = RateLimitConfig::default();
959        assert_eq!(config.max_concurrent_requests, 100);
960    }
961
962    #[test]
963    fn test_rate_limit_config_unlimited() {
964        let config = RateLimitConfig::unlimited();
965        assert_eq!(config.max_concurrent_requests, usize::MAX);
966    }
967
968    #[test]
969    fn test_rate_limit_config_conservative() {
970        let config = RateLimitConfig::conservative();
971        assert_eq!(config.max_concurrent_requests, 10);
972    }
973
974    #[test]
975    fn test_http_client_config_defaults() {
976        let config = HttpClientConfig::default();
977        assert_eq!(config.request_timeout, Duration::from_secs(30));
978        assert_eq!(config.max_body_size, 10 * 1024 * 1024);
979        assert_eq!(config.user_agent, DEFAULT_USER_AGENT);
980        assert!(config.retry.is_some());
981        assert!(config.rate_limit.is_some());
982        assert_eq!(config.transport, TransportSecurity::TlsOnly);
983        assert!(!config.otel);
984        assert_eq!(config.buffer_capacity, 1024);
985    }
986
987    #[test]
988    fn test_http_client_config_minimal() {
989        let config = HttpClientConfig::minimal();
990        assert_eq!(config.request_timeout, Duration::from_secs(10));
991        assert_eq!(config.max_body_size, 1024 * 1024);
992        assert!(config.retry.is_none());
993        assert!(config.rate_limit.is_none());
994    }
995
996    #[test]
997    fn test_http_client_config_infra_default() {
998        let config = HttpClientConfig::infra_default();
999        assert_eq!(config.request_timeout, Duration::from_secs(60));
1000        assert_eq!(config.max_body_size, 50 * 1024 * 1024);
1001        assert!(config.retry.is_some());
1002        assert_eq!(config.retry.unwrap().max_retries, 5);
1003    }
1004
1005    #[test]
1006    fn test_http_client_config_token_endpoint() {
1007        let config = HttpClientConfig::token_endpoint();
1008        assert_eq!(config.request_timeout, Duration::from_secs(30));
1009
1010        let retry = config.retry.unwrap();
1011        // Token endpoint: no idempotent-only retries (conservative for auth)
1012        assert!(retry.idempotent_retry.is_empty());
1013        // But still retry transport errors and 429
1014        assert!(retry.always_retry.contains(&RetryTrigger::TransportError));
1015        assert!(
1016            retry
1017                .always_retry
1018                .contains(&RetryTrigger::TOO_MANY_REQUESTS)
1019        );
1020
1021        let rate_limit = config.rate_limit.unwrap();
1022        assert_eq!(rate_limit.max_concurrent_requests, 10); // Conservative
1023    }
1024
1025    #[test]
1026    fn test_http_client_config_for_testing() {
1027        let config = HttpClientConfig::for_testing();
1028        assert_eq!(config.transport, TransportSecurity::AllowInsecureHttp);
1029        assert!(config.retry.is_none());
1030    }
1031
1032    #[test]
1033    fn test_http_client_config_sse() {
1034        let config = HttpClientConfig::sse();
1035        assert_eq!(config.request_timeout, Duration::from_secs(86_400));
1036        assert!(config.total_timeout.is_none());
1037        assert!(config.retry.is_none());
1038        assert!(config.rate_limit.is_none());
1039        assert!(!config.otel);
1040        assert_eq!(config.buffer_capacity, 64);
1041        assert!(config.pool_idle_timeout.is_none());
1042        assert_eq!(config.pool_max_idle_per_host, 1);
1043    }
1044}