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/// Concurrency-limit configuration.
306///
307/// Despite the name, this is a *concurrency* cap (max in-flight requests), not a
308/// requests-per-second rate limit. Installed by
309/// [`HttpClientBuilder::concurrency_limit`](crate::builder::HttpClientBuilder::concurrency_limit);
310/// see there for the load-shedding behaviour.
311#[derive(Debug, Clone)]
312pub struct RateLimitConfig {
313 /// Maximum in-flight requests at once. Defaults to 100
314 /// ([`RateLimitConfig::default`]). `usize::MAX` disables the limiter
315 /// entirely (the layer is skipped in `build`); `0` is clamped to `1` at
316 /// `build` time so the client can never wedge shedding every request.
317 pub max_concurrent_requests: usize,
318}
319
320impl Default for RateLimitConfig {
321 fn default() -> Self {
322 Self {
323 max_concurrent_requests: 100,
324 }
325 }
326}
327
328impl RateLimitConfig {
329 /// Create config with unlimited concurrency
330 #[must_use]
331 pub fn unlimited() -> Self {
332 Self {
333 max_concurrent_requests: usize::MAX,
334 }
335 }
336
337 /// Create config with very conservative limit
338 #[must_use]
339 pub fn conservative() -> Self {
340 Self {
341 max_concurrent_requests: 10,
342 }
343 }
344}
345
346/// Configuration for redirect behavior
347///
348/// Controls how the HTTP client handles 3xx redirect responses with security protections.
349///
350/// ## Security Features
351///
352/// - **Same-origin enforcement**: By default, only follows redirects to the same host
353/// - **Header stripping**: Removes `Authorization`, `Cookie` on cross-origin redirects
354/// - **Downgrade protection**: Blocks HTTPS → HTTP redirects
355/// - **Host allow-list**: Configurable list of trusted redirect targets
356///
357/// ## Example
358///
359/// ```rust,ignore
360/// use toolkit_http::RedirectConfig;
361/// use std::collections::HashSet;
362///
363/// // Permissive mode for general-purpose clients
364/// let config = RedirectConfig::permissive();
365///
366/// // Custom allow-list for trusted hosts
367/// let config = RedirectConfig {
368/// same_origin_only: true,
369/// allowed_redirect_hosts: HashSet::from(["cdn.example.com".to_string()]),
370/// ..Default::default()
371/// };
372/// ```
373#[derive(Debug, Clone)]
374pub struct RedirectConfig {
375 /// Maximum number of redirects to follow (default: 10)
376 ///
377 /// Set to `0` to disable redirect following entirely.
378 pub max_redirects: usize,
379
380 /// Only allow same-origin redirects (default: true)
381 ///
382 /// When `true`, redirects to different hosts are blocked unless the target
383 /// host is in `allowed_redirect_hosts`.
384 ///
385 /// **Security**: This is the safest default, preventing SSRF attacks where
386 /// a malicious server redirects requests to internal services.
387 pub same_origin_only: bool,
388
389 /// Hosts that are allowed as redirect targets even when `same_origin_only` is true
390 ///
391 /// Use this to allow redirects to known, trusted hosts (e.g., CDN domains,
392 /// authentication servers).
393 ///
394 /// **Note**: Entries should be hostnames only, without scheme or port.
395 /// Example: `"cdn.example.com"`, not `"https://cdn.example.com"`.
396 pub allowed_redirect_hosts: HashSet<String>,
397
398 /// Strip sensitive headers on cross-origin redirects (default: true)
399 ///
400 /// When a redirect goes to a different origin (even if allowed), this removes:
401 /// - `Authorization` header (prevents credential leakage)
402 /// - `Cookie` header (prevents session hijacking)
403 /// - `Proxy-Authorization` header
404 ///
405 /// **Security**: Always keep this enabled unless you have specific requirements.
406 pub strip_sensitive_headers: bool,
407
408 /// Allow HTTPS → HTTP downgrades (default: false)
409 ///
410 /// When `false`, redirects from HTTPS to HTTP are blocked.
411 ///
412 /// **Security**: Downgrades expose traffic to interception. Only enable
413 /// for testing with local mock servers.
414 pub allow_https_downgrade: bool,
415}
416
417impl Default for RedirectConfig {
418 fn default() -> Self {
419 Self {
420 max_redirects: 10,
421 same_origin_only: true,
422 allowed_redirect_hosts: HashSet::new(),
423 strip_sensitive_headers: true,
424 allow_https_downgrade: false,
425 }
426 }
427}
428
429impl RedirectConfig {
430 /// Create a permissive configuration that allows all redirects with header stripping
431 ///
432 /// This is suitable for general-purpose HTTP clients that need to follow
433 /// redirects to any host, but still want protection against credential leakage.
434 ///
435 /// **Note**: This configuration still blocks HTTPS → HTTP downgrades.
436 #[must_use]
437 pub fn permissive() -> Self {
438 Self {
439 max_redirects: 10,
440 same_origin_only: false,
441 allowed_redirect_hosts: HashSet::new(),
442 strip_sensitive_headers: true,
443 allow_https_downgrade: false,
444 }
445 }
446
447 /// Create a configuration that disables redirect following
448 #[must_use]
449 pub fn disabled() -> Self {
450 Self {
451 max_redirects: 0,
452 ..Default::default()
453 }
454 }
455
456 /// Create a configuration for testing (allows HTTP, permissive)
457 ///
458 /// **WARNING**: Only use for local testing with mock servers.
459 #[must_use]
460 pub fn for_testing() -> Self {
461 Self {
462 max_redirects: 10,
463 same_origin_only: false,
464 allowed_redirect_hosts: HashSet::new(),
465 strip_sensitive_headers: true, // Still strip headers even in tests
466 allow_https_downgrade: true, // Allow for HTTP mock servers
467 }
468 }
469}
470
471/// TLS root certificate configuration
472#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
473#[non_exhaustive]
474pub enum TlsRootConfig {
475 /// Use Mozilla's root certificates (webpki-roots, no OS dependency)
476 #[default]
477 WebPki,
478 /// Use OS native root certificate store
479 Native,
480}
481
482/// Transport security configuration
483///
484/// Controls whether the client enforces TLS or allows insecure HTTP.
485#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
486#[non_exhaustive]
487pub enum TransportSecurity {
488 /// Require TLS for all connections (HTTPS only)
489 TlsOnly,
490 /// Allow insecure HTTP connections
491 ///
492 /// Use [`HttpClientBuilder::deny_insecure_http`] to switch to `TlsOnly`
493 /// when TLS enforcement is required.
494 ///
495 /// **FIPS**: under `--features fips`, configuring this on a builder causes
496 /// [`HttpClientBuilder::build`] to return [`HttpError::InsecureTransport`].
497 /// Use [`HttpClientConfig::for_testing`] only for non-FIPS local mocks.
498 ///
499 /// [`HttpClientBuilder::deny_insecure_http`]: crate::builder::HttpClientBuilder::deny_insecure_http
500 /// [`HttpClientBuilder::build`]: crate::builder::HttpClientBuilder::build
501 /// [`HttpError::InsecureTransport`]: crate::error::HttpError::InsecureTransport
502 #[default]
503 AllowInsecureHttp,
504}
505
506/// Default transport security for built-in presets.
507///
508/// Under `--features fips` every non-testing preset defaults to
509/// [`TransportSecurity::TlsOnly`] so cleartext HTTP cannot be selected by
510/// accident; otherwise the historical [`TransportSecurity::AllowInsecureHttp`]
511/// default is retained for local development convenience.
512#[cfg(feature = "fips")]
513const DEFAULT_TRANSPORT: TransportSecurity = TransportSecurity::TlsOnly;
514#[cfg(not(feature = "fips"))]
515const DEFAULT_TRANSPORT: TransportSecurity = TransportSecurity::AllowInsecureHttp;
516
517/// Minimum TLS protocol version the client will negotiate.
518///
519/// Maps onto the rustls protocol-version slice passed to
520/// `ClientConfig::builder_with_provider(..).with_protocol_versions(..)`:
521/// - [`TlsVersion::Tls12`] advertises both TLS 1.2 and TLS 1.3 (the historical
522/// `with_safe_default_protocol_versions()` behaviour).
523/// - [`TlsVersion::Tls13`] advertises TLS 1.3 only.
524///
525/// This is a *user* knob; it does not relax FIPS hardening. Under
526/// `--features fips`, `tls::apply_fips_hardening` still asserts
527/// `ClientConfig::fips()`, so a version selection incompatible with the active
528/// FIPS provider surfaces as a [`crate::error::HttpError::Tls`] at build time.
529#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
530#[non_exhaustive]
531pub enum TlsVersion {
532 /// Allow TLS 1.2 and TLS 1.3 (default — matches rustls safe defaults).
533 #[default]
534 Tls12,
535 /// Require TLS 1.3; reject TLS 1.2 handshakes.
536 Tls13,
537}
538
539/// Client-certificate (mutual TLS) identity.
540///
541/// Holds filesystem paths to PEM-encoded material rather than parsed key bytes
542/// so that [`HttpClientConfig`] stays cheaply `Clone`/`Debug` and no private-key
543/// bytes are held in the config. The files are read and parsed lazily in
544/// [`HttpClientBuilder::build`]; IO or parse failures are reported as
545/// [`crate::error::HttpError::Tls`].
546///
547/// [`HttpClientBuilder::build`]: crate::builder::HttpClientBuilder::build
548#[derive(Debug, Clone, PartialEq, Eq)]
549#[non_exhaustive]
550pub struct ClientAuthConfig {
551 /// Path to a PEM file containing the client certificate chain
552 /// (leaf first, then intermediates).
553 pub cert_chain: PathBuf,
554 /// Path to a PEM file containing the client private key
555 /// (PKCS#8, PKCS#1/RSA, or SEC1/EC).
556 pub key: PathBuf,
557}
558
559impl ClientAuthConfig {
560 /// Construct a mutual-TLS identity from PEM cert-chain and key file paths.
561 #[must_use]
562 pub fn new(cert_chain: impl Into<PathBuf>, key: impl Into<PathBuf>) -> Self {
563 Self {
564 cert_chain: cert_chain.into(),
565 key: key.into(),
566 }
567 }
568}
569
570/// TLS handshake configuration for the HTTP client.
571///
572/// Carries knobs that shape the rustls `ClientConfig` beyond the root-trust
573/// strategy (which lives in [`TlsRootConfig`]):
574/// - [`TlsConfig::min_version`] — minimum negotiated protocol version.
575/// - [`TlsConfig::client_auth`] — optional mutual-TLS client identity.
576#[derive(Debug, Clone, Default, PartialEq, Eq)]
577#[non_exhaustive]
578pub struct TlsConfig {
579 /// Minimum TLS protocol version (default: [`TlsVersion::Tls12`]).
580 pub min_version: TlsVersion,
581 /// Optional client-certificate identity for mutual TLS (default: `None`).
582 pub client_auth: Option<ClientAuthConfig>,
583}
584
585/// Overall HTTP client configuration
586#[derive(Debug, Clone)]
587#[non_exhaustive]
588pub struct HttpClientConfig {
589 /// Per-request timeout (default: 30 seconds)
590 ///
591 /// This timeout applies to each individual HTTP request/attempt.
592 /// If retries are enabled, each retry attempt gets its own timeout.
593 pub request_timeout: Duration,
594
595 /// Total timeout spanning all retry attempts (default: None)
596 ///
597 /// When set, the entire operation (including all retries and backoff delays)
598 /// must complete within this duration. If the deadline is exceeded,
599 /// the request fails with `HttpError::DeadlineExceeded(total_timeout)`.
600 ///
601 /// When `None`, there is no total deadline - each attempt can take up to
602 /// `request_timeout`, and retries can continue indefinitely within their limits.
603 pub total_timeout: Option<Duration>,
604
605 /// Maximum response body size in bytes (default: 10 MB)
606 pub max_body_size: usize,
607
608 /// User-Agent header value (default: "toolkit-http/1.0")
609 pub user_agent: String,
610
611 /// Retry policy configuration
612 pub retry: Option<RetryConfig>,
613
614 /// Rate limiting / concurrency configuration
615 pub rate_limit: Option<RateLimitConfig>,
616
617 /// Transport security mode.
618 ///
619 /// Default: `TlsOnly` under `--features fips`, `AllowInsecureHttp` otherwise.
620 /// Only [`HttpClientConfig::for_testing`] keeps `AllowInsecureHttp` regardless
621 /// of features. Under `--features fips`, [`HttpClientBuilder::build`] returns
622 /// [`HttpError::InsecureTransport`] when this is `AllowInsecureHttp`.
623 ///
624 /// Use [`HttpClientBuilder::deny_insecure_http`] to enforce TLS for all connections.
625 ///
626 /// [`HttpClientBuilder::build`]: crate::builder::HttpClientBuilder::build
627 /// [`HttpError::InsecureTransport`]: crate::error::HttpError::InsecureTransport
628 pub transport: TransportSecurity,
629
630 /// TLS root certificate strategy (default: `WebPki`)
631 pub tls_roots: TlsRootConfig,
632
633 /// TLS handshake configuration: minimum protocol version and optional
634 /// mutual-TLS client identity (default: [`TlsConfig::default`] — TLS 1.2
635 /// floor, no client auth).
636 pub tls: TlsConfig,
637
638 /// Enable OpenTelemetry tracing layer (default: false)
639 /// Creates spans for outbound requests and injects trace context headers.
640 pub otel: bool,
641
642 /// Buffer capacity for concurrent request handling (default: 1024)
643 ///
644 /// The HTTP client uses an internal buffer to allow multiple concurrent
645 /// requests without external locking. This sets the maximum number of
646 /// requests that can be queued waiting for processing.
647 pub buffer_capacity: usize,
648
649 /// Redirect policy configuration (default: same-origin only with header stripping)
650 ///
651 /// Controls how 3xx redirect responses are handled with security protections:
652 /// - Same-origin enforcement (SSRF protection)
653 /// - Sensitive header stripping on cross-origin redirects
654 /// - HTTPS downgrade protection
655 ///
656 /// Use `RedirectConfig::permissive()` for general-purpose HTTP client behavior
657 /// that allows cross-origin redirects with header stripping.
658 ///
659 /// Use `RedirectConfig::disabled()` to turn off redirect following entirely.
660 pub redirect: RedirectConfig,
661
662 /// Timeout for idle connections in the pool (default: 90 seconds)
663 ///
664 /// Connections that remain idle (unused) for longer than this duration
665 /// will be closed and removed from the pool. This prevents resource leaks
666 /// and ensures connections don't become stale.
667 ///
668 /// Set to `None` to use hyper-util's default idle timeout.
669 pub pool_idle_timeout: Option<Duration>,
670
671 /// Maximum number of idle connections per host (default: 32)
672 ///
673 /// Limits how many idle connections are kept in the pool for each host.
674 /// Setting this to `0` disables connection reuse entirely.
675 /// Setting this too high may waste resources on rarely-used connections.
676 ///
677 /// **Note**: This only limits *idle* connections. Active connections are
678 /// not limited by this setting.
679 pub pool_max_idle_per_host: usize,
680}
681
682impl Default for HttpClientConfig {
683 fn default() -> Self {
684 Self {
685 request_timeout: Duration::from_secs(30),
686 total_timeout: None,
687 max_body_size: 10 * 1024 * 1024, // 10 MB
688 user_agent: DEFAULT_USER_AGENT.to_owned(),
689 retry: Some(RetryConfig::default()),
690 rate_limit: Some(RateLimitConfig::default()),
691 transport: DEFAULT_TRANSPORT,
692 tls_roots: TlsRootConfig::default(),
693 tls: TlsConfig::default(),
694 otel: false,
695 buffer_capacity: 1024,
696 redirect: RedirectConfig::default(),
697 pool_idle_timeout: Some(Duration::from_secs(90)),
698 pool_max_idle_per_host: 32,
699 }
700 }
701}
702
703impl HttpClientConfig {
704 /// Create minimal configuration (no retry, no rate limit, small timeout)
705 #[must_use]
706 pub fn minimal() -> Self {
707 Self {
708 request_timeout: Duration::from_secs(10),
709 total_timeout: None,
710 max_body_size: 1024 * 1024, // 1 MB
711 user_agent: DEFAULT_USER_AGENT.to_owned(),
712 retry: None,
713 rate_limit: None,
714 transport: DEFAULT_TRANSPORT,
715 tls_roots: TlsRootConfig::default(),
716 tls: TlsConfig::default(),
717 otel: false,
718 buffer_capacity: 256,
719 redirect: RedirectConfig::default(),
720 pool_idle_timeout: Some(Duration::from_secs(30)),
721 pool_max_idle_per_host: 8,
722 }
723 }
724
725 /// Create configuration for infrastructure services (aggressive retry, large timeout)
726 #[must_use]
727 pub fn infra_default() -> Self {
728 Self {
729 request_timeout: Duration::from_mins(1),
730 total_timeout: None,
731 max_body_size: 50 * 1024 * 1024, // 50 MB
732 user_agent: DEFAULT_USER_AGENT.to_owned(),
733 retry: Some(RetryConfig::aggressive()),
734 rate_limit: Some(RateLimitConfig::default()),
735 transport: DEFAULT_TRANSPORT,
736 tls_roots: TlsRootConfig::default(),
737 tls: TlsConfig::default(),
738 otel: false,
739 buffer_capacity: 1024,
740 redirect: RedirectConfig::default(),
741 pool_idle_timeout: Some(Duration::from_mins(2)),
742 pool_max_idle_per_host: 64,
743 }
744 }
745
746 /// Create configuration for `OAuth2` token endpoints (conservative retry)
747 ///
748 /// Token endpoints use POST but are effectively idempotent for retry purposes:
749 /// - Getting a token twice is safe (you'd just use the second one)
750 /// - Transport errors before response mean no token was issued
751 ///
752 /// This config retries on transport errors, timeout, and 429 for all methods.
753 #[must_use]
754 pub fn token_endpoint() -> Self {
755 Self {
756 request_timeout: Duration::from_secs(30),
757 total_timeout: None,
758 max_body_size: 1024 * 1024, // 1 MB
759 user_agent: DEFAULT_USER_AGENT.to_owned(),
760 retry: Some(RetryConfig {
761 max_retries: 3,
762 // For token endpoints: retry transport errors, timeout, and 429
763 // Note: Token requests (POST) are effectively idempotent - getting
764 // a token twice is safe, so we put these in always_retry
765 always_retry: HashSet::from([
766 RetryTrigger::TransportError,
767 RetryTrigger::Timeout,
768 RetryTrigger::TOO_MANY_REQUESTS,
769 ]),
770 idempotent_retry: HashSet::new(), // No additional retries for 5xx
771 ignore_retry_after: false,
772 retry_response_drain_limit: DEFAULT_RETRY_RESPONSE_DRAIN_LIMIT,
773 idempotency_key_header: None, // Not needed - always_retry handles all cases
774 ..RetryConfig::default()
775 }),
776 rate_limit: Some(RateLimitConfig::conservative()),
777 transport: DEFAULT_TRANSPORT,
778 tls_roots: TlsRootConfig::default(),
779 tls: TlsConfig::default(),
780 otel: false,
781 buffer_capacity: 256,
782 redirect: RedirectConfig::default(),
783 pool_idle_timeout: Some(Duration::from_mins(1)),
784 pool_max_idle_per_host: 4,
785 }
786 }
787
788 /// Create configuration for testing with mock servers.
789 ///
790 /// **This is the only built-in preset that sets
791 /// `transport: TransportSecurity::AllowInsecureHttp`** — every other
792 /// preset (`default`, `minimal`, `infra_default`, `token_endpoint`, `sse`)
793 /// uses `DEFAULT_TRANSPORT`, which is `TlsOnly` under `--features fips`.
794 ///
795 /// Under `--features fips`, [`HttpClientBuilder::build`] still rejects
796 /// `AllowInsecureHttp` and returns [`HttpError::InsecureTransport`]; this
797 /// preset is intended for non-FIPS test code that wires up `httpmock` or
798 /// other plaintext mock servers.
799 ///
800 /// [`HttpClientBuilder::build`]: crate::builder::HttpClientBuilder::build
801 /// [`HttpError::InsecureTransport`]: crate::error::HttpError::InsecureTransport
802 #[must_use]
803 pub fn for_testing() -> Self {
804 Self {
805 request_timeout: Duration::from_secs(10),
806 total_timeout: None,
807 max_body_size: 1024 * 1024, // 1 MB
808 user_agent: DEFAULT_USER_AGENT.to_owned(),
809 retry: None,
810 rate_limit: None,
811 transport: TransportSecurity::AllowInsecureHttp,
812 tls_roots: TlsRootConfig::default(),
813 tls: TlsConfig::default(),
814 otel: false,
815 buffer_capacity: 256,
816 redirect: RedirectConfig::for_testing(),
817 pool_idle_timeout: Some(Duration::from_secs(10)),
818 pool_max_idle_per_host: 4,
819 }
820 }
821
822 /// Create configuration optimized for Server-Sent Events (SSE) streaming.
823 ///
824 /// SSE connections are long-lived HTTP requests where the server holds the
825 /// connection open and pushes events. This preset disables retry and rate
826 /// limiting, and sets a permissive request timeout.
827 ///
828 /// # Timeout behavior
829 ///
830 /// `request_timeout` is set to 24 hours rather than truly unlimited,
831 /// because `TimeoutLayer` requires a finite `Duration`. Override if needed:
832 ///
833 /// ```rust,ignore
834 /// let mut config = HttpClientConfig::sse();
835 /// config.request_timeout = Duration::from_secs(3600); // 1 hour
836 /// let client = HttpClientBuilder::with_config(config).build()?;
837 /// ```
838 ///
839 /// # Streaming
840 ///
841 /// Use [`HttpResponse::into_body()`] for streaming — it bypasses the
842 /// `max_body_size` limit. SSE reconnection with `Last-Event-ID` is the
843 /// caller's responsibility.
844 ///
845 /// ```rust,ignore
846 /// let client = HttpClientBuilder::with_config(HttpClientConfig::sse()).build()?;
847 ///
848 /// let response = client
849 /// .get("https://api.example.com/events")
850 /// .header("accept", "text/event-stream")
851 /// .send()
852 /// .await?;
853 ///
854 /// let mut body = response.into_body();
855 /// while let Some(frame) = body.frame().await {
856 /// let frame = frame?;
857 /// if let Some(chunk) = frame.data_ref() {
858 /// // parse SSE event data
859 /// }
860 /// }
861 /// ```
862 #[must_use]
863 pub fn sse() -> Self {
864 Self {
865 request_timeout: Duration::from_hours(24), // 24 hours
866 total_timeout: None,
867 max_body_size: 10 * 1024 * 1024, // 10 MB (only for bytes()/json(), not into_body())
868 user_agent: DEFAULT_USER_AGENT.to_owned(),
869 retry: None, // SSE reconnection is protocol-level (Last-Event-ID)
870 rate_limit: None,
871 transport: DEFAULT_TRANSPORT,
872 tls_roots: TlsRootConfig::default(),
873 tls: TlsConfig::default(),
874 otel: false,
875 buffer_capacity: 64,
876 redirect: RedirectConfig::default(),
877 pool_idle_timeout: None, // use hyper-util default
878 pool_max_idle_per_host: 1,
879 }
880 }
881
882 /// Create configuration for a reverse-proxy data plane (e.g. the
883 /// api-gateway edge forwarding to out-of-process gears).
884 ///
885 /// A gateway data plane has different requirements from a general-purpose
886 /// client, because it forwards on behalf of an external caller rather than
887 /// making its own calls:
888 ///
889 /// - **No retries.** The edge must not silently re-send a client's request
890 /// (duplicating non-idempotent side effects) nor amplify load against an
891 /// upstream that is already failing. Retry/idempotency is the client's or
892 /// the upstream's concern.
893 /// - **No client-side rate limit.** A single shared concurrency semaphore
894 /// across the whole gateway would turn request N+1 into a spurious `503`
895 /// `Overloaded`; back-pressure belongs to the upstream and the listener.
896 /// - **No response body cap.** [`max_body_size`](Self::max_body_size) is set
897 /// effectively unbounded so large downloads stream through untruncated
898 /// (the forwarder streams the body via
899 /// [`HttpResponse::into_limited_body`](crate::HttpResponse::into_limited_body)).
900 /// The upstream gear owns its own size limits.
901 /// - **No blanket request timeout.** Set to 24h (the `TimeoutLayer` floor is
902 /// a finite `Duration`) so long-lived responses — SSE, chat streaming —
903 /// are not cut off mid-stream at 30s.
904 /// - **No redirect following.** A reverse proxy returns `3xx` to the client
905 /// verbatim rather than resolving it server-side.
906 #[must_use]
907 pub fn proxy() -> Self {
908 Self {
909 request_timeout: Duration::from_hours(24), // effectively "no blanket timeout"
910 total_timeout: None,
911 max_body_size: usize::MAX, // no cap: stream large downloads untruncated
912 user_agent: DEFAULT_USER_AGENT.to_owned(),
913 retry: None, // the edge must not re-send or amplify load
914 rate_limit: None, // no gateway-wide concurrency semaphore
915 transport: DEFAULT_TRANSPORT,
916 tls_roots: TlsRootConfig::default(),
917 tls: TlsConfig::default(),
918 otel: false,
919 buffer_capacity: 1024,
920 redirect: RedirectConfig::disabled(), // pass 3xx back to the client
921 pool_idle_timeout: Some(Duration::from_secs(90)),
922 pool_max_idle_per_host: 64, // fan out to many upstream gears
923 }
924 }
925}
926
927#[cfg(test)]
928#[cfg_attr(coverage_nightly, coverage(off))]
929mod tests {
930 use super::*;
931
932 #[test]
933 fn test_retry_trigger_constants() {
934 assert_eq!(RetryTrigger::TOO_MANY_REQUESTS, RetryTrigger::Status(429));
935 assert_eq!(RetryTrigger::REQUEST_TIMEOUT, RetryTrigger::Status(408));
936 assert_eq!(
937 RetryTrigger::INTERNAL_SERVER_ERROR,
938 RetryTrigger::Status(500)
939 );
940 assert_eq!(RetryTrigger::BAD_GATEWAY, RetryTrigger::Status(502));
941 assert_eq!(RetryTrigger::SERVICE_UNAVAILABLE, RetryTrigger::Status(503));
942 assert_eq!(RetryTrigger::GATEWAY_TIMEOUT, RetryTrigger::Status(504));
943 }
944
945 #[test]
946 fn test_is_idempotent_method() {
947 // Idempotent per RFC 9110
948 assert!(is_idempotent_method(&http::Method::GET));
949 assert!(is_idempotent_method(&http::Method::HEAD));
950 assert!(is_idempotent_method(&http::Method::PUT));
951 assert!(is_idempotent_method(&http::Method::DELETE));
952 assert!(is_idempotent_method(&http::Method::OPTIONS));
953 assert!(is_idempotent_method(&http::Method::TRACE));
954 // Non-idempotent
955 assert!(!is_idempotent_method(&http::Method::POST));
956 assert!(!is_idempotent_method(&http::Method::PATCH));
957 }
958
959 #[test]
960 fn test_retry_config_defaults() {
961 let config = RetryConfig::default();
962 assert_eq!(config.max_retries, 3);
963 assert_eq!(config.backoff.initial, Duration::from_millis(100));
964 assert_eq!(config.backoff.max, Duration::from_secs(10));
965 assert!((config.backoff.multiplier - 2.0).abs() < f64::EPSILON);
966 assert!(config.backoff.jitter);
967
968 // Check always_retry defaults - only 429 is always retried
969 assert!(
970 config
971 .always_retry
972 .contains(&RetryTrigger::TOO_MANY_REQUESTS)
973 );
974 assert_eq!(config.always_retry.len(), 1);
975
976 // Check idempotent_retry defaults - includes TransportError and Timeout for safety
977 assert!(
978 config
979 .idempotent_retry
980 .contains(&RetryTrigger::TransportError)
981 );
982 assert!(config.idempotent_retry.contains(&RetryTrigger::Timeout));
983 assert!(
984 config
985 .idempotent_retry
986 .contains(&RetryTrigger::REQUEST_TIMEOUT)
987 );
988 assert!(
989 config
990 .idempotent_retry
991 .contains(&RetryTrigger::INTERNAL_SERVER_ERROR)
992 );
993 assert!(config.idempotent_retry.contains(&RetryTrigger::BAD_GATEWAY));
994 assert!(
995 config
996 .idempotent_retry
997 .contains(&RetryTrigger::SERVICE_UNAVAILABLE)
998 );
999 assert!(
1000 config
1001 .idempotent_retry
1002 .contains(&RetryTrigger::GATEWAY_TIMEOUT)
1003 );
1004 assert_eq!(config.idempotent_retry.len(), 7);
1005
1006 // Default respects Retry-After header
1007 assert!(!config.ignore_retry_after);
1008
1009 // Default drain limit
1010 assert_eq!(
1011 config.retry_response_drain_limit,
1012 DEFAULT_RETRY_RESPONSE_DRAIN_LIMIT
1013 );
1014
1015 // Default idempotency key header
1016 assert_eq!(
1017 config.idempotency_key_header,
1018 Some(http::header::HeaderName::from_static(
1019 IDEMPOTENCY_KEY_HEADER_LOWER
1020 ))
1021 );
1022 }
1023
1024 #[test]
1025 fn test_retry_config_disabled() {
1026 let config = RetryConfig::disabled();
1027 assert_eq!(config.max_retries, 0);
1028 }
1029
1030 #[test]
1031 fn test_retry_config_aggressive() {
1032 let config = RetryConfig::aggressive();
1033 assert_eq!(config.max_retries, 5);
1034 assert_eq!(config.backoff.initial, Duration::from_millis(50));
1035 assert_eq!(config.backoff.max, Duration::from_secs(30));
1036 // Aggressive moves all 5xx to always_retry
1037 assert!(
1038 config
1039 .always_retry
1040 .contains(&RetryTrigger::INTERNAL_SERVER_ERROR)
1041 );
1042 assert!(config.idempotent_retry.is_empty());
1043 }
1044
1045 #[test]
1046 fn test_should_retry_always() {
1047 let config = RetryConfig::default();
1048
1049 // 429 always retries regardless of method or idempotency key
1050 assert!(config.should_retry(RetryTrigger::TOO_MANY_REQUESTS, &http::Method::GET, false));
1051 assert!(config.should_retry(RetryTrigger::TOO_MANY_REQUESTS, &http::Method::POST, false));
1052 assert!(config.should_retry(RetryTrigger::TOO_MANY_REQUESTS, &http::Method::POST, true));
1053 }
1054
1055 #[test]
1056 fn test_should_retry_idempotent_only() {
1057 let config = RetryConfig::default();
1058
1059 // TransportError retries for idempotent methods only (by default)
1060 assert!(config.should_retry(RetryTrigger::TransportError, &http::Method::GET, false));
1061 assert!(!config.should_retry(RetryTrigger::TransportError, &http::Method::POST, false));
1062
1063 // 500 only retries for idempotent methods
1064 assert!(config.should_retry(
1065 RetryTrigger::INTERNAL_SERVER_ERROR,
1066 &http::Method::GET,
1067 false
1068 ));
1069 assert!(!config.should_retry(
1070 RetryTrigger::INTERNAL_SERVER_ERROR,
1071 &http::Method::POST,
1072 false
1073 ));
1074
1075 // 503 only retries for idempotent methods
1076 assert!(config.should_retry(
1077 RetryTrigger::SERVICE_UNAVAILABLE,
1078 &http::Method::HEAD,
1079 false
1080 ));
1081 assert!(!config.should_retry(
1082 RetryTrigger::SERVICE_UNAVAILABLE,
1083 &http::Method::POST,
1084 false
1085 ));
1086
1087 // Timeout only retries for idempotent methods
1088 assert!(config.should_retry(RetryTrigger::Timeout, &http::Method::GET, false));
1089 assert!(!config.should_retry(RetryTrigger::Timeout, &http::Method::POST, false));
1090 }
1091
1092 #[test]
1093 fn test_should_retry_with_idempotency_key() {
1094 let config = RetryConfig::default();
1095
1096 // TransportError retries for non-idempotent methods when idempotency key is present
1097 assert!(config.should_retry(RetryTrigger::TransportError, &http::Method::POST, true));
1098 assert!(config.should_retry(RetryTrigger::TransportError, &http::Method::PUT, true));
1099 assert!(config.should_retry(RetryTrigger::TransportError, &http::Method::DELETE, true));
1100 assert!(config.should_retry(RetryTrigger::TransportError, &http::Method::PATCH, true));
1101
1102 // Timeout retries for non-idempotent methods when idempotency key is present
1103 assert!(config.should_retry(RetryTrigger::Timeout, &http::Method::POST, true));
1104
1105 // 500 retries for non-idempotent methods when idempotency key is present
1106 assert!(config.should_retry(
1107 RetryTrigger::INTERNAL_SERVER_ERROR,
1108 &http::Method::POST,
1109 true
1110 ));
1111 }
1112
1113 #[test]
1114 fn test_should_retry_not_configured() {
1115 let config = RetryConfig::default();
1116
1117 // 400 Bad Request is not in any retry set
1118 assert!(!config.should_retry(RetryTrigger::Status(400), &http::Method::GET, false));
1119 assert!(!config.should_retry(RetryTrigger::Status(400), &http::Method::POST, false));
1120 assert!(!config.should_retry(RetryTrigger::Status(400), &http::Method::POST, true)); // Even with idempotency key
1121
1122 // 404 Not Found is not in any retry set
1123 assert!(!config.should_retry(RetryTrigger::Status(404), &http::Method::GET, false));
1124 }
1125
1126 #[test]
1127 fn test_rate_limit_config_defaults() {
1128 let config = RateLimitConfig::default();
1129 assert_eq!(config.max_concurrent_requests, 100);
1130 }
1131
1132 #[test]
1133 fn test_rate_limit_config_unlimited() {
1134 let config = RateLimitConfig::unlimited();
1135 assert_eq!(config.max_concurrent_requests, usize::MAX);
1136 }
1137
1138 #[test]
1139 fn test_rate_limit_config_conservative() {
1140 let config = RateLimitConfig::conservative();
1141 assert_eq!(config.max_concurrent_requests, 10);
1142 }
1143
1144 #[test]
1145 fn test_http_client_config_defaults() {
1146 let config = HttpClientConfig::default();
1147 assert_eq!(config.request_timeout, Duration::from_secs(30));
1148 assert_eq!(config.max_body_size, 10 * 1024 * 1024);
1149 assert_eq!(config.user_agent, DEFAULT_USER_AGENT);
1150 assert!(config.retry.is_some());
1151 assert!(config.rate_limit.is_some());
1152 #[cfg(not(feature = "fips"))]
1153 assert_eq!(config.transport, TransportSecurity::AllowInsecureHttp);
1154 #[cfg(feature = "fips")]
1155 assert_eq!(config.transport, TransportSecurity::TlsOnly);
1156 // TLS knobs default to a TLS 1.2 floor with no mutual-TLS identity.
1157 assert_eq!(config.tls.min_version, TlsVersion::Tls12);
1158 assert!(config.tls.client_auth.is_none());
1159 assert!(!config.otel);
1160 assert_eq!(config.buffer_capacity, 1024);
1161 }
1162
1163 #[test]
1164 fn test_http_client_config_minimal() {
1165 let config = HttpClientConfig::minimal();
1166 assert_eq!(config.request_timeout, Duration::from_secs(10));
1167 assert_eq!(config.max_body_size, 1024 * 1024);
1168 assert!(config.retry.is_none());
1169 assert!(config.rate_limit.is_none());
1170 }
1171
1172 #[test]
1173 fn test_http_client_config_infra_default() {
1174 let config = HttpClientConfig::infra_default();
1175 assert_eq!(config.request_timeout, Duration::from_mins(1));
1176 assert_eq!(config.max_body_size, 50 * 1024 * 1024);
1177 assert!(config.retry.is_some());
1178 assert_eq!(config.retry.unwrap().max_retries, 5);
1179 }
1180
1181 #[test]
1182 fn test_http_client_config_token_endpoint() {
1183 let config = HttpClientConfig::token_endpoint();
1184 assert_eq!(config.request_timeout, Duration::from_secs(30));
1185
1186 let retry = config.retry.unwrap();
1187 // Token endpoint: no idempotent-only retries (conservative for auth)
1188 assert!(retry.idempotent_retry.is_empty());
1189 // But still retry transport errors and 429
1190 assert!(retry.always_retry.contains(&RetryTrigger::TransportError));
1191 assert!(
1192 retry
1193 .always_retry
1194 .contains(&RetryTrigger::TOO_MANY_REQUESTS)
1195 );
1196
1197 let rate_limit = config.rate_limit.unwrap();
1198 assert_eq!(rate_limit.max_concurrent_requests, 10); // Conservative
1199 }
1200
1201 #[test]
1202 fn test_http_client_config_for_testing() {
1203 let config = HttpClientConfig::for_testing();
1204 assert_eq!(config.transport, TransportSecurity::AllowInsecureHttp);
1205 assert!(config.retry.is_none());
1206 }
1207
1208 #[test]
1209 fn test_http_client_config_sse() {
1210 let config = HttpClientConfig::sse();
1211 assert_eq!(config.request_timeout, Duration::from_hours(24));
1212 assert!(config.total_timeout.is_none());
1213 assert!(config.retry.is_none());
1214 assert!(config.rate_limit.is_none());
1215 assert!(!config.otel);
1216 assert_eq!(config.buffer_capacity, 64);
1217 assert!(config.pool_idle_timeout.is_none());
1218 assert_eq!(config.pool_max_idle_per_host, 1);
1219 }
1220
1221 #[test]
1222 fn test_http_client_config_proxy() {
1223 let config = HttpClientConfig::proxy();
1224 // No retries, no client-side rate limit: the edge must not re-send or
1225 // impose a gateway-wide concurrency ceiling.
1226 assert!(config.retry.is_none());
1227 assert!(config.rate_limit.is_none());
1228 // No response body cap: large downloads stream untruncated.
1229 assert_eq!(config.max_body_size, usize::MAX);
1230 // No blanket request timeout that would cut SSE/streaming at 30s.
1231 assert_eq!(config.request_timeout, Duration::from_hours(24));
1232 assert!(config.total_timeout.is_none());
1233 // A reverse proxy returns 3xx to the client rather than following it.
1234 assert_eq!(config.redirect.max_redirects, 0);
1235 }
1236}