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
378#[cfg(test)]
379impl HttpClientBuilder {
380    /// Build an `HttpClient` with a custom inner service replacing the
381    /// hyper connector. The full middleware stack (Retry, Concurrency,
382    /// Buffer, etc.) is applied on top.
383    ///
384    /// The inner service must handle `Request<Full<Bytes>>` and return
385    /// `Response<ResponseBody>`. Use this to inject a fake slow service
386    /// for cancellation testing without needing a real HTTP server.
387    fn build_with_inner_service(self, inner: InnerService) -> crate::HttpClient {
388        let mut boxed_service = inner;
389
390        if let Some(ref retry_config) = self.config.retry {
391            let retry_layer =
392                RetryLayer::with_total_timeout(retry_config.clone(), self.config.total_timeout);
393            let retry_service = ServiceBuilder::new()
394                .layer(retry_layer)
395                .service(boxed_service);
396            boxed_service = retry_service.boxed_clone();
397        }
398
399        if let Some(rate_limit) = self.config.rate_limit
400            && rate_limit.max_concurrent_requests < usize::MAX
401        {
402            let limited_service = ServiceBuilder::new()
403                .layer(LoadShedLayer::new())
404                .layer(ConcurrencyLimitLayer::new(
405                    rate_limit.max_concurrent_requests,
406                ))
407                .service(boxed_service);
408            let limited_service = limited_service.map_err(map_load_shed_error);
409            boxed_service = limited_service.boxed_clone();
410        }
411
412        let buffer_capacity = self.config.buffer_capacity.max(1);
413        let buffered_service: crate::client::BufferedService =
414            Buffer::new(boxed_service, buffer_capacity);
415
416        crate::HttpClient {
417            service: buffered_service,
418            max_body_size: self.config.max_body_size,
419            transport_security: self.config.transport,
420        }
421    }
422}
423
424impl Default for HttpClientBuilder {
425    fn default() -> Self {
426        Self::new()
427    }
428}
429
430/// Map tower errors to `HttpError` with actual timeout duration
431///
432/// Attempts to extract existing `HttpError` from the boxed error before
433/// wrapping as `Transport`. This preserves typed errors like `Overloaded`
434/// and `ServiceClosed` that may have been boxed by tower middleware.
435fn map_tower_error(err: tower::BoxError, timeout: Duration) -> HttpError {
436    if err.is::<tower::timeout::error::Elapsed>() {
437        return HttpError::Timeout(timeout);
438    }
439
440    // Try to extract existing HttpError before wrapping as Transport
441    match err.downcast::<HttpError>() {
442        Ok(http_err) => *http_err,
443        Err(other) => HttpError::Transport(other),
444    }
445}
446
447/// Map load shed errors to `HttpError::Overloaded`
448fn map_load_shed_error(err: tower::BoxError) -> HttpError {
449    if err.is::<tower::load_shed::error::Overloaded>() {
450        HttpError::Overloaded
451    } else {
452        // Pass through other HttpError types (from inner service)
453        match err.downcast::<HttpError>() {
454            Ok(http_err) => *http_err,
455            Err(err) => HttpError::Transport(err),
456        }
457    }
458}
459
460/// Map the decompression response to our boxed response body type.
461///
462/// This converts `Response<DecompressionBody<Incoming>>` to `Response<ResponseBody>`
463/// by boxing the body with appropriate error type mapping.
464fn map_decompression_response<B>(response: Response<B>) -> Response<ResponseBody>
465where
466    B: hyper::body::Body<Data = Bytes> + Send + Sync + 'static,
467    B::Error: Into<Box<dyn std::error::Error + Send + Sync>>,
468{
469    let (parts, body) = response.into_parts();
470    // Convert the decompression body errors to our boxed error type.
471    // tower-http's DecompressionBody uses tower_http::BoxError which is
472    // compatible with our Box<dyn Error + Send + Sync> via Into.
473    let boxed_body: ResponseBody = body.map_err(Into::into).boxed();
474    Response::from_parts(parts, boxed_body)
475}
476
477/// Build the HTTPS connector with the specified TLS root configuration.
478///
479/// For `TlsRootConfig::Native`, uses cached native root certificates to avoid
480/// repeated OS certificate store lookups on each `build()` call.
481///
482/// HTTP/2 is enabled via `enable_all_versions()` which configures ALPN to
483/// advertise both h2 and http/1.1. Protocol selection happens during TLS
484/// handshake based on server support.
485///
486/// # Errors
487///
488/// Returns `HttpError::Tls` if `TlsRootConfig::Native` is requested but no
489/// valid root certificates are available from the OS certificate store.
490fn build_https_connector(
491    tls_roots: TlsRootConfig,
492    transport: TransportSecurity,
493) -> Result<HttpsConnector<HttpConnector>, HttpError> {
494    let allow_http = transport == TransportSecurity::AllowInsecureHttp;
495
496    match tls_roots {
497        TlsRootConfig::WebPki => {
498            let provider = tls::get_crypto_provider();
499            let builder = hyper_rustls::HttpsConnectorBuilder::new()
500                .with_provider_and_webpki_roots(provider)
501                // Preserve source error for debugging -
502                // rustls::Error implements Error + Send + Sync
503                .map_err(|e| HttpError::Tls(Box::new(e)))?;
504            let connector = if allow_http {
505                builder.https_or_http().enable_all_versions().build()
506            } else {
507                builder.https_only().enable_all_versions().build()
508            };
509            Ok(connector)
510        }
511        TlsRootConfig::Native => {
512            let client_config = tls::native_roots_client_config()
513                // Native returns String error; convert to boxed error for consistency
514                .map_err(|e| HttpError::Tls(e.into()))?;
515            let builder = hyper_rustls::HttpsConnectorBuilder::new().with_tls_config(client_config);
516            let connector = if allow_http {
517                builder.https_or_http().enable_all_versions().build()
518            } else {
519                builder.https_only().enable_all_versions().build()
520            };
521            Ok(connector)
522        }
523    }
524}
525
526#[cfg(test)]
527#[cfg_attr(coverage_nightly, coverage(off))]
528mod tests {
529    use super::*;
530    use crate::config::DEFAULT_USER_AGENT;
531
532    #[test]
533    fn test_builder_default() {
534        let builder = HttpClientBuilder::new();
535        assert_eq!(builder.config.request_timeout, Duration::from_secs(30));
536        assert_eq!(builder.config.user_agent, DEFAULT_USER_AGENT);
537        assert!(builder.config.retry.is_some());
538        assert_eq!(builder.config.buffer_capacity, 1024);
539    }
540
541    #[test]
542    fn test_builder_with_config() {
543        let config = HttpClientConfig::minimal();
544        let builder = HttpClientBuilder::with_config(config);
545        assert_eq!(builder.config.request_timeout, Duration::from_secs(10));
546    }
547
548    #[test]
549    fn test_builder_timeout() {
550        let builder = HttpClientBuilder::new().timeout(Duration::from_secs(60));
551        assert_eq!(builder.config.request_timeout, Duration::from_secs(60));
552    }
553
554    #[test]
555    fn test_builder_user_agent() {
556        let builder = HttpClientBuilder::new().user_agent("custom/1.0");
557        assert_eq!(builder.config.user_agent, "custom/1.0");
558    }
559
560    #[test]
561    fn test_builder_retry() {
562        let builder = HttpClientBuilder::new().retry(None);
563        assert!(builder.config.retry.is_none());
564    }
565
566    #[test]
567    fn test_builder_max_body_size() {
568        let builder = HttpClientBuilder::new().max_body_size(1024);
569        assert_eq!(builder.config.max_body_size, 1024);
570    }
571
572    #[test]
573    fn test_builder_transport_security() {
574        let builder = HttpClientBuilder::new().transport(TransportSecurity::TlsOnly);
575        assert_eq!(builder.config.transport, TransportSecurity::TlsOnly);
576
577        let builder = HttpClientBuilder::new().deny_insecure_http();
578        assert_eq!(builder.config.transport, TransportSecurity::TlsOnly);
579
580        let builder = HttpClientBuilder::new();
581        assert_eq!(
582            builder.config.transport,
583            TransportSecurity::AllowInsecureHttp
584        );
585    }
586
587    #[test]
588    fn test_builder_otel() {
589        let builder = HttpClientBuilder::new().with_otel();
590        assert!(builder.config.otel);
591    }
592
593    #[test]
594    fn test_builder_buffer_capacity() {
595        let builder = HttpClientBuilder::new().buffer_capacity(512);
596        assert_eq!(builder.config.buffer_capacity, 512);
597    }
598
599    /// Test that `buffer_capacity=0` is clamped to 1 to prevent panic.
600    ///
601    /// Tower's Buffer panics with capacity=0, so we enforce minimum of 1.
602    #[test]
603    fn test_builder_buffer_capacity_zero_clamped() {
604        let builder = HttpClientBuilder::new().buffer_capacity(0);
605        assert_eq!(
606            builder.config.buffer_capacity, 1,
607            "buffer_capacity=0 should be clamped to 1"
608        );
609    }
610
611    /// Test that `buffer_capacity=0` via config is clamped during `build()`.
612    #[tokio::test]
613    async fn test_builder_buffer_capacity_zero_in_config_clamped() {
614        let config = HttpClientConfig {
615            buffer_capacity: 0, // Invalid - should be clamped in build()
616            ..Default::default()
617        };
618        let result = HttpClientBuilder::with_config(config).build();
619        // Should succeed (clamped to 1), not panic
620        assert!(
621            result.is_ok(),
622            "build() should succeed with capacity clamped to 1"
623        );
624    }
625
626    #[tokio::test]
627    async fn test_builder_build_with_otel() {
628        let client = HttpClientBuilder::new().with_otel().build();
629        assert!(client.is_ok());
630    }
631
632    #[tokio::test]
633    async fn test_builder_with_auth_layer() {
634        let client = HttpClientBuilder::new()
635            .with_auth_layer(|svc| svc) // identity transform
636            .build();
637        assert!(client.is_ok());
638    }
639
640    #[tokio::test]
641    async fn test_builder_build() {
642        let client = HttpClientBuilder::new().build();
643        assert!(client.is_ok());
644    }
645
646    #[tokio::test]
647    async fn test_builder_build_with_deny_insecure_http() {
648        let client = HttpClientBuilder::new().deny_insecure_http().build();
649        assert!(client.is_ok());
650    }
651
652    #[tokio::test]
653    async fn test_builder_build_with_sse_config() {
654        use crate::config::HttpClientConfig;
655        let config = HttpClientConfig::sse();
656        let client = HttpClientBuilder::with_config(config).build();
657        assert!(client.is_ok(), "SSE config should build successfully");
658    }
659
660    #[tokio::test]
661    async fn test_builder_build_invalid_user_agent() {
662        let client = HttpClientBuilder::new()
663            .user_agent("invalid\x00agent")
664            .build();
665        assert!(client.is_err());
666    }
667
668    #[tokio::test]
669    async fn test_builder_default_uses_webpki_roots() {
670        let builder = HttpClientBuilder::new();
671        assert_eq!(builder.config.tls_roots, TlsRootConfig::WebPki);
672        // Build should succeed without OS native roots
673        let client = builder.build();
674        assert!(client.is_ok());
675    }
676
677    #[tokio::test]
678    async fn test_builder_native_roots() {
679        let config = HttpClientConfig {
680            tls_roots: TlsRootConfig::Native,
681            ..Default::default()
682        };
683        let result = HttpClientBuilder::with_config(config).build();
684
685        // Native roots may succeed or fail depending on OS certificate availability.
686        // On systems with certs: Ok(_)
687        // On minimal containers without certs: Err(HttpError::Tls(_))
688        match &result {
689            Ok(_) => {
690                // Success on systems with native certs
691            }
692            Err(HttpError::Tls(err)) => {
693                // Expected failure on systems without native certs
694                let msg = err.to_string();
695                assert!(
696                    msg.contains("native root") || msg.contains("certificate"),
697                    "TLS error should mention certificates: {msg}"
698                );
699            }
700            Err(other) => {
701                panic!("Unexpected error type: {other:?}");
702            }
703        }
704    }
705
706    #[tokio::test]
707    async fn test_builder_webpki_roots_https_only() {
708        let config = HttpClientConfig {
709            tls_roots: TlsRootConfig::WebPki,
710            transport: TransportSecurity::TlsOnly,
711            ..Default::default()
712        };
713        let client = HttpClientBuilder::with_config(config).build();
714        assert!(client.is_ok());
715    }
716
717    /// Verify HTTP/2 is enabled for all TLS root configurations.
718    ///
719    /// HTTP/2 support is configured via `enable_all_versions()` on the connector,
720    /// which sets up ALPN to negotiate h2 or http/1.1 during TLS handshake.
721    /// The hyper client uses `http2_only(false)` to allow both protocols.
722    #[tokio::test]
723    async fn test_http2_enabled_for_all_configurations() {
724        // Test WebPki with AllowInsecureHttp (default)
725        let client = HttpClientBuilder::new().build();
726        assert!(
727            client.is_ok(),
728            "WebPki + AllowInsecureHttp should build with HTTP/2 enabled"
729        );
730
731        // Test WebPki with TlsOnly (HTTPS only)
732        let client = HttpClientBuilder::new()
733            .transport(TransportSecurity::TlsOnly)
734            .build();
735        assert!(
736            client.is_ok(),
737            "WebPki + TlsOnly should build with HTTP/2 enabled"
738        );
739
740        // Test Native roots with AllowInsecureHttp
741        let config = HttpClientConfig {
742            tls_roots: TlsRootConfig::Native,
743            transport: TransportSecurity::AllowInsecureHttp,
744            ..Default::default()
745        };
746        let client = HttpClientBuilder::with_config(config).build();
747        assert!(
748            client.is_ok(),
749            "Native + AllowInsecureHttp should build with HTTP/2 enabled"
750        );
751
752        // Test Native roots with TlsOnly (HTTPS only)
753        let config = HttpClientConfig {
754            tls_roots: TlsRootConfig::Native,
755            transport: TransportSecurity::TlsOnly,
756            ..Default::default()
757        };
758        let client = HttpClientBuilder::with_config(config).build();
759        assert!(
760            client.is_ok(),
761            "Native + TlsOnly should build with HTTP/2 enabled"
762        );
763    }
764
765    /// Test that concurrency limit uses fail-fast behavior (C2).
766    ///
767    /// `LoadShedLayer` + `ConcurrencyLimitLayer` combination returns Overloaded error
768    /// immediately when capacity is exhausted, instead of blocking indefinitely.
769    #[tokio::test]
770    async fn test_load_shedding_returns_overloaded_error() {
771        use bytes::Bytes;
772        use http::{Request, Response};
773        use http_body_util::Full;
774        use std::future::Future;
775        use std::pin::Pin;
776        use std::sync::Arc;
777        use std::sync::atomic::{AtomicUsize, Ordering};
778        use std::task::{Context, Poll};
779        use tower::Service;
780        use tower::ServiceExt;
781
782        // A service that holds a slot forever once called
783        #[derive(Clone)]
784        struct SlotHoldingService {
785            active: Arc<AtomicUsize>,
786        }
787
788        impl Service<Request<Full<Bytes>>> for SlotHoldingService {
789            type Response = Response<Full<Bytes>>;
790            type Error = HttpError;
791            type Future = Pin<Box<dyn Future<Output = Result<Self::Response, Self::Error>> + Send>>;
792
793            fn poll_ready(&mut self, _: &mut Context<'_>) -> Poll<Result<(), Self::Error>> {
794                Poll::Ready(Ok(()))
795            }
796
797            fn call(&mut self, _: Request<Full<Bytes>>) -> Self::Future {
798                self.active.fetch_add(1, Ordering::SeqCst);
799                // Never complete - holds the slot
800                Box::pin(std::future::pending())
801            }
802        }
803
804        let active = Arc::new(AtomicUsize::new(0));
805
806        // Build a service with load shedding and concurrency limit of 1
807        let service = tower::ServiceBuilder::new()
808            .layer(LoadShedLayer::new())
809            .layer(ConcurrencyLimitLayer::new(1))
810            .service(SlotHoldingService {
811                active: active.clone(),
812            });
813
814        let service = service.map_err(map_load_shed_error);
815
816        // First request: will occupy the single slot
817        let req1 = Request::builder()
818            .uri("http://test")
819            .body(Full::new(Bytes::new()))
820            .unwrap();
821        let mut svc1 = service.clone();
822
823        let svc1_ready = svc1.ready().await.unwrap();
824        let _pending_fut = svc1_ready.call(req1);
825
826        // Wait for the slot to be occupied
827        tokio::time::sleep(Duration::from_millis(10)).await;
828        assert_eq!(
829            active.load(Ordering::SeqCst),
830            1,
831            "First request should be active"
832        );
833
834        // Second request: LoadShedLayer should reject because ConcurrencyLimit is at capacity
835        let req2 = Request::builder()
836            .uri("http://test")
837            .body(Full::new(Bytes::new()))
838            .unwrap();
839
840        let mut svc2 = service.clone();
841
842        // LoadShedLayer checks poll_ready and returns Overloaded if inner service is not ready
843        let result = tokio::time::timeout(Duration::from_millis(100), async {
844            // poll_ready should return quickly with error (not block)
845            match svc2.ready().await {
846                Ok(ready_svc) => ready_svc.call(req2).await,
847                Err(e) => Err(e),
848            }
849        })
850        .await;
851
852        // Should complete within timeout (not hang) and return Overloaded
853        assert!(result.is_ok(), "Request should not hang");
854        let err = result.unwrap().unwrap_err();
855        assert!(
856            matches!(err, HttpError::Overloaded),
857            "Expected Overloaded error, got: {err:?}"
858        );
859    }
860
861    // ==========================================================================
862    // map_tower_error Tests
863    // ==========================================================================
864
865    /// Test that `map_tower_error` preserves `HttpError::Overloaded` when wrapped in `BoxError`
866    #[test]
867    fn test_map_tower_error_preserves_overloaded() {
868        let http_err = HttpError::Overloaded;
869        let boxed: tower::BoxError = Box::new(http_err);
870        let result = map_tower_error(boxed, Duration::from_secs(30));
871
872        assert!(
873            matches!(result, HttpError::Overloaded),
874            "Should preserve HttpError::Overloaded, got: {result:?}"
875        );
876    }
877
878    /// Test that `map_tower_error` preserves `HttpError::ServiceClosed` when wrapped in `BoxError`
879    #[test]
880    fn test_map_tower_error_preserves_service_closed() {
881        let http_err = HttpError::ServiceClosed;
882        let boxed: tower::BoxError = Box::new(http_err);
883        let result = map_tower_error(boxed, Duration::from_secs(30));
884
885        assert!(
886            matches!(result, HttpError::ServiceClosed),
887            "Should preserve HttpError::ServiceClosed, got: {result:?}"
888        );
889    }
890
891    /// Test that `map_tower_error` preserves `HttpError::Timeout` with original duration
892    #[test]
893    fn test_map_tower_error_preserves_timeout_attempt() {
894        let original_duration = Duration::from_secs(5);
895        let http_err = HttpError::Timeout(original_duration);
896        let boxed: tower::BoxError = Box::new(http_err);
897        // Pass a different timeout to verify original is preserved
898        let result = map_tower_error(boxed, Duration::from_secs(30));
899
900        match result {
901            HttpError::Timeout(d) => {
902                assert_eq!(
903                    d, original_duration,
904                    "Should preserve original timeout duration"
905                );
906            }
907            other => panic!("Should preserve HttpError::Timeout, got: {other:?}"),
908        }
909    }
910
911    /// Test that `map_tower_error` wraps unknown errors as Transport
912    #[test]
913    fn test_map_tower_error_wraps_unknown_as_transport() {
914        let other_err: tower::BoxError = Box::new(std::io::Error::new(
915            std::io::ErrorKind::ConnectionRefused,
916            "connection refused",
917        ));
918        let result = map_tower_error(other_err, Duration::from_secs(30));
919
920        assert!(
921            matches!(result, HttpError::Transport(_)),
922            "Should wrap unknown errors as Transport, got: {result:?}"
923        );
924    }
925
926    // ==========================================================================
927    // Cancellation chain test
928    //
929    // Proves that dropping the response future from HttpClient cancels the
930    // inner service future through the modkit-http middleware stack
931    // (Buffer → Concurrency → inner service). Retry is disabled to
932    // isolate the cancellation path.
933    //
934    // Uses build_with_inner_service() to inject a fake slow service at the
935    // bottom of the real tower stack - no HTTP server needed.
936    // ==========================================================================
937
938    /// Dropping the `HttpClient::send()` future must cancel the inner
939    /// service future through the full middleware stack.
940    ///
941    /// Injects a fake service via `build_with_inner_service()` that
942    /// blocks on a `Notify` (never completes) and signals a second
943    /// `Notify` from its `Drop` impl. No sleeps - purely notification-based.
944    #[tokio::test]
945    async fn test_cancellation_propagates_through_full_stack() {
946        use crate::response::ResponseBody;
947        use std::future::Future;
948        use std::pin::Pin;
949        use std::sync::Arc;
950        use std::sync::atomic::{AtomicBool, Ordering};
951        use std::task::{Context, Poll};
952        use tower::Service;
953
954        #[derive(Clone)]
955        struct PendingService {
956            completed: Arc<AtomicBool>,
957            drop_notifier: Arc<tokio::sync::Notify>,
958            started_notifier: Arc<tokio::sync::Notify>,
959        }
960
961        struct FutureGuard {
962            completed: Arc<AtomicBool>,
963            drop_notifier: Arc<tokio::sync::Notify>,
964        }
965
966        impl Drop for FutureGuard {
967            fn drop(&mut self) {
968                if !self.completed.load(Ordering::SeqCst) {
969                    self.drop_notifier.notify_one();
970                }
971            }
972        }
973
974        impl Service<http::Request<Full<Bytes>>> for PendingService {
975            type Response = http::Response<ResponseBody>;
976            type Error = HttpError;
977            type Future = Pin<Box<dyn Future<Output = Result<Self::Response, Self::Error>> + Send>>;
978
979            fn poll_ready(&mut self, _: &mut Context<'_>) -> Poll<Result<(), Self::Error>> {
980                Poll::Ready(Ok(()))
981            }
982
983            fn call(&mut self, _: http::Request<Full<Bytes>>) -> Self::Future {
984                let completed = self.completed.clone();
985                let drop_notifier = self.drop_notifier.clone();
986                let started_notifier = self.started_notifier.clone();
987                Box::pin(async move {
988                    let _guard = FutureGuard {
989                        completed: completed.clone(),
990                        drop_notifier,
991                    };
992                    // Signal that the request reached the inner service
993                    started_notifier.notify_one();
994                    // Block forever - only completes via drop
995                    std::future::pending::<()>().await;
996                    completed.store(true, Ordering::SeqCst);
997                    unreachable!()
998                })
999            }
1000        }
1001
1002        let inner_completed = Arc::new(AtomicBool::new(false));
1003        let drop_notifier = Arc::new(tokio::sync::Notify::new());
1004        let started_notifier = Arc::new(tokio::sync::Notify::new());
1005
1006        let inner = PendingService {
1007            completed: inner_completed.clone(),
1008            drop_notifier: drop_notifier.clone(),
1009            started_notifier: started_notifier.clone(),
1010        };
1011
1012        // Build the real HttpClient stack with our fake service at the bottom.
1013        // Retry disabled to isolate cancellation. Tests: Buffer → Concurrency → PendingService
1014        let client = HttpClientBuilder::new()
1015            .timeout(Duration::from_secs(30))
1016            .retry(None)
1017            .build_with_inner_service(inner.boxed_clone());
1018
1019        // Spawn the request so we can drop it explicitly
1020        let send_handle = tokio::spawn({
1021            let client = client.clone();
1022            async move { client.get("http://fake/slow").send().await }
1023        });
1024
1025        // Wait for the request to reach the inner service
1026        started_notifier.notified().await;
1027
1028        // Drop the in-flight request by aborting the task
1029        send_handle.abort();
1030
1031        // Wait for the drop notification - no sleep, pure notification
1032        tokio::time::timeout(Duration::from_secs(5), drop_notifier.notified())
1033            .await
1034            .expect(
1035                "Inner service future should have been dropped within 5s - \
1036                 the full modkit-http stack must propagate cancellation",
1037            );
1038
1039        assert!(
1040            !inner_completed.load(Ordering::SeqCst),
1041            "Inner service future should NOT have completed"
1042        );
1043    }
1044}