Skip to main content

modkit_http/
builder.rs

1use crate::config::{
2    HttpClientConfig, RedirectConfig, RetryConfig, TlsRootConfig, TransportSecurity,
3};
4use crate::error::HttpError;
5use crate::layers::{OtelLayer, RetryLayer, SecureRedirectPolicy, UserAgentLayer};
6use crate::response::ResponseBody;
7use crate::tls;
8use bytes::Bytes;
9use http::Response;
10use http_body_util::{BodyExt, Full};
11use hyper_rustls::HttpsConnector;
12use hyper_util::client::legacy::Client;
13use hyper_util::client::legacy::connect::HttpConnector;
14use hyper_util::rt::{TokioExecutor, TokioTimer};
15use std::time::Duration;
16use tower::buffer::Buffer;
17use tower::limit::ConcurrencyLimitLayer;
18use tower::load_shed::LoadShedLayer;
19use tower::timeout::TimeoutLayer;
20use tower::util::BoxCloneService;
21use tower::{ServiceBuilder, ServiceExt};
22use tower_http::decompression::DecompressionLayer;
23use tower_http::follow_redirect::FollowRedirectLayer;
24
25/// Type-erased inner service between layer composition steps in [`HttpClientBuilder::build`].
26type InnerService =
27    BoxCloneService<http::Request<Full<Bytes>>, http::Response<ResponseBody>, HttpError>;
28
29/// Builder for constructing an [`HttpClient`] with a layered tower middleware stack.
30pub struct HttpClientBuilder {
31    config: HttpClientConfig,
32    auth_layer: Option<Box<dyn FnOnce(InnerService) -> InnerService + Send>>,
33}
34
35impl HttpClientBuilder {
36    /// Create a new builder with default configuration
37    #[must_use]
38    pub fn new() -> Self {
39        Self {
40            config: HttpClientConfig::default(),
41            auth_layer: None,
42        }
43    }
44
45    /// Create a builder with a specific configuration
46    #[must_use]
47    pub fn with_config(config: HttpClientConfig) -> Self {
48        Self {
49            config,
50            auth_layer: None,
51        }
52    }
53
54    /// Set the per-request timeout
55    ///
56    /// This timeout applies to each individual HTTP request/attempt.
57    /// If retries are enabled, each retry attempt gets its own timeout.
58    #[must_use]
59    pub fn timeout(mut self, timeout: Duration) -> Self {
60        self.config.request_timeout = timeout;
61        self
62    }
63
64    /// Set the total timeout spanning all retry attempts
65    ///
66    /// When set, the entire operation (including all retries and backoff delays)
67    /// must complete within this duration. If the deadline is exceeded,
68    /// the request fails with `HttpError::DeadlineExceeded(total_timeout)`.
69    #[must_use]
70    pub fn total_timeout(mut self, timeout: Duration) -> Self {
71        self.config.total_timeout = Some(timeout);
72        self
73    }
74
75    /// Set the user agent string
76    #[must_use]
77    pub fn user_agent(mut self, user_agent: impl Into<String>) -> Self {
78        self.config.user_agent = user_agent.into();
79        self
80    }
81
82    /// Set the retry configuration
83    #[must_use]
84    pub fn retry(mut self, retry: Option<RetryConfig>) -> Self {
85        self.config.retry = retry;
86        self
87    }
88
89    /// Set the maximum response body size
90    #[must_use]
91    pub fn max_body_size(mut self, size: usize) -> Self {
92        self.config.max_body_size = size;
93        self
94    }
95
96    /// Set transport security mode
97    ///
98    /// Use `TransportSecurity::TlsOnly` to enforce HTTPS for all connections.
99    #[must_use]
100    pub fn transport(mut self, transport: TransportSecurity) -> Self {
101        self.config.transport = transport;
102        self
103    }
104
105    /// Deny insecure HTTP connections, enforcing TLS for all traffic
106    ///
107    /// Equivalent to `.transport(TransportSecurity::TlsOnly)`.
108    ///
109    /// Use this when TLS enforcement is required (e.g., production environments).
110    #[must_use]
111    pub fn deny_insecure_http(mut self) -> Self {
112        tracing::debug!(
113            target: "modkit_http::security",
114            "deny_insecure_http() called - enforcing TLS for all connections"
115        );
116        self.config.transport = TransportSecurity::TlsOnly;
117        self
118    }
119
120    /// Enable OpenTelemetry tracing layer
121    ///
122    /// When enabled, creates spans for outbound requests with HTTP metadata
123    /// and injects W3C trace context headers (when `otel` feature is enabled).
124    #[must_use]
125    pub fn with_otel(mut self) -> Self {
126        self.config.otel = true;
127        self
128    }
129
130    /// Insert an optional auth layer between retry and timeout in the stack.
131    ///
132    /// Stack position: `… → Retry → **this layer** → Timeout → …`
133    ///
134    /// The layer sits inside the retry loop so each attempt re-executes it
135    /// (e.g. re-reads a refreshed bearer token). Only one auth layer can be
136    /// set; a second call replaces the first.
137    #[must_use]
138    pub fn with_auth_layer(
139        mut self,
140        wrap: impl FnOnce(InnerService) -> InnerService + Send + 'static,
141    ) -> Self {
142        self.auth_layer = Some(Box::new(wrap));
143        self
144    }
145
146    /// Set the buffer capacity for concurrent request handling
147    ///
148    /// The HTTP client uses an internal buffer to allow concurrent requests
149    /// without external locking. This sets the maximum number of requests
150    /// that can be queued.
151    ///
152    /// **Note**: A capacity of 0 is invalid and will be clamped to 1.
153    /// Tower's Buffer panics with capacity=0, so we enforce minimum of 1.
154    #[must_use]
155    pub fn buffer_capacity(mut self, capacity: usize) -> Self {
156        // Clamp to at least 1 - tower::Buffer panics with capacity=0
157        self.config.buffer_capacity = capacity.max(1);
158        self
159    }
160
161    /// Set the maximum number of redirects to follow
162    ///
163    /// Set to `0` to disable redirect following (3xx responses pass through as-is).
164    /// Default: 10
165    #[must_use]
166    pub fn max_redirects(mut self, max_redirects: usize) -> Self {
167        self.config.redirect.max_redirects = max_redirects;
168        self
169    }
170
171    /// Disable redirect following
172    ///
173    /// Equivalent to `.max_redirects(0)`. When disabled, 3xx responses are
174    /// returned to the caller without following the `Location` header.
175    #[must_use]
176    pub fn no_redirects(mut self) -> Self {
177        self.config.redirect = RedirectConfig::disabled();
178        self
179    }
180
181    /// Set the redirect policy configuration
182    ///
183    /// Use this to configure redirect security settings:
184    /// - `same_origin_only`: Only follow redirects to the same host
185    /// - `strip_sensitive_headers`: Remove `Authorization`/`Cookie` on cross-origin
186    /// - `allow_https_downgrade`: Allow HTTPS → HTTP redirects (not recommended)
187    ///
188    /// # Example
189    ///
190    /// ```rust,ignore
191    /// let client = HttpClient::builder()
192    ///     .redirect(RedirectConfig::permissive()) // Allow all redirects with header stripping
193    ///     .build()?;
194    /// ```
195    #[must_use]
196    pub fn redirect(mut self, config: RedirectConfig) -> Self {
197        self.config.redirect = config;
198        self
199    }
200
201    /// Set the idle connection timeout for the connection pool
202    ///
203    /// Connections that remain idle for longer than this duration will be
204    /// closed and removed from the pool. Default: 90 seconds.
205    ///
206    /// Set to `None` to disable idle timeout (connections kept indefinitely).
207    #[must_use]
208    pub fn pool_idle_timeout(mut self, timeout: Option<Duration>) -> Self {
209        self.config.pool_idle_timeout = timeout;
210        self
211    }
212
213    /// Set the maximum number of idle connections per host
214    ///
215    /// Limits how many idle connections are kept in the pool for each host.
216    /// Default: 32.
217    ///
218    /// - Setting to `0` disables connection reuse entirely
219    /// - Setting too high may waste resources on rarely-used connections
220    #[must_use]
221    pub fn pool_max_idle_per_host(mut self, max: usize) -> Self {
222        self.config.pool_max_idle_per_host = max;
223        self
224    }
225
226    /// Build the HTTP client with all configured layers
227    ///
228    /// # Errors
229    /// Returns an error if TLS initialization fails or configuration is invalid
230    pub fn build(self) -> Result<crate::HttpClient, HttpError> {
231        let timeout = self.config.request_timeout;
232        let total_timeout = self.config.total_timeout;
233
234        // Build the HTTPS connector (may fail for Native roots if no valid certs)
235        let https = build_https_connector(self.config.tls_roots, self.config.transport)?;
236
237        // Create the base hyper client with HTTP/2 support and connection pool settings
238        let mut client_builder = Client::builder(TokioExecutor::new());
239
240        // Configure connection pool
241        // CRITICAL: pool_timer is required for pool_idle_timeout to work!
242        client_builder
243            .pool_timer(TokioTimer::new())
244            .pool_max_idle_per_host(self.config.pool_max_idle_per_host)
245            .http2_only(false); // Allow both HTTP/1 and HTTP/2 via ALPN
246
247        // Set idle timeout (None = no timeout, connections kept indefinitely)
248        if let Some(idle_timeout) = self.config.pool_idle_timeout {
249            client_builder.pool_idle_timeout(idle_timeout);
250        }
251
252        let hyper_client = client_builder.build::<_, Full<Bytes>>(https);
253
254        // Parse user agent header (may fail)
255        let ua_layer = UserAgentLayer::try_new(&self.config.user_agent)?;
256
257        // =======================================================================
258        // Tower Layer Stack (outer to inner)
259        // =======================================================================
260        //
261        // Request flow (outer → inner):
262        //   Buffer → OtelLayer → LoadShed/Concurrency → RetryLayer →
263        //   [AuthLayer?] → ErrorMapping → Timeout → UserAgent →
264        //   Decompression → FollowRedirect → hyper_client
265        //
266        // AuthLayer (if set via with_auth_layer) sits inside the retry
267        // loop so each retry re-acquires credentials (e.g. refreshed
268        // bearer token).
269        //
270        // Response flow (inner → outer):
271        //   hyper_client → FollowRedirect → Decompression → UserAgent →
272        //   Timeout → ErrorMapping → [AuthLayer?] → RetryLayer →
273        //   LoadShed/Concurrency → OtelLayer → Buffer
274        //
275        // Key semantics (reqwest-like):
276        //   - send() returns Ok(Response) for ALL HTTP statuses (including 4xx/5xx)
277        //   - send() returns Err only for transport/timeout/TLS errors
278        //   - Non-2xx converted to error ONLY via error_for_status()
279        //   - RetryLayer handles both Err (transport) and Ok(Response) (status)
280        //     retries internally, draining body before retry for connection reuse
281        //   - FollowRedirect handles 3xx responses internally with security protections:
282        //     * Same-origin enforcement (default) - blocks SSRF attacks
283        //     * Sensitive header stripping on cross-origin redirects
284        //     * HTTPS downgrade protection
285        //
286        // =======================================================================
287        //
288        let redirect_policy = SecureRedirectPolicy::new(self.config.redirect.clone());
289
290        // Build the service stack with secure redirect following
291        let service = ServiceBuilder::new()
292            .layer(TimeoutLayer::new(timeout))
293            .layer(ua_layer)
294            .layer(DecompressionLayer::new())
295            .layer(FollowRedirectLayer::with_policy(redirect_policy))
296            .service(hyper_client);
297
298        // Map the decompression body to our boxed ResponseBody type.
299        // This converts Response<DecompressionBody<Incoming>> to Response<ResponseBody>.
300        //
301        // The decompression body's error type is tower_http::BoxError, which we convert
302        // to our boxed error type for consistency with the ResponseBody definition.
303        let service = service.map_response(map_decompression_response);
304
305        // Map errors to HttpError with proper timeout duration
306        let service = service.map_err(move |e: tower::BoxError| map_tower_error(e, timeout));
307
308        // Box the service for type erasure
309        let mut boxed_service = service.boxed_clone();
310
311        // Apply auth layer (between timeout and retry).
312        // Inside retry so each retry attempt re-acquires the token.
313        if let Some(wrap) = self.auth_layer {
314            boxed_service = wrap(boxed_service);
315        }
316
317        // Conditionally wrap with RetryLayer
318        //
319        // RetryLayer handles retries for both:
320        // - Err(HttpError::Transport/Timeout) - transport-level failures
321        // - Ok(Response) with retryable status codes (429, 5xx for GET, etc.)
322        //
323        // When retrying on status codes, RetryLayer drains the response body
324        // (up to configured limit) to allow connection reuse.
325        //
326        // If total_timeout is set, the entire operation (including all retries)
327        // must complete within that duration.
328        if let Some(ref retry_config) = self.config.retry {
329            let retry_layer = RetryLayer::with_total_timeout(retry_config.clone(), total_timeout);
330            let retry_service = ServiceBuilder::new()
331                .layer(retry_layer)
332                .service(boxed_service);
333            boxed_service = retry_service.boxed_clone();
334        }
335
336        // Conditionally wrap with concurrency limit + load shedding
337        // LoadShedLayer returns error immediately when ConcurrencyLimitLayer is saturated
338        // instead of waiting indefinitely (Poll::Pending)
339        if let Some(rate_limit) = self.config.rate_limit
340            && rate_limit.max_concurrent_requests < usize::MAX
341        {
342            let limited_service = ServiceBuilder::new()
343                .layer(LoadShedLayer::new())
344                .layer(ConcurrencyLimitLayer::new(
345                    rate_limit.max_concurrent_requests,
346                ))
347                .service(boxed_service);
348            // Map load shed errors to HttpError::Overloaded
349            let limited_service = limited_service.map_err(map_load_shed_error);
350            boxed_service = limited_service.boxed_clone();
351        }
352
353        // Conditionally wrap with OTEL tracing layer (outermost layer before buffer)
354        // Applied last so it sees the final request after UserAgent and other modifications.
355        // Creates spans, records status, and injects trace context headers.
356        if self.config.otel {
357            let otel_service = ServiceBuilder::new()
358                .layer(OtelLayer::new())
359                .service(boxed_service);
360            boxed_service = otel_service.boxed_clone();
361        }
362
363        // Wrap in Buffer as the final step for true concurrent access
364        // Buffer spawns a background task that processes requests from a channel,
365        // providing Clone + Send + Sync without any mutex serialization.
366        let buffer_capacity = self.config.buffer_capacity.max(1);
367        let buffered_service: crate::client::BufferedService =
368            Buffer::new(boxed_service, buffer_capacity);
369
370        Ok(crate::HttpClient {
371            service: buffered_service,
372            max_body_size: self.config.max_body_size,
373            transport_security: self.config.transport,
374        })
375    }
376}
377
378impl Default for HttpClientBuilder {
379    fn default() -> Self {
380        Self::new()
381    }
382}
383
384/// Map tower errors to `HttpError` with actual timeout duration
385///
386/// Attempts to extract existing `HttpError` from the boxed error before
387/// wrapping as `Transport`. This preserves typed errors like `Overloaded`
388/// and `ServiceClosed` that may have been boxed by tower middleware.
389fn map_tower_error(err: tower::BoxError, timeout: Duration) -> HttpError {
390    if err.is::<tower::timeout::error::Elapsed>() {
391        return HttpError::Timeout(timeout);
392    }
393
394    // Try to extract existing HttpError before wrapping as Transport
395    match err.downcast::<HttpError>() {
396        Ok(http_err) => *http_err,
397        Err(other) => HttpError::Transport(other),
398    }
399}
400
401/// Map load shed errors to `HttpError::Overloaded`
402fn map_load_shed_error(err: tower::BoxError) -> HttpError {
403    if err.is::<tower::load_shed::error::Overloaded>() {
404        HttpError::Overloaded
405    } else {
406        // Pass through other HttpError types (from inner service)
407        match err.downcast::<HttpError>() {
408            Ok(http_err) => *http_err,
409            Err(err) => HttpError::Transport(err),
410        }
411    }
412}
413
414/// Map the decompression response to our boxed response body type.
415///
416/// This converts `Response<DecompressionBody<Incoming>>` to `Response<ResponseBody>`
417/// by boxing the body with appropriate error type mapping.
418fn map_decompression_response<B>(response: Response<B>) -> Response<ResponseBody>
419where
420    B: hyper::body::Body<Data = Bytes> + Send + Sync + 'static,
421    B::Error: Into<Box<dyn std::error::Error + Send + Sync>>,
422{
423    let (parts, body) = response.into_parts();
424    // Convert the decompression body errors to our boxed error type.
425    // tower-http's DecompressionBody uses tower_http::BoxError which is
426    // compatible with our Box<dyn Error + Send + Sync> via Into.
427    let boxed_body: ResponseBody = body.map_err(Into::into).boxed();
428    Response::from_parts(parts, boxed_body)
429}
430
431/// Build the HTTPS connector with the specified TLS root configuration.
432///
433/// For `TlsRootConfig::Native`, uses cached native root certificates to avoid
434/// repeated OS certificate store lookups on each `build()` call.
435///
436/// HTTP/2 is enabled via `enable_all_versions()` which configures ALPN to
437/// advertise both h2 and http/1.1. Protocol selection happens during TLS
438/// handshake based on server support.
439///
440/// # Errors
441///
442/// Returns `HttpError::Tls` if `TlsRootConfig::Native` is requested but no
443/// valid root certificates are available from the OS certificate store.
444fn build_https_connector(
445    tls_roots: TlsRootConfig,
446    transport: TransportSecurity,
447) -> Result<HttpsConnector<HttpConnector>, HttpError> {
448    let allow_http = transport == TransportSecurity::AllowInsecureHttp;
449
450    match tls_roots {
451        TlsRootConfig::WebPki => {
452            let provider = tls::get_crypto_provider();
453            let builder = hyper_rustls::HttpsConnectorBuilder::new()
454                .with_provider_and_webpki_roots(provider)
455                // Preserve source error for debugging —
456                // rustls::Error implements Error + Send + Sync
457                .map_err(|e| HttpError::Tls(Box::new(e)))?;
458            let connector = if allow_http {
459                builder.https_or_http().enable_all_versions().build()
460            } else {
461                builder.https_only().enable_all_versions().build()
462            };
463            Ok(connector)
464        }
465        TlsRootConfig::Native => {
466            let client_config = tls::native_roots_client_config()
467                // Native returns String error; convert to boxed error for consistency
468                .map_err(|e| HttpError::Tls(e.into()))?;
469            let builder = hyper_rustls::HttpsConnectorBuilder::new().with_tls_config(client_config);
470            let connector = if allow_http {
471                builder.https_or_http().enable_all_versions().build()
472            } else {
473                builder.https_only().enable_all_versions().build()
474            };
475            Ok(connector)
476        }
477    }
478}
479
480#[cfg(test)]
481#[cfg_attr(coverage_nightly, coverage(off))]
482mod tests {
483    use super::*;
484    use crate::config::DEFAULT_USER_AGENT;
485
486    #[test]
487    fn test_builder_default() {
488        let builder = HttpClientBuilder::new();
489        assert_eq!(builder.config.request_timeout, Duration::from_secs(30));
490        assert_eq!(builder.config.user_agent, DEFAULT_USER_AGENT);
491        assert!(builder.config.retry.is_some());
492        assert_eq!(builder.config.buffer_capacity, 1024);
493    }
494
495    #[test]
496    fn test_builder_with_config() {
497        let config = HttpClientConfig::minimal();
498        let builder = HttpClientBuilder::with_config(config);
499        assert_eq!(builder.config.request_timeout, Duration::from_secs(10));
500    }
501
502    #[test]
503    fn test_builder_timeout() {
504        let builder = HttpClientBuilder::new().timeout(Duration::from_secs(60));
505        assert_eq!(builder.config.request_timeout, Duration::from_secs(60));
506    }
507
508    #[test]
509    fn test_builder_user_agent() {
510        let builder = HttpClientBuilder::new().user_agent("custom/1.0");
511        assert_eq!(builder.config.user_agent, "custom/1.0");
512    }
513
514    #[test]
515    fn test_builder_retry() {
516        let builder = HttpClientBuilder::new().retry(None);
517        assert!(builder.config.retry.is_none());
518    }
519
520    #[test]
521    fn test_builder_max_body_size() {
522        let builder = HttpClientBuilder::new().max_body_size(1024);
523        assert_eq!(builder.config.max_body_size, 1024);
524    }
525
526    #[test]
527    fn test_builder_transport_security() {
528        let builder = HttpClientBuilder::new().transport(TransportSecurity::TlsOnly);
529        assert_eq!(builder.config.transport, TransportSecurity::TlsOnly);
530
531        let builder = HttpClientBuilder::new().deny_insecure_http();
532        assert_eq!(builder.config.transport, TransportSecurity::TlsOnly);
533
534        let builder = HttpClientBuilder::new();
535        assert_eq!(
536            builder.config.transport,
537            TransportSecurity::AllowInsecureHttp
538        );
539    }
540
541    #[test]
542    fn test_builder_otel() {
543        let builder = HttpClientBuilder::new().with_otel();
544        assert!(builder.config.otel);
545    }
546
547    #[test]
548    fn test_builder_buffer_capacity() {
549        let builder = HttpClientBuilder::new().buffer_capacity(512);
550        assert_eq!(builder.config.buffer_capacity, 512);
551    }
552
553    /// Test that `buffer_capacity=0` is clamped to 1 to prevent panic.
554    ///
555    /// Tower's Buffer panics with capacity=0, so we enforce minimum of 1.
556    #[test]
557    fn test_builder_buffer_capacity_zero_clamped() {
558        let builder = HttpClientBuilder::new().buffer_capacity(0);
559        assert_eq!(
560            builder.config.buffer_capacity, 1,
561            "buffer_capacity=0 should be clamped to 1"
562        );
563    }
564
565    /// Test that `buffer_capacity=0` via config is clamped during `build()`.
566    #[tokio::test]
567    async fn test_builder_buffer_capacity_zero_in_config_clamped() {
568        let config = HttpClientConfig {
569            buffer_capacity: 0, // Invalid - should be clamped in build()
570            ..Default::default()
571        };
572        let result = HttpClientBuilder::with_config(config).build();
573        // Should succeed (clamped to 1), not panic
574        assert!(
575            result.is_ok(),
576            "build() should succeed with capacity clamped to 1"
577        );
578    }
579
580    #[tokio::test]
581    async fn test_builder_build_with_otel() {
582        let client = HttpClientBuilder::new().with_otel().build();
583        assert!(client.is_ok());
584    }
585
586    #[tokio::test]
587    async fn test_builder_with_auth_layer() {
588        let client = HttpClientBuilder::new()
589            .with_auth_layer(|svc| svc) // identity transform
590            .build();
591        assert!(client.is_ok());
592    }
593
594    #[tokio::test]
595    async fn test_builder_build() {
596        let client = HttpClientBuilder::new().build();
597        assert!(client.is_ok());
598    }
599
600    #[tokio::test]
601    async fn test_builder_build_with_deny_insecure_http() {
602        let client = HttpClientBuilder::new().deny_insecure_http().build();
603        assert!(client.is_ok());
604    }
605
606    #[tokio::test]
607    async fn test_builder_build_with_sse_config() {
608        use crate::config::HttpClientConfig;
609        let config = HttpClientConfig::sse();
610        let client = HttpClientBuilder::with_config(config).build();
611        assert!(client.is_ok(), "SSE config should build successfully");
612    }
613
614    #[tokio::test]
615    async fn test_builder_build_invalid_user_agent() {
616        let client = HttpClientBuilder::new()
617            .user_agent("invalid\x00agent")
618            .build();
619        assert!(client.is_err());
620    }
621
622    #[tokio::test]
623    async fn test_builder_default_uses_webpki_roots() {
624        let builder = HttpClientBuilder::new();
625        assert_eq!(builder.config.tls_roots, TlsRootConfig::WebPki);
626        // Build should succeed without OS native roots
627        let client = builder.build();
628        assert!(client.is_ok());
629    }
630
631    #[tokio::test]
632    async fn test_builder_native_roots() {
633        let config = HttpClientConfig {
634            tls_roots: TlsRootConfig::Native,
635            ..Default::default()
636        };
637        let result = HttpClientBuilder::with_config(config).build();
638
639        // Native roots may succeed or fail depending on OS certificate availability.
640        // On systems with certs: Ok(_)
641        // On minimal containers without certs: Err(HttpError::Tls(_))
642        match &result {
643            Ok(_) => {
644                // Success on systems with native certs
645            }
646            Err(HttpError::Tls(err)) => {
647                // Expected failure on systems without native certs
648                let msg = err.to_string();
649                assert!(
650                    msg.contains("native root") || msg.contains("certificate"),
651                    "TLS error should mention certificates: {msg}"
652                );
653            }
654            Err(other) => {
655                panic!("Unexpected error type: {other:?}");
656            }
657        }
658    }
659
660    #[tokio::test]
661    async fn test_builder_webpki_roots_https_only() {
662        let config = HttpClientConfig {
663            tls_roots: TlsRootConfig::WebPki,
664            transport: TransportSecurity::TlsOnly,
665            ..Default::default()
666        };
667        let client = HttpClientBuilder::with_config(config).build();
668        assert!(client.is_ok());
669    }
670
671    /// Verify HTTP/2 is enabled for all TLS root configurations.
672    ///
673    /// HTTP/2 support is configured via `enable_all_versions()` on the connector,
674    /// which sets up ALPN to negotiate h2 or http/1.1 during TLS handshake.
675    /// The hyper client uses `http2_only(false)` to allow both protocols.
676    #[tokio::test]
677    async fn test_http2_enabled_for_all_configurations() {
678        // Test WebPki with AllowInsecureHttp (default)
679        let client = HttpClientBuilder::new().build();
680        assert!(
681            client.is_ok(),
682            "WebPki + AllowInsecureHttp should build with HTTP/2 enabled"
683        );
684
685        // Test WebPki with TlsOnly (HTTPS only)
686        let client = HttpClientBuilder::new()
687            .transport(TransportSecurity::TlsOnly)
688            .build();
689        assert!(
690            client.is_ok(),
691            "WebPki + TlsOnly should build with HTTP/2 enabled"
692        );
693
694        // Test Native roots with AllowInsecureHttp
695        let config = HttpClientConfig {
696            tls_roots: TlsRootConfig::Native,
697            transport: TransportSecurity::AllowInsecureHttp,
698            ..Default::default()
699        };
700        let client = HttpClientBuilder::with_config(config).build();
701        assert!(
702            client.is_ok(),
703            "Native + AllowInsecureHttp should build with HTTP/2 enabled"
704        );
705
706        // Test Native roots with TlsOnly (HTTPS only)
707        let config = HttpClientConfig {
708            tls_roots: TlsRootConfig::Native,
709            transport: TransportSecurity::TlsOnly,
710            ..Default::default()
711        };
712        let client = HttpClientBuilder::with_config(config).build();
713        assert!(
714            client.is_ok(),
715            "Native + TlsOnly should build with HTTP/2 enabled"
716        );
717    }
718
719    /// Test that concurrency limit uses fail-fast behavior (C2).
720    ///
721    /// `LoadShedLayer` + `ConcurrencyLimitLayer` combination returns Overloaded error
722    /// immediately when capacity is exhausted, instead of blocking indefinitely.
723    #[tokio::test]
724    async fn test_load_shedding_returns_overloaded_error() {
725        use bytes::Bytes;
726        use http::{Request, Response};
727        use http_body_util::Full;
728        use std::future::Future;
729        use std::pin::Pin;
730        use std::sync::Arc;
731        use std::sync::atomic::{AtomicUsize, Ordering};
732        use std::task::{Context, Poll};
733        use tower::Service;
734        use tower::ServiceExt;
735
736        // A service that holds a slot forever once called
737        #[derive(Clone)]
738        struct SlotHoldingService {
739            active: Arc<AtomicUsize>,
740        }
741
742        impl Service<Request<Full<Bytes>>> for SlotHoldingService {
743            type Response = Response<Full<Bytes>>;
744            type Error = HttpError;
745            type Future = Pin<Box<dyn Future<Output = Result<Self::Response, Self::Error>> + Send>>;
746
747            fn poll_ready(&mut self, _: &mut Context<'_>) -> Poll<Result<(), Self::Error>> {
748                Poll::Ready(Ok(()))
749            }
750
751            fn call(&mut self, _: Request<Full<Bytes>>) -> Self::Future {
752                self.active.fetch_add(1, Ordering::SeqCst);
753                // Never complete - holds the slot
754                Box::pin(std::future::pending())
755            }
756        }
757
758        let active = Arc::new(AtomicUsize::new(0));
759
760        // Build a service with load shedding and concurrency limit of 1
761        let service = tower::ServiceBuilder::new()
762            .layer(LoadShedLayer::new())
763            .layer(ConcurrencyLimitLayer::new(1))
764            .service(SlotHoldingService {
765                active: active.clone(),
766            });
767
768        let service = service.map_err(map_load_shed_error);
769
770        // First request: will occupy the single slot
771        let req1 = Request::builder()
772            .uri("http://test")
773            .body(Full::new(Bytes::new()))
774            .unwrap();
775        let mut svc1 = service.clone();
776
777        let svc1_ready = svc1.ready().await.unwrap();
778        let _pending_fut = svc1_ready.call(req1);
779
780        // Wait for the slot to be occupied
781        tokio::time::sleep(Duration::from_millis(10)).await;
782        assert_eq!(
783            active.load(Ordering::SeqCst),
784            1,
785            "First request should be active"
786        );
787
788        // Second request: LoadShedLayer should reject because ConcurrencyLimit is at capacity
789        let req2 = Request::builder()
790            .uri("http://test")
791            .body(Full::new(Bytes::new()))
792            .unwrap();
793
794        let mut svc2 = service.clone();
795
796        // LoadShedLayer checks poll_ready and returns Overloaded if inner service is not ready
797        let result = tokio::time::timeout(Duration::from_millis(100), async {
798            // poll_ready should return quickly with error (not block)
799            match svc2.ready().await {
800                Ok(ready_svc) => ready_svc.call(req2).await,
801                Err(e) => Err(e),
802            }
803        })
804        .await;
805
806        // Should complete within timeout (not hang) and return Overloaded
807        assert!(result.is_ok(), "Request should not hang");
808        let err = result.unwrap().unwrap_err();
809        assert!(
810            matches!(err, HttpError::Overloaded),
811            "Expected Overloaded error, got: {err:?}"
812        );
813    }
814
815    // ==========================================================================
816    // map_tower_error Tests
817    // ==========================================================================
818
819    /// Test that `map_tower_error` preserves `HttpError::Overloaded` when wrapped in `BoxError`
820    #[test]
821    fn test_map_tower_error_preserves_overloaded() {
822        let http_err = HttpError::Overloaded;
823        let boxed: tower::BoxError = Box::new(http_err);
824        let result = map_tower_error(boxed, Duration::from_secs(30));
825
826        assert!(
827            matches!(result, HttpError::Overloaded),
828            "Should preserve HttpError::Overloaded, got: {result:?}"
829        );
830    }
831
832    /// Test that `map_tower_error` preserves `HttpError::ServiceClosed` when wrapped in `BoxError`
833    #[test]
834    fn test_map_tower_error_preserves_service_closed() {
835        let http_err = HttpError::ServiceClosed;
836        let boxed: tower::BoxError = Box::new(http_err);
837        let result = map_tower_error(boxed, Duration::from_secs(30));
838
839        assert!(
840            matches!(result, HttpError::ServiceClosed),
841            "Should preserve HttpError::ServiceClosed, got: {result:?}"
842        );
843    }
844
845    /// Test that `map_tower_error` preserves `HttpError::Timeout` with original duration
846    #[test]
847    fn test_map_tower_error_preserves_timeout_attempt() {
848        let original_duration = Duration::from_secs(5);
849        let http_err = HttpError::Timeout(original_duration);
850        let boxed: tower::BoxError = Box::new(http_err);
851        // Pass a different timeout to verify original is preserved
852        let result = map_tower_error(boxed, Duration::from_secs(30));
853
854        match result {
855            HttpError::Timeout(d) => {
856                assert_eq!(
857                    d, original_duration,
858                    "Should preserve original timeout duration"
859                );
860            }
861            other => panic!("Should preserve HttpError::Timeout, got: {other:?}"),
862        }
863    }
864
865    /// Test that `map_tower_error` wraps unknown errors as Transport
866    #[test]
867    fn test_map_tower_error_wraps_unknown_as_transport() {
868        let other_err: tower::BoxError = Box::new(std::io::Error::new(
869            std::io::ErrorKind::ConnectionRefused,
870            "connection refused",
871        ));
872        let result = map_tower_error(other_err, Duration::from_secs(30));
873
874        assert!(
875            matches!(result, HttpError::Transport(_)),
876            "Should wrap unknown errors as Transport, got: {result:?}"
877        );
878    }
879}