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}