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
875#[cfg(test)]
876#[cfg_attr(coverage_nightly, coverage(off))]
877mod tests {
878 use super::*;
879
880 #[test]
881 fn test_retry_trigger_constants() {
882 assert_eq!(RetryTrigger::TOO_MANY_REQUESTS, RetryTrigger::Status(429));
883 assert_eq!(RetryTrigger::REQUEST_TIMEOUT, RetryTrigger::Status(408));
884 assert_eq!(
885 RetryTrigger::INTERNAL_SERVER_ERROR,
886 RetryTrigger::Status(500)
887 );
888 assert_eq!(RetryTrigger::BAD_GATEWAY, RetryTrigger::Status(502));
889 assert_eq!(RetryTrigger::SERVICE_UNAVAILABLE, RetryTrigger::Status(503));
890 assert_eq!(RetryTrigger::GATEWAY_TIMEOUT, RetryTrigger::Status(504));
891 }
892
893 #[test]
894 fn test_is_idempotent_method() {
895 // Idempotent per RFC 9110
896 assert!(is_idempotent_method(&http::Method::GET));
897 assert!(is_idempotent_method(&http::Method::HEAD));
898 assert!(is_idempotent_method(&http::Method::PUT));
899 assert!(is_idempotent_method(&http::Method::DELETE));
900 assert!(is_idempotent_method(&http::Method::OPTIONS));
901 assert!(is_idempotent_method(&http::Method::TRACE));
902 // Non-idempotent
903 assert!(!is_idempotent_method(&http::Method::POST));
904 assert!(!is_idempotent_method(&http::Method::PATCH));
905 }
906
907 #[test]
908 fn test_retry_config_defaults() {
909 let config = RetryConfig::default();
910 assert_eq!(config.max_retries, 3);
911 assert_eq!(config.backoff.initial, Duration::from_millis(100));
912 assert_eq!(config.backoff.max, Duration::from_secs(10));
913 assert!((config.backoff.multiplier - 2.0).abs() < f64::EPSILON);
914 assert!(config.backoff.jitter);
915
916 // Check always_retry defaults - only 429 is always retried
917 assert!(
918 config
919 .always_retry
920 .contains(&RetryTrigger::TOO_MANY_REQUESTS)
921 );
922 assert_eq!(config.always_retry.len(), 1);
923
924 // Check idempotent_retry defaults - includes TransportError and Timeout for safety
925 assert!(
926 config
927 .idempotent_retry
928 .contains(&RetryTrigger::TransportError)
929 );
930 assert!(config.idempotent_retry.contains(&RetryTrigger::Timeout));
931 assert!(
932 config
933 .idempotent_retry
934 .contains(&RetryTrigger::REQUEST_TIMEOUT)
935 );
936 assert!(
937 config
938 .idempotent_retry
939 .contains(&RetryTrigger::INTERNAL_SERVER_ERROR)
940 );
941 assert!(config.idempotent_retry.contains(&RetryTrigger::BAD_GATEWAY));
942 assert!(
943 config
944 .idempotent_retry
945 .contains(&RetryTrigger::SERVICE_UNAVAILABLE)
946 );
947 assert!(
948 config
949 .idempotent_retry
950 .contains(&RetryTrigger::GATEWAY_TIMEOUT)
951 );
952 assert_eq!(config.idempotent_retry.len(), 7);
953
954 // Default respects Retry-After header
955 assert!(!config.ignore_retry_after);
956
957 // Default drain limit
958 assert_eq!(
959 config.retry_response_drain_limit,
960 DEFAULT_RETRY_RESPONSE_DRAIN_LIMIT
961 );
962
963 // Default idempotency key header
964 assert_eq!(
965 config.idempotency_key_header,
966 Some(http::header::HeaderName::from_static(
967 IDEMPOTENCY_KEY_HEADER_LOWER
968 ))
969 );
970 }
971
972 #[test]
973 fn test_retry_config_disabled() {
974 let config = RetryConfig::disabled();
975 assert_eq!(config.max_retries, 0);
976 }
977
978 #[test]
979 fn test_retry_config_aggressive() {
980 let config = RetryConfig::aggressive();
981 assert_eq!(config.max_retries, 5);
982 assert_eq!(config.backoff.initial, Duration::from_millis(50));
983 assert_eq!(config.backoff.max, Duration::from_secs(30));
984 // Aggressive moves all 5xx to always_retry
985 assert!(
986 config
987 .always_retry
988 .contains(&RetryTrigger::INTERNAL_SERVER_ERROR)
989 );
990 assert!(config.idempotent_retry.is_empty());
991 }
992
993 #[test]
994 fn test_should_retry_always() {
995 let config = RetryConfig::default();
996
997 // 429 always retries regardless of method or idempotency key
998 assert!(config.should_retry(RetryTrigger::TOO_MANY_REQUESTS, &http::Method::GET, false));
999 assert!(config.should_retry(RetryTrigger::TOO_MANY_REQUESTS, &http::Method::POST, false));
1000 assert!(config.should_retry(RetryTrigger::TOO_MANY_REQUESTS, &http::Method::POST, true));
1001 }
1002
1003 #[test]
1004 fn test_should_retry_idempotent_only() {
1005 let config = RetryConfig::default();
1006
1007 // TransportError retries for idempotent methods only (by default)
1008 assert!(config.should_retry(RetryTrigger::TransportError, &http::Method::GET, false));
1009 assert!(!config.should_retry(RetryTrigger::TransportError, &http::Method::POST, false));
1010
1011 // 500 only retries for idempotent methods
1012 assert!(config.should_retry(
1013 RetryTrigger::INTERNAL_SERVER_ERROR,
1014 &http::Method::GET,
1015 false
1016 ));
1017 assert!(!config.should_retry(
1018 RetryTrigger::INTERNAL_SERVER_ERROR,
1019 &http::Method::POST,
1020 false
1021 ));
1022
1023 // 503 only retries for idempotent methods
1024 assert!(config.should_retry(
1025 RetryTrigger::SERVICE_UNAVAILABLE,
1026 &http::Method::HEAD,
1027 false
1028 ));
1029 assert!(!config.should_retry(
1030 RetryTrigger::SERVICE_UNAVAILABLE,
1031 &http::Method::POST,
1032 false
1033 ));
1034
1035 // Timeout only retries for idempotent methods
1036 assert!(config.should_retry(RetryTrigger::Timeout, &http::Method::GET, false));
1037 assert!(!config.should_retry(RetryTrigger::Timeout, &http::Method::POST, false));
1038 }
1039
1040 #[test]
1041 fn test_should_retry_with_idempotency_key() {
1042 let config = RetryConfig::default();
1043
1044 // TransportError retries for non-idempotent methods when idempotency key is present
1045 assert!(config.should_retry(RetryTrigger::TransportError, &http::Method::POST, true));
1046 assert!(config.should_retry(RetryTrigger::TransportError, &http::Method::PUT, true));
1047 assert!(config.should_retry(RetryTrigger::TransportError, &http::Method::DELETE, true));
1048 assert!(config.should_retry(RetryTrigger::TransportError, &http::Method::PATCH, true));
1049
1050 // Timeout retries for non-idempotent methods when idempotency key is present
1051 assert!(config.should_retry(RetryTrigger::Timeout, &http::Method::POST, true));
1052
1053 // 500 retries for non-idempotent methods when idempotency key is present
1054 assert!(config.should_retry(
1055 RetryTrigger::INTERNAL_SERVER_ERROR,
1056 &http::Method::POST,
1057 true
1058 ));
1059 }
1060
1061 #[test]
1062 fn test_should_retry_not_configured() {
1063 let config = RetryConfig::default();
1064
1065 // 400 Bad Request is not in any retry set
1066 assert!(!config.should_retry(RetryTrigger::Status(400), &http::Method::GET, false));
1067 assert!(!config.should_retry(RetryTrigger::Status(400), &http::Method::POST, false));
1068 assert!(!config.should_retry(RetryTrigger::Status(400), &http::Method::POST, true)); // Even with idempotency key
1069
1070 // 404 Not Found is not in any retry set
1071 assert!(!config.should_retry(RetryTrigger::Status(404), &http::Method::GET, false));
1072 }
1073
1074 #[test]
1075 fn test_rate_limit_config_defaults() {
1076 let config = RateLimitConfig::default();
1077 assert_eq!(config.max_concurrent_requests, 100);
1078 }
1079
1080 #[test]
1081 fn test_rate_limit_config_unlimited() {
1082 let config = RateLimitConfig::unlimited();
1083 assert_eq!(config.max_concurrent_requests, usize::MAX);
1084 }
1085
1086 #[test]
1087 fn test_rate_limit_config_conservative() {
1088 let config = RateLimitConfig::conservative();
1089 assert_eq!(config.max_concurrent_requests, 10);
1090 }
1091
1092 #[test]
1093 fn test_http_client_config_defaults() {
1094 let config = HttpClientConfig::default();
1095 assert_eq!(config.request_timeout, Duration::from_secs(30));
1096 assert_eq!(config.max_body_size, 10 * 1024 * 1024);
1097 assert_eq!(config.user_agent, DEFAULT_USER_AGENT);
1098 assert!(config.retry.is_some());
1099 assert!(config.rate_limit.is_some());
1100 #[cfg(not(feature = "fips"))]
1101 assert_eq!(config.transport, TransportSecurity::AllowInsecureHttp);
1102 #[cfg(feature = "fips")]
1103 assert_eq!(config.transport, TransportSecurity::TlsOnly);
1104 // TLS knobs default to a TLS 1.2 floor with no mutual-TLS identity.
1105 assert_eq!(config.tls.min_version, TlsVersion::Tls12);
1106 assert!(config.tls.client_auth.is_none());
1107 assert!(!config.otel);
1108 assert_eq!(config.buffer_capacity, 1024);
1109 }
1110
1111 #[test]
1112 fn test_http_client_config_minimal() {
1113 let config = HttpClientConfig::minimal();
1114 assert_eq!(config.request_timeout, Duration::from_secs(10));
1115 assert_eq!(config.max_body_size, 1024 * 1024);
1116 assert!(config.retry.is_none());
1117 assert!(config.rate_limit.is_none());
1118 }
1119
1120 #[test]
1121 fn test_http_client_config_infra_default() {
1122 let config = HttpClientConfig::infra_default();
1123 assert_eq!(config.request_timeout, Duration::from_mins(1));
1124 assert_eq!(config.max_body_size, 50 * 1024 * 1024);
1125 assert!(config.retry.is_some());
1126 assert_eq!(config.retry.unwrap().max_retries, 5);
1127 }
1128
1129 #[test]
1130 fn test_http_client_config_token_endpoint() {
1131 let config = HttpClientConfig::token_endpoint();
1132 assert_eq!(config.request_timeout, Duration::from_secs(30));
1133
1134 let retry = config.retry.unwrap();
1135 // Token endpoint: no idempotent-only retries (conservative for auth)
1136 assert!(retry.idempotent_retry.is_empty());
1137 // But still retry transport errors and 429
1138 assert!(retry.always_retry.contains(&RetryTrigger::TransportError));
1139 assert!(
1140 retry
1141 .always_retry
1142 .contains(&RetryTrigger::TOO_MANY_REQUESTS)
1143 );
1144
1145 let rate_limit = config.rate_limit.unwrap();
1146 assert_eq!(rate_limit.max_concurrent_requests, 10); // Conservative
1147 }
1148
1149 #[test]
1150 fn test_http_client_config_for_testing() {
1151 let config = HttpClientConfig::for_testing();
1152 assert_eq!(config.transport, TransportSecurity::AllowInsecureHttp);
1153 assert!(config.retry.is_none());
1154 }
1155
1156 #[test]
1157 fn test_http_client_config_sse() {
1158 let config = HttpClientConfig::sse();
1159 assert_eq!(config.request_timeout, Duration::from_hours(24));
1160 assert!(config.total_timeout.is_none());
1161 assert!(config.retry.is_none());
1162 assert!(config.rate_limit.is_none());
1163 assert!(!config.otel);
1164 assert_eq!(config.buffer_capacity, 64);
1165 assert!(config.pool_idle_timeout.is_none());
1166 assert_eq!(config.pool_max_idle_per_host, 1);
1167 }
1168}