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