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