Skip to main content

toolkit_http/
client.rs

1use crate::builder::HttpClientBuilder;
2use crate::config::TransportSecurity;
3use crate::error::HttpError;
4use crate::request::RequestBuilder;
5use crate::response::ResponseBody;
6use bytes::Bytes;
7use http::{Request, Response};
8use http_body_util::Full;
9use std::future::Future;
10use std::pin::Pin;
11use tower::Service;
12use tower::buffer::Buffer;
13
14/// Type alias for the future type of the inner service
15pub type ServiceFuture =
16    Pin<Box<dyn Future<Output = Result<Response<ResponseBody>, HttpError>> + Send>>;
17
18/// Type alias for the buffered service
19/// Buffer<Req, F> in tower 0.5 where Req is the request type and F is the service future type
20pub type BufferedService = Buffer<Request<Full<Bytes>>, ServiceFuture>;
21
22/// HTTP client with tower middleware stack
23///
24/// This client provides a clean interface over a tower service stack that includes:
25/// - Timeout handling
26/// - Automatic retries with exponential backoff
27/// - User-Agent header injection
28/// - Concurrency limiting (optional)
29///
30/// Use [`HttpClientBuilder`] to construct instances with custom configuration.
31///
32/// # Thread Safety
33///
34/// `HttpClient` is `Clone + Send + Sync`. Cloning is cheap (internal channel clone).
35/// The client uses `tower::buffer::Buffer` internally, which allows true concurrent
36/// access without any mutex serialization. Callers do NOT need to wrap `HttpClient`
37/// in `Mutex` or `Arc<Mutex<_>>`.
38///
39/// # Example
40///
41/// ```no_run
42/// # use toolkit_http::HttpClient;
43/// # use toolkit_http::HttpError;
44///
45/// // Just store the client directly - no Mutex needed!
46/// struct MyService {
47///     http: HttpClient,
48/// }
49///
50/// # #[derive(serde::Deserialize)]
51/// struct Data;
52///
53/// impl MyService {
54///     async fn fetch(&self) -> Result<Data, HttpError> {
55///         // reqwest-like API: response has body-reading methods
56///         self.http.get("https://example.com/api").send().await?.json().await
57///     }
58/// }
59/// ```
60#[derive(Clone)]
61pub struct HttpClient {
62    /// Buffered middleware stack that executes requests.
63    pub(crate) service: BufferedService,
64    /// Maximum response body size in bytes accepted by this client.
65    pub(crate) max_body_size: usize,
66    /// Transport security policy the client was built with.
67    pub(crate) transport_security: TransportSecurity,
68}
69
70impl HttpClient {
71    /// Create a new HTTP client with default configuration
72    ///
73    /// # Errors
74    /// Returns an error if TLS initialization fails
75    pub fn new() -> Result<Self, HttpError> {
76        HttpClientBuilder::new().build()
77    }
78
79    /// Create a builder for configuring the HTTP client
80    #[must_use]
81    pub fn builder() -> HttpClientBuilder {
82        HttpClientBuilder::new()
83    }
84
85    /// Create a GET request builder
86    ///
87    /// Returns a [`RequestBuilder`] that can be configured with headers
88    /// before sending with `.send().await`.
89    ///
90    /// # URL Requirements
91    ///
92    /// The URL must be an absolute URI with scheme and authority (host).
93    /// Relative URLs like `/path` or `example.com/path` are rejected with
94    /// [`HttpError::InvalidUri`].
95    ///
96    /// Valid examples:
97    /// - `https://api.example.com/users`
98    /// - `http://localhost:8080/health` (requires [`TransportSecurity::AllowInsecureHttp`])
99    ///
100    /// # URL Construction
101    ///
102    /// Query parameters must be encoded into the URL externally (e.g. via `url::Url`):
103    ///
104    /// ```no_run
105    /// # use toolkit_http::HttpClient;
106    /// # async fn example() -> Result<(), Box<dyn std::error::Error>> {
107    /// # let client = HttpClient::new()?;
108    /// use url::Url;
109    ///
110    /// let mut url = Url::parse("https://api.example.com/search")?;
111    /// url.query_pairs_mut().append_pair("q", "rust").append_pair("page", "1");
112    ///
113    /// let resp = client
114    ///     .get(url.as_str())
115    ///     .header("authorization", "Bearer token")
116    ///     .send()
117    ///     .await?;
118    /// # Ok(())
119    /// # }
120    /// ```
121    ///
122    /// # Example
123    ///
124    /// ```no_run
125    /// # use toolkit_http::HttpClient;
126    /// # async fn example() -> Result<(), toolkit_http::HttpError> {
127    /// # let client = HttpClient::new()?;
128    /// // Simple GET
129    /// let resp = client.get("https://api.example.com/data").send().await?;
130    /// # Ok(())
131    /// # }
132    /// ```
133    ///
134    /// [`HttpError::InvalidUri`]: crate::error::HttpError::InvalidUri
135    /// [`TransportSecurity::AllowInsecureHttp`]: crate::config::TransportSecurity::AllowInsecureHttp
136    pub fn get(&self, url: &str) -> RequestBuilder {
137        RequestBuilder::new(
138            self.service.clone(),
139            self.max_body_size,
140            http::Method::GET,
141            url.to_owned(),
142            self.transport_security,
143        )
144    }
145
146    /// Create a POST request builder
147    ///
148    /// Returns a [`RequestBuilder`] that can be configured with headers,
149    /// body (JSON, form, bytes), etc. before sending with `.send().await`.
150    ///
151    /// # Example
152    ///
153    /// ```no_run
154    /// # use toolkit_http::HttpClient;
155    /// # #[derive(serde::Serialize)]
156    /// # struct NewUser { name: &'static str }
157    /// # async fn example() -> Result<(), toolkit_http::HttpError> {
158    /// # let client = HttpClient::new()?;
159    /// // POST with JSON body
160    /// let resp = client
161    ///     .post("https://api.example.com/users")
162    ///     .json(&NewUser { name: "Alice" })?
163    ///     .send()
164    ///     .await?;
165    ///
166    /// // POST with form body
167    /// let resp = client
168    ///     .post("https://auth.example.com/token")
169    ///     .form(&[("grant_type", "client_credentials")])?
170    ///     .send()
171    ///     .await?;
172    /// # Ok(())
173    /// # }
174    /// ```
175    pub fn post(&self, url: &str) -> RequestBuilder {
176        RequestBuilder::new(
177            self.service.clone(),
178            self.max_body_size,
179            http::Method::POST,
180            url.to_owned(),
181            self.transport_security,
182        )
183    }
184
185    /// Create a PUT request builder
186    ///
187    /// Returns a [`RequestBuilder`] that can be configured with headers,
188    /// body (JSON, form, bytes), etc. before sending with `.send().await`.
189    ///
190    /// # Example
191    ///
192    /// ```no_run
193    /// # use toolkit_http::HttpClient;
194    /// # #[derive(serde::Serialize)]
195    /// # struct UpdateData { value: i32 }
196    /// # async fn example() -> Result<(), toolkit_http::HttpError> {
197    /// # let client = HttpClient::new()?;
198    /// let resp = client
199    ///     .put("https://api.example.com/resource/1")
200    ///     .json(&UpdateData { value: 42 })?
201    ///     .send()
202    ///     .await?;
203    /// # Ok(())
204    /// # }
205    /// ```
206    pub fn put(&self, url: &str) -> RequestBuilder {
207        RequestBuilder::new(
208            self.service.clone(),
209            self.max_body_size,
210            http::Method::PUT,
211            url.to_owned(),
212            self.transport_security,
213        )
214    }
215
216    /// Create a PATCH request builder
217    ///
218    /// Returns a [`RequestBuilder`] that can be configured with headers,
219    /// body (JSON, form, bytes), etc. before sending with `.send().await`.
220    ///
221    /// # Example
222    ///
223    /// ```no_run
224    /// # use toolkit_http::HttpClient;
225    /// # #[derive(serde::Serialize)]
226    /// # struct PatchData { field: &'static str }
227    /// # async fn example() -> Result<(), toolkit_http::HttpError> {
228    /// # let client = HttpClient::new()?;
229    /// let resp = client
230    ///     .patch("https://api.example.com/resource/1")
231    ///     .json(&PatchData { field: "new_value" })?
232    ///     .send()
233    ///     .await?;
234    /// # Ok(())
235    /// # }
236    /// ```
237    pub fn patch(&self, url: &str) -> RequestBuilder {
238        RequestBuilder::new(
239            self.service.clone(),
240            self.max_body_size,
241            http::Method::PATCH,
242            url.to_owned(),
243            self.transport_security,
244        )
245    }
246
247    /// Create a DELETE request builder
248    ///
249    /// Returns a [`RequestBuilder`] that can be configured with headers
250    /// before sending with `.send().await`.
251    ///
252    /// # Example
253    ///
254    /// ```no_run
255    /// # use toolkit_http::HttpClient;
256    /// # async fn example() -> Result<(), toolkit_http::HttpError> {
257    /// # let client = HttpClient::new()?;
258    /// let resp = client
259    ///     .delete("https://api.example.com/resource/42")
260    ///     .header("authorization", "Bearer token")
261    ///     .send()
262    ///     .await?;
263    /// # Ok(())
264    /// # }
265    /// ```
266    pub fn delete(&self, url: &str) -> RequestBuilder {
267        RequestBuilder::new(
268            self.service.clone(),
269            self.max_body_size,
270            http::Method::DELETE,
271            url.to_owned(),
272            self.transport_security,
273        )
274    }
275
276    /// Create a HEAD request builder.
277    ///
278    /// HEAD requests are like GET but the server returns only headers — the
279    /// response body is empty per RFC 9110 §9.3.2. Use for cheap existence
280    /// checks (`Content-Length` / `ETag` probes) without paying for the
281    /// payload.
282    ///
283    /// HEAD is idempotent, so the SDK's retry policy retries it on transient
284    /// upstream failures the same way it retries GET.
285    ///
286    /// # Example
287    ///
288    /// ```no_run
289    /// # use toolkit_http::HttpClient;
290    /// # async fn example() -> Result<(), toolkit_http::HttpError> {
291    /// # let client = HttpClient::new()?;
292    /// let resp = client.head("https://api.example.com/big-blob").send().await?;
293    /// let len = resp
294    ///     .headers()
295    ///     .get(http::header::CONTENT_LENGTH)
296    ///     .and_then(|v| v.to_str().ok())
297    ///     .and_then(|s| s.parse::<u64>().ok());
298    /// # Ok(())
299    /// # }
300    /// ```
301    pub fn head(&self, url: &str) -> RequestBuilder {
302        RequestBuilder::new(
303            self.service.clone(),
304            self.max_body_size,
305            http::Method::HEAD,
306            url.to_owned(),
307            self.transport_security,
308        )
309    }
310
311    /// Create an OPTIONS request builder.
312    ///
313    /// OPTIONS is the wire form of CORS preflight and capability discovery.
314    /// Servers typically respond with `Allow:` and CORS-preflight headers and
315    /// no body. The SDK installs no CORS middleware — the response is
316    /// returned to the caller verbatim.
317    ///
318    /// OPTIONS is idempotent and is retried by default on transient failures
319    /// the same way GET is.
320    ///
321    /// # Example
322    ///
323    /// ```no_run
324    /// # use toolkit_http::HttpClient;
325    /// # async fn example() -> Result<(), toolkit_http::HttpError> {
326    /// # let client = HttpClient::new()?;
327    /// let resp = client
328    ///     .options("https://api.example.com/resource")
329    ///     .send()
330    ///     .await?;
331    /// let allow = resp
332    ///     .headers()
333    ///     .get(http::header::ALLOW)
334    ///     .and_then(|v| v.to_str().ok())
335    ///     .map(str::to_owned);
336    /// # Ok(())
337    /// # }
338    /// ```
339    pub fn options(&self, url: &str) -> RequestBuilder {
340        RequestBuilder::new(
341            self.service.clone(),
342            self.max_body_size,
343            http::Method::OPTIONS,
344            url.to_owned(),
345            self.transport_security,
346        )
347    }
348}
349
350/// Map buffer errors to `HttpError`
351///
352/// Buffer can return `ServiceError` which wraps the inner service error,
353/// or `Closed` if the buffer worker has shut down.
354pub fn map_buffer_error(err: tower::BoxError) -> HttpError {
355    // Try to downcast to HttpError (from inner service)
356    match err.downcast::<HttpError>() {
357        Ok(http_err) => *http_err,
358        Err(err) => {
359            // Buffer closed or other internal failure.
360            // This happens when buffer worker panics or channel is dropped.
361            //
362            // Return ServiceClosed (not Overloaded) to distinguish from normal
363            // overload (buffer full). This is a serious condition indicating
364            // the background worker has died unexpectedly.
365            tracing::error!(
366                error = %err,
367                "buffer worker closed unexpectedly; service unavailable"
368            );
369            HttpError::ServiceClosed
370        }
371    }
372}
373
374/// Try to acquire a buffer slot with fail-fast semantics.
375///
376/// If the buffer is full, returns `HttpError::Overloaded` immediately instead
377/// of blocking. This prevents request pile-up under load.
378pub async fn try_acquire_buffer_slot(service: &mut BufferedService) -> Result<(), HttpError> {
379    use std::task::Poll;
380
381    // Poll once to check if buffer has space available
382    let poll_result = std::future::poll_fn(|cx| match service.poll_ready(cx) {
383        Poll::Ready(result) => Poll::Ready(Some(result)),
384        Poll::Pending => Poll::Ready(None), // Buffer full, don't block
385    })
386    .await;
387
388    match poll_result {
389        Some(Ok(())) => Ok(()),
390        Some(Err(e)) => Err(map_buffer_error(e)),
391        None => Err(HttpError::Overloaded), // Buffer full, fail fast
392    }
393}
394
395// The in-crate tests below rely on `httpmock` (plaintext HTTP only) and on
396// constructing clients that accept `http://` URLs. Under `--features fips`
397// the default `HttpClientConfig.transport` is `TlsOnly` and
398// `HttpClientBuilder::build()` rejects `AllowInsecureHttp` outright (see
399// issue #1934), so the whole gear cannot compile/run there. The FIPS-mode
400// contract is covered by the integration test in
401// `tests/fips_default_transport.rs` and by `tests/no_crypto_provider_fips.rs`.
402//
403// The two cfg gates are kept separate — `#[cfg(test)]` then
404// `#[cfg(not(feature = "fips"))]` — so that clippy's test-aware lint
405// exemptions (e.g. tolerating `unwrap()` in test code) still trigger on the
406// bare `cfg(test)` marker.
407#[cfg(test)]
408#[cfg(not(feature = "fips"))]
409#[cfg_attr(coverage_nightly, coverage(off))]
410mod tests {
411    use super::*;
412    use crate::error::HttpError;
413    use httpmock::prelude::*;
414    use serde_json::json;
415
416    fn test_client() -> HttpClient {
417        HttpClientBuilder::new().retry(None).build().unwrap()
418    }
419
420    #[tokio::test]
421    async fn test_http_client_get() {
422        let server = MockServer::start();
423        let _m = server.mock(|when, then| {
424            when.method(Method::GET).path("/test");
425            then.status(200).json_body(json!({"success": true}));
426        });
427
428        let client = test_client();
429        let url = format!("{}/test", server.base_url());
430        let resp = client.get(&url).send().await.unwrap();
431
432        assert_eq!(resp.status(), hyper::StatusCode::OK);
433    }
434
435    #[tokio::test]
436    async fn test_http_client_post() {
437        let server = MockServer::start();
438        let _m = server.mock(|when, then| {
439            when.method(Method::POST).path("/action");
440            then.status(200).json_body(json!({"ok": true}));
441        });
442
443        let client = test_client();
444        let url = format!("{}/action", server.base_url());
445        let resp = client.post(&url).send().await.unwrap();
446
447        assert_eq!(resp.status(), hyper::StatusCode::OK);
448    }
449
450    #[tokio::test]
451    async fn test_http_client_post_form() {
452        let server = MockServer::start();
453        let _m = server.mock(|when, then| {
454            when.method(Method::POST)
455                .path("/submit")
456                .header("content-type", "application/x-www-form-urlencoded")
457                .body("key1=value1&key2=value2");
458            then.status(200).json_body(json!({"received": true}));
459        });
460
461        let client = test_client();
462        let url = format!("{}/submit", server.base_url());
463
464        let resp = client
465            .post(&url)
466            .form(&[("key1", "value1"), ("key2", "value2")])
467            .unwrap()
468            .send()
469            .await
470            .unwrap();
471        assert_eq!(resp.status(), hyper::StatusCode::OK);
472    }
473
474    #[tokio::test]
475    async fn test_json_body_parsing() {
476        #[derive(serde::Deserialize)]
477        struct TestResponse {
478            name: String,
479            value: i32,
480        }
481
482        let server = MockServer::start();
483        let _m = server.mock(|when, then| {
484            when.method(Method::GET).path("/json");
485            then.status(200)
486                .json_body(json!({"name": "test", "value": 42}));
487        });
488
489        let client = test_client();
490        let url = format!("{}/json", server.base_url());
491
492        let data: TestResponse = client.get(&url).send().await.unwrap().json().await.unwrap();
493        assert_eq!(data.name, "test");
494        assert_eq!(data.value, 42);
495    }
496
497    #[tokio::test]
498    async fn test_body_size_limit() {
499        let server = MockServer::start();
500        let large_body = "x".repeat(1024 * 1024); // 1MB
501        let _m = server.mock(|when, then| {
502            when.method(Method::GET).path("/large");
503            then.status(200).body(&large_body);
504        });
505
506        let client = HttpClientBuilder::new()
507            .retry(None)
508            .max_body_size(1024) // 1KB limit
509            .build()
510            .unwrap();
511
512        let url = format!("{}/large", server.base_url());
513        let result = client.get(&url).send().await.unwrap().bytes().await;
514
515        assert!(matches!(result, Err(HttpError::BodyTooLarge { .. })));
516    }
517
518    #[tokio::test]
519    async fn test_custom_user_agent() {
520        let server = MockServer::start();
521        let _m = server.mock(|when, then| {
522            when.method(Method::GET)
523                .path("/test")
524                .header("user-agent", "custom/1.0");
525            then.status(200);
526        });
527
528        let client = HttpClientBuilder::new()
529            .retry(None)
530            .user_agent("custom/1.0")
531            .build()
532            .unwrap();
533
534        let url = format!("{}/test", server.base_url());
535        let resp = client.get(&url).send().await.unwrap();
536        assert_eq!(resp.status(), hyper::StatusCode::OK);
537    }
538
539    #[tokio::test]
540    async fn test_non_2xx_returns_http_status_error() {
541        let server = MockServer::start();
542        let _m = server.mock(|when, then| {
543            when.method(Method::GET).path("/error");
544            then.status(404)
545                .header("content-type", "application/json")
546                .body(r#"{"error": "not found"}"#);
547        });
548
549        let client = test_client();
550        let url = format!("{}/error", server.base_url());
551
552        let result: Result<serde_json::Value, _> =
553            client.get(&url).send().await.unwrap().json().await;
554        match result {
555            Err(HttpError::HttpStatus {
556                status,
557                body_preview,
558                content_type,
559                ..
560            }) => {
561                assert_eq!(status, hyper::StatusCode::NOT_FOUND);
562                assert!(body_preview.contains("not found"));
563                assert_eq!(content_type, Some("application/json".to_owned()));
564            }
565            other => panic!("Expected HttpStatus error, got: {other:?}"),
566        }
567    }
568
569    #[tokio::test]
570    async fn test_checked_body_success() {
571        let server = MockServer::start();
572        let _m = server.mock(|when, then| {
573            when.method(Method::GET).path("/data");
574            then.status(200).body("hello world");
575        });
576
577        let client = test_client();
578        let url = format!("{}/data", server.base_url());
579
580        let body = client
581            .get(&url)
582            .send()
583            .await
584            .unwrap()
585            .checked_bytes()
586            .await
587            .unwrap();
588        assert_eq!(&body[..], b"hello world");
589    }
590
591    #[tokio::test]
592    async fn test_client_is_clone() {
593        let client = test_client();
594        let client2 = client.clone();
595
596        // Both should work independently
597        let server = MockServer::start();
598        let _m = server.mock(|when, then| {
599            when.method(Method::GET).path("/test");
600            then.status(200);
601        });
602
603        let url = format!("{}/test", server.base_url());
604        let resp1 = client.get(&url).send().await.unwrap();
605        let resp2 = client2.get(&url).send().await.unwrap();
606
607        assert_eq!(resp1.status(), hyper::StatusCode::OK);
608        assert_eq!(resp2.status(), hyper::StatusCode::OK);
609    }
610
611    /// Compile-time assertion that `HttpClient` is `Send + Sync`
612    ///
613    /// This test ensures callers do NOT need to wrap `HttpClient` in `Mutex`.
614    #[test]
615    fn test_http_client_is_send_sync() {
616        fn assert_send_sync<T: Send + Sync>() {}
617        assert_send_sync::<HttpClient>();
618    }
619
620    /// Test that 50 concurrent requests all succeed
621    #[tokio::test]
622    async fn test_concurrent_requests_50() {
623        let server = MockServer::start();
624        let _m = server.mock(|when, then| {
625            when.method(Method::GET).path("/concurrent");
626            then.status(200).body("ok");
627        });
628
629        let client = test_client();
630        let url = format!("{}/concurrent", server.base_url());
631
632        // Spawn 50 concurrent requests
633        let handles: Vec<_> = (0..50)
634            .map(|_| {
635                let client = client.clone();
636                let url = url.clone();
637                tokio::spawn(async move { client.get(&url).send().await })
638            })
639            .collect();
640
641        // All should succeed
642        for handle in handles {
643            let resp = handle.await.unwrap().unwrap();
644            assert_eq!(resp.status(), hyper::StatusCode::OK);
645        }
646    }
647
648    /// `x-request-id` set by an inbound middleware's `with_request_id` scope is
649    /// forwarded on the outbound request, so a downstream gear sees the edge id.
650    #[tokio::test]
651    async fn test_request_id_forwarded_when_in_scope() {
652        let server = MockServer::start();
653        let _m = server.mock(|when, then| {
654            when.method(Method::GET)
655                .path("/test")
656                .header("x-request-id", "req-42");
657            then.status(200);
658        });
659
660        let client = test_client();
661        let url = format!("{}/test", server.base_url());
662
663        let resp = toolkit_trace_context::with_request_id(
664            Some("req-42".to_owned()),
665            client.get(&url).send(),
666        )
667        .await
668        .unwrap();
669        assert_eq!(resp.status(), hyper::StatusCode::OK);
670    }
671
672    /// An explicit `x-request-id` header set by the caller wins over the
673    /// scoped id — the carrier must never overwrite a deliberate choice.
674    #[tokio::test]
675    async fn test_request_id_explicit_header_wins_over_scope() {
676        let server = MockServer::start();
677        let _m = server.mock(|when, then| {
678            when.method(Method::GET)
679                .path("/test")
680                .header("x-request-id", "explicit-id");
681            then.status(200);
682        });
683
684        let client = test_client();
685        let url = format!("{}/test", server.base_url());
686
687        let resp = toolkit_trace_context::with_request_id(
688            Some("scoped-id".to_owned()),
689            client
690                .get(&url)
691                .header("x-request-id", "explicit-id")
692                .send(),
693        )
694        .await
695        .unwrap();
696        assert_eq!(resp.status(), hyper::StatusCode::OK);
697    }
698
699    /// Outside a request scope nothing is minted: the header stays absent, so
700    /// background/startup calls don't invent request ids.
701    #[tokio::test]
702    async fn test_request_id_absent_outside_scope() {
703        let server = MockServer::start();
704        let mock = server.mock(|when, then| {
705            when.method(Method::GET)
706                .path("/test")
707                .header_missing("x-request-id");
708            then.status(200);
709        });
710
711        let client = test_client();
712        let url = format!("{}/test", server.base_url());
713        let resp = client.get(&url).send().await.unwrap();
714        assert_eq!(resp.status(), hyper::StatusCode::OK);
715        mock.assert_async().await;
716    }
717
718    /// Test small buffer capacity with fail-fast behavior
719    ///
720    /// With fail-fast buffer semantics, some requests may fail with Overloaded
721    /// when buffer is full. This test verifies:
722    /// 1. No deadlock (all complete within timeout)
723    /// 2. At least some requests succeed
724    /// 3. Failed requests get Overloaded error (not other errors)
725    #[tokio::test]
726    async fn test_small_buffer_capacity_no_deadlock() {
727        use crate::config::HttpClientConfig;
728
729        let server = MockServer::start();
730        let _m = server.mock(|when, then| {
731            when.method(Method::GET).path("/test");
732            then.status(200).body("ok");
733        });
734
735        // Create client with very small buffer (capacity 2)
736        let config = HttpClientConfig {
737            transport: crate::config::TransportSecurity::AllowInsecureHttp,
738            retry: None,
739            rate_limit: None,
740            buffer_capacity: 2,
741            ..Default::default()
742        };
743
744        let client = HttpClientBuilder::with_config(config).build().unwrap();
745        let url = format!("{}/test", server.base_url());
746
747        // Fire 10 concurrent requests - some may fail with Overloaded (fail-fast)
748        let handles: Vec<_> = (0..10)
749            .map(|_| {
750                let client = client.clone();
751                let url = url.clone();
752                tokio::spawn(async move { client.get(&url).send().await })
753            })
754            .collect();
755
756        // All should complete (not hang) within timeout
757        let timeout_result = tokio::time::timeout(std::time::Duration::from_secs(10), async {
758            let mut results = Vec::new();
759            for handle in handles {
760                results.push(handle.await);
761            }
762            results
763        })
764        .await;
765
766        let results = timeout_result.expect("requests should complete within timeout");
767
768        let mut success_count = 0;
769        let mut overloaded_count = 0;
770        for result in results {
771            match result.unwrap() {
772                Ok(resp) => {
773                    assert_eq!(resp.status(), hyper::StatusCode::OK);
774                    success_count += 1;
775                }
776                Err(HttpError::Overloaded) => {
777                    overloaded_count += 1;
778                }
779                Err(e) => panic!("unexpected error: {e:?}"),
780            }
781        }
782
783        // At least some should succeed (buffer processes requests)
784        assert!(success_count > 0, "at least one request should succeed");
785        // Total should be 10
786        assert_eq!(success_count + overloaded_count, 10);
787    }
788
789    /// Test buffer overflow returns Overloaded error immediately (fail-fast)
790    ///
791    /// Verifies that when buffer is full and inner service is blocked,
792    /// new requests fail immediately with Overloaded instead of hanging.
793    #[tokio::test]
794    async fn test_buffer_overflow_returns_overloaded() {
795        use crate::config::HttpClientConfig;
796
797        let server = MockServer::start();
798
799        let _m = server.mock(|when, then| {
800            when.method(Method::GET).path("/slow");
801            then.status(200).body("ok");
802        });
803
804        // Create client with buffer capacity of 1
805        let config = HttpClientConfig {
806            transport: crate::config::TransportSecurity::AllowInsecureHttp,
807            retry: None,
808            rate_limit: None,
809            buffer_capacity: 1,
810            ..Default::default()
811        };
812
813        let client = HttpClientBuilder::with_config(config).build().unwrap();
814        let url = format!("{}/slow", server.base_url());
815
816        // First request - will occupy the single buffer slot
817        let client1 = client.clone();
818        let url1 = url.clone();
819        let handle1 = tokio::spawn(async move { client1.get(&url1).send().await });
820
821        // Give first request time to acquire buffer slot
822        tokio::time::sleep(std::time::Duration::from_millis(10)).await;
823
824        // Second request - should fail immediately with Overloaded (buffer full)
825        let result2 = tokio::time::timeout(
826            std::time::Duration::from_millis(50),
827            client.get(&url).send(),
828        )
829        .await;
830
831        // Should complete immediately (not timeout) with Overloaded
832        let inner_result = result2.expect("request should not timeout waiting for buffer");
833        match inner_result {
834            // Expected: buffer full (fail-fast) or request got through (timing dependent)
835            Err(HttpError::Overloaded) | Ok(_) => {}
836            Err(e) => panic!("unexpected error: {e:?}"),
837        }
838
839        // Let first request complete
840        _ = handle1.await;
841    }
842
843    /// Test that large body reading doesn't cause deadlock
844    #[tokio::test]
845    async fn test_large_body_no_deadlock() {
846        let server = MockServer::start();
847        let large_body = "x".repeat(100 * 1024); // 100KB
848        let _m = server.mock(|when, then| {
849            when.method(Method::GET).path("/large");
850            then.status(200).body(&large_body);
851        });
852
853        let client = HttpClientBuilder::new()
854            .retry(None)
855            .max_body_size(1024 * 1024) // 1MB limit
856            .build()
857            .unwrap();
858
859        let url = format!("{}/large", server.base_url());
860
861        // Fire multiple concurrent requests that read large bodies
862        let handles: Vec<_> = (0..5)
863            .map(|_| {
864                let client = client.clone();
865                let url = url.clone();
866                tokio::spawn(async move { client.get(&url).send().await?.checked_bytes().await })
867            })
868            .collect();
869
870        // All should complete
871        let timeout_result = tokio::time::timeout(std::time::Duration::from_secs(10), async {
872            let mut results = Vec::new();
873            for handle in handles {
874                results.push(handle.await);
875            }
876            results
877        })
878        .await;
879
880        let results = timeout_result.expect("body reads should complete within timeout");
881        for result in results {
882            let body = result.unwrap().unwrap();
883            assert_eq!(body.len(), 100 * 1024);
884        }
885    }
886
887    /// Test that `token_endpoint` config does NOT retry POST requests
888    ///
889    /// `OAuth2` token endpoints use POST, and we must not retry POST to avoid
890    /// duplicate token requests. This test verifies the retry config in
891    /// `HttpClientConfig::token_endpoint()` only retries GET.
892    #[tokio::test]
893    async fn test_token_endpoint_post_not_retried() {
894        use crate::config::HttpClientConfig;
895
896        let server = MockServer::start();
897
898        // Mock that always returns 500 (retriable error)
899        let mock = server.mock(|when, then| {
900            when.method(Method::POST).path("/token");
901            then.status(500).body("server error");
902        });
903
904        // Use token_endpoint config (retry enabled but only for GET)
905        let mut config = HttpClientConfig::token_endpoint();
906        config.transport = crate::config::TransportSecurity::AllowInsecureHttp; // Allow HTTP for test server
907
908        let client = HttpClientBuilder::with_config(config).build().unwrap();
909        let url = format!("{}/token", server.base_url());
910
911        // POST form to token endpoint
912        let result = client
913            .post(&url)
914            .form(&[("grant_type", "client_credentials"), ("client_id", "test")])
915            .unwrap()
916            .send()
917            .await;
918
919        // Request should fail (500 error)
920        assert!(result.is_ok()); // HTTP request succeeded
921        let response = result.unwrap();
922        assert_eq!(response.status(), hyper::StatusCode::INTERNAL_SERVER_ERROR);
923
924        // Verify mock was called exactly once (no retries)
925        // httpmock tracks calls internally
926        assert_eq!(
927            mock.calls(),
928            1,
929            "POST should not be retried; expected 1 call, got {}",
930            mock.calls()
931        );
932    }
933
934    // NOTE: GET retry behavior is tested at the layer level in
935    // `layers::tests::test_retry_layer_retries_transport_errors` which uses
936    // a mock service to simulate transport errors. HTTP status codes (like 500)
937    // don't trigger retries since they're returned as Ok(Response), not Err.
938
939    #[tokio::test]
940    async fn test_http_client_put() {
941        let server = MockServer::start();
942        let _m = server.mock(|when, then| {
943            when.method(Method::PUT).path("/resource");
944            then.status(200).json_body(json!({"updated": true}));
945        });
946
947        let client = test_client();
948        let url = format!("{}/resource", server.base_url());
949        let resp = client.put(&url).send().await.unwrap();
950
951        assert_eq!(resp.status(), hyper::StatusCode::OK);
952    }
953
954    #[tokio::test]
955    async fn test_http_client_put_form() {
956        let server = MockServer::start();
957        let _m = server.mock(|when, then| {
958            when.method(Method::PUT)
959                .path("/resource")
960                .header("content-type", "application/x-www-form-urlencoded")
961                .body("name=updated&value=123");
962            then.status(200).json_body(json!({"updated": true}));
963        });
964
965        let client = test_client();
966        let url = format!("{}/resource", server.base_url());
967
968        let resp = client
969            .put(&url)
970            .form(&[("name", "updated"), ("value", "123")])
971            .unwrap()
972            .send()
973            .await
974            .unwrap();
975        assert_eq!(resp.status(), hyper::StatusCode::OK);
976    }
977
978    #[tokio::test]
979    async fn test_http_client_patch() {
980        let server = MockServer::start();
981        let _m = server.mock(|when, then| {
982            when.method(Method::PATCH).path("/resource/1");
983            then.status(200).json_body(json!({"patched": true}));
984        });
985
986        let client = test_client();
987        let url = format!("{}/resource/1", server.base_url());
988        let resp = client.patch(&url).send().await.unwrap();
989
990        assert_eq!(resp.status(), hyper::StatusCode::OK);
991    }
992
993    #[tokio::test]
994    async fn test_http_client_patch_form() {
995        let server = MockServer::start();
996        let _m = server.mock(|when, then| {
997            when.method(Method::PATCH)
998                .path("/resource/1")
999                .header("content-type", "application/x-www-form-urlencoded")
1000                .body("field=patched");
1001            then.status(200).json_body(json!({"patched": true}));
1002        });
1003
1004        let client = test_client();
1005        let url = format!("{}/resource/1", server.base_url());
1006
1007        let resp = client
1008            .patch(&url)
1009            .form(&[("field", "patched")])
1010            .unwrap()
1011            .send()
1012            .await
1013            .unwrap();
1014        assert_eq!(resp.status(), hyper::StatusCode::OK);
1015    }
1016
1017    #[tokio::test]
1018    async fn test_http_client_delete() {
1019        let server = MockServer::start();
1020        let _m = server.mock(|when, then| {
1021            when.method(Method::DELETE).path("/resource/42");
1022            then.status(204);
1023        });
1024
1025        let client = test_client();
1026        let url = format!("{}/resource/42", server.base_url());
1027        let resp = client.delete(&url).send().await.unwrap();
1028
1029        assert_eq!(resp.status(), hyper::StatusCode::NO_CONTENT);
1030    }
1031
1032    #[tokio::test]
1033    async fn test_http_client_delete_returns_200() {
1034        let server = MockServer::start();
1035        let _m = server.mock(|when, then| {
1036            when.method(Method::DELETE).path("/resource/99");
1037            then.status(200).json_body(json!({"deleted": true}));
1038        });
1039
1040        let client = test_client();
1041        let url = format!("{}/resource/99", server.base_url());
1042        let resp = client.delete(&url).send().await.unwrap();
1043
1044        assert_eq!(resp.status(), hyper::StatusCode::OK);
1045    }
1046
1047    #[tokio::test]
1048    async fn test_http_client_head_returns_headers_no_body() {
1049        let server = MockServer::start();
1050        let _m = server.mock(|when, then| {
1051            when.method(Method::HEAD).path("/probe");
1052            then.status(200)
1053                .header("content-length", "12345")
1054                .header("etag", "\"abc\"");
1055        });
1056
1057        let client = test_client();
1058        let url = format!("{}/probe", server.base_url());
1059        let resp = client.head(&url).send().await.unwrap();
1060
1061        assert_eq!(resp.status(), hyper::StatusCode::OK);
1062        assert_eq!(
1063            resp.headers()
1064                .get("content-length")
1065                .and_then(|v| v.to_str().ok()),
1066            Some("12345"),
1067        );
1068        let body = resp.bytes().await.unwrap();
1069        assert!(body.is_empty(), "HEAD response body must be empty");
1070    }
1071
1072    #[tokio::test]
1073    async fn test_http_client_options_returns_allow_header() {
1074        let server = MockServer::start();
1075        let _m = server.mock(|when, then| {
1076            when.method(Method::OPTIONS).path("/resource");
1077            then.status(204).header("allow", "GET, POST, PUT, DELETE");
1078        });
1079
1080        let client = test_client();
1081        let url = format!("{}/resource", server.base_url());
1082        let resp = client.options(&url).send().await.unwrap();
1083
1084        assert_eq!(resp.status(), hyper::StatusCode::NO_CONTENT);
1085        assert_eq!(
1086            resp.headers().get("allow").and_then(|v| v.to_str().ok()),
1087            Some("GET, POST, PUT, DELETE"),
1088        );
1089    }
1090
1091    #[tokio::test]
1092    async fn test_put_form_with_custom_headers() {
1093        let server = MockServer::start();
1094        let _m = server.mock(|when, then| {
1095            when.method(Method::PUT)
1096                .path("/api/data")
1097                .header("content-type", "application/x-www-form-urlencoded")
1098                .header("x-custom-header", "custom-value")
1099                .body("key=value");
1100            then.status(200);
1101        });
1102
1103        let client = test_client();
1104        let url = format!("{}/api/data", server.base_url());
1105
1106        let resp = client
1107            .put(&url)
1108            .header("x-custom-header", "custom-value")
1109            .form(&[("key", "value")])
1110            .unwrap()
1111            .send()
1112            .await
1113            .unwrap();
1114        assert_eq!(resp.status(), hyper::StatusCode::OK);
1115    }
1116
1117    #[tokio::test]
1118    async fn test_patch_form_with_custom_headers() {
1119        let server = MockServer::start();
1120        let _m = server.mock(|when, then| {
1121            when.method(Method::PATCH)
1122                .path("/api/data")
1123                .header("content-type", "application/x-www-form-urlencoded")
1124                .header("authorization", "Bearer token123")
1125                .body("status=active");
1126            then.status(200);
1127        });
1128
1129        let client = test_client();
1130        let url = format!("{}/api/data", server.base_url());
1131
1132        let resp = client
1133            .patch(&url)
1134            .header("authorization", "Bearer token123")
1135            .form(&[("status", "active")])
1136            .unwrap()
1137            .send()
1138            .await
1139            .unwrap();
1140        assert_eq!(resp.status(), hyper::StatusCode::OK);
1141    }
1142
1143    #[tokio::test]
1144    async fn test_request_builder_json_body() {
1145        #[derive(serde::Serialize)]
1146        struct CreateUser {
1147            name: String,
1148            email: String,
1149        }
1150
1151        let server = MockServer::start();
1152        let _m = server.mock(|when, then| {
1153            when.method(Method::POST)
1154                .path("/users")
1155                .header("content-type", "application/json")
1156                .json_body(json!({"name": "Alice", "email": "alice@example.com"}));
1157            then.status(201).json_body(json!({"id": 1}));
1158        });
1159
1160        let client = test_client();
1161        let url = format!("{}/users", server.base_url());
1162
1163        let resp = client
1164            .post(&url)
1165            .json(&CreateUser {
1166                name: "Alice".into(),
1167                email: "alice@example.com".into(),
1168            })
1169            .unwrap()
1170            .send()
1171            .await
1172            .unwrap();
1173        assert_eq!(resp.status(), hyper::StatusCode::CREATED);
1174    }
1175
1176    #[tokio::test]
1177    async fn test_request_builder_body_bytes() {
1178        let server = MockServer::start();
1179        let _m = server.mock(|when, then| {
1180            when.method(Method::POST)
1181                .path("/upload")
1182                .body("raw binary data");
1183            then.status(200);
1184        });
1185
1186        let client = test_client();
1187        let url = format!("{}/upload", server.base_url());
1188
1189        let resp = client
1190            .post(&url)
1191            .body_bytes(bytes::Bytes::from("raw binary data"))
1192            .send()
1193            .await
1194            .unwrap();
1195        assert_eq!(resp.status(), hyper::StatusCode::OK);
1196    }
1197
1198    /// Test that user-provided Content-Type is not duplicated when using `json()`.
1199    ///
1200    /// When the user supplies a Content-Type header before calling `.json()`,
1201    /// the default `application/json` should NOT be added. The final request
1202    /// should have exactly one Content-Type header with the user's value.
1203    #[tokio::test]
1204    async fn test_content_type_not_duplicated_with_json() {
1205        #[derive(serde::Serialize)]
1206        struct TestData {
1207            value: i32,
1208        }
1209
1210        let server = MockServer::start();
1211        let mock = server.mock(|when, then| {
1212            when.method(Method::POST)
1213                .path("/custom-content-type")
1214                // Match the custom Content-Type (not application/json)
1215                .header("content-type", "application/vnd.custom+json");
1216            then.status(200);
1217        });
1218
1219        let client = test_client();
1220        let url = format!("{}/custom-content-type", server.base_url());
1221
1222        let resp = client
1223            .post(&url)
1224            .header("content-type", "application/vnd.custom+json") // Custom Content-Type
1225            .json(&TestData { value: 42 })
1226            .unwrap()
1227            .send()
1228            .await
1229            .unwrap();
1230
1231        assert_eq!(resp.status(), hyper::StatusCode::OK);
1232        assert_eq!(
1233            mock.calls(),
1234            1,
1235            "Request with custom Content-Type should match"
1236        );
1237    }
1238
1239    /// Test that user-provided Content-Type is not duplicated when using `form()`.
1240    #[tokio::test]
1241    async fn test_content_type_not_duplicated_with_form() {
1242        let server = MockServer::start();
1243        let mock = server.mock(|when, then| {
1244            when.method(Method::POST)
1245                .path("/custom-form-type")
1246                // Match the custom Content-Type (not application/x-www-form-urlencoded)
1247                .header("content-type", "application/x-custom-form");
1248            then.status(200);
1249        });
1250
1251        let client = test_client();
1252        let url = format!("{}/custom-form-type", server.base_url());
1253
1254        let resp = client
1255            .post(&url)
1256            .header("content-type", "application/x-custom-form") // Custom Content-Type
1257            .form(&[("key", "value")])
1258            .unwrap()
1259            .send()
1260            .await
1261            .unwrap();
1262
1263        assert_eq!(resp.status(), hyper::StatusCode::OK);
1264        assert_eq!(
1265            mock.calls(),
1266            1,
1267            "Request with custom Content-Type should match"
1268        );
1269    }
1270
1271    #[tokio::test]
1272    async fn test_request_builder_body_string() {
1273        let server = MockServer::start();
1274        let _m = server.mock(|when, then| {
1275            when.method(Method::POST)
1276                .path("/text")
1277                .body("Hello, World!");
1278            then.status(200);
1279        });
1280
1281        let client = test_client();
1282        let url = format!("{}/text", server.base_url());
1283
1284        let resp = client
1285            .post(&url)
1286            .body_string("Hello, World!".into())
1287            .send()
1288            .await
1289            .unwrap();
1290        assert_eq!(resp.status(), hyper::StatusCode::OK);
1291    }
1292
1293    #[tokio::test]
1294    async fn test_response_text_method() {
1295        let server = MockServer::start();
1296        let _m = server.mock(|when, then| {
1297            when.method(Method::GET).path("/text");
1298            then.status(200).body("Hello, World!");
1299        });
1300
1301        let client = test_client();
1302        let url = format!("{}/text", server.base_url());
1303
1304        let text = client.get(&url).send().await.unwrap().text().await.unwrap();
1305        assert_eq!(text, "Hello, World!");
1306    }
1307
1308    #[tokio::test]
1309    async fn test_request_builder_multiple_headers() {
1310        let server = MockServer::start();
1311        let _m = server.mock(|when, then| {
1312            when.method(Method::GET)
1313                .path("/headers")
1314                .header("x-first", "one")
1315                .header("x-second", "two");
1316            then.status(200);
1317        });
1318
1319        let client = test_client();
1320        let url = format!("{}/headers", server.base_url());
1321
1322        let resp = client
1323            .get(&url)
1324            .header("x-first", "one")
1325            .header("x-second", "two")
1326            .send()
1327            .await
1328            .unwrap();
1329        assert_eq!(resp.status(), hyper::StatusCode::OK);
1330    }
1331
1332    #[tokio::test]
1333    async fn test_request_builder_headers_vec() {
1334        let server = MockServer::start();
1335        let _m = server.mock(|when, then| {
1336            when.method(Method::GET)
1337                .path("/headers")
1338                .header("x-first", "one")
1339                .header("x-second", "two");
1340            then.status(200);
1341        });
1342
1343        let client = test_client();
1344        let url = format!("{}/headers", server.base_url());
1345
1346        let resp = client
1347            .get(&url)
1348            .headers(vec![
1349                ("x-first".to_owned(), "one".to_owned()),
1350                ("x-second".to_owned(), "two".to_owned()),
1351            ])
1352            .send()
1353            .await
1354            .unwrap();
1355        assert_eq!(resp.status(), hyper::StatusCode::OK);
1356    }
1357
1358    /// Test that `checked_bytes` returns `HttpStatus` error (not `BodyTooLarge`) when
1359    /// a non-2xx response has a body larger than the preview limit.
1360    #[tokio::test]
1361    async fn test_error_response_with_large_body_returns_http_status() {
1362        use crate::security::ERROR_BODY_PREVIEW_LIMIT;
1363
1364        let server = MockServer::start();
1365
1366        // Create a body larger than ERROR_BODY_PREVIEW_LIMIT (8KB)
1367        let large_body = "x".repeat(ERROR_BODY_PREVIEW_LIMIT + 1000);
1368
1369        let _m = server.mock(|when, then| {
1370            when.method(Method::GET).path("/error-with-large-body");
1371            then.status(500).body(&large_body);
1372        });
1373
1374        let client = test_client();
1375        let url = format!("{}/error-with-large-body", server.base_url());
1376
1377        let result = client.get(&url).send().await.unwrap().checked_bytes().await;
1378
1379        // Should return HttpStatus error, NOT BodyTooLarge
1380        match result {
1381            Err(HttpError::HttpStatus {
1382                status,
1383                body_preview,
1384                ..
1385            }) => {
1386                assert_eq!(status, hyper::StatusCode::INTERNAL_SERVER_ERROR);
1387                // Body preview should indicate it was too large
1388                assert_eq!(body_preview, "<body too large for preview>");
1389            }
1390            Err(HttpError::BodyTooLarge { .. }) => {
1391                panic!("Should return HttpStatus, not BodyTooLarge for non-2xx responses");
1392            }
1393            Err(other) => panic!("Unexpected error: {other:?}"),
1394            Ok(_) => panic!("Should have returned an error for 500 status"),
1395        }
1396    }
1397
1398    // ==========================================================================
1399    // Gzip/Br/Deflate Decompression Tests
1400    // ==========================================================================
1401
1402    /// Helper to gzip-compress data
1403    fn gzip_compress(data: &[u8]) -> Vec<u8> {
1404        use flate2::Compression;
1405        use flate2::write::GzEncoder;
1406        use std::io::Write;
1407
1408        let mut encoder = GzEncoder::new(Vec::new(), Compression::default());
1409        encoder.write_all(data).unwrap();
1410        encoder.finish().unwrap()
1411    }
1412
1413    /// Test that gzip-encoded response is automatically decompressed.
1414    ///
1415    /// Server returns a gzip-compressed body with `Content-Encoding: gzip`.
1416    /// Client should automatically decompress and return the original bytes.
1417    #[tokio::test]
1418    async fn test_gzip_decompression_basic() {
1419        let server = MockServer::start();
1420
1421        let original_body = b"Hello, this is a test body that will be gzip compressed!";
1422        let compressed_body = gzip_compress(original_body);
1423
1424        let _m = server.mock(|when, then| {
1425            when.method(Method::GET).path("/gzip");
1426            then.status(200)
1427                .header("content-encoding", "gzip")
1428                .body(compressed_body);
1429        });
1430
1431        let client = test_client();
1432        let url = format!("{}/gzip", server.base_url());
1433
1434        let body = client
1435            .get(&url)
1436            .send()
1437            .await
1438            .unwrap()
1439            .bytes()
1440            .await
1441            .unwrap();
1442
1443        assert_eq!(
1444            body.as_ref(),
1445            original_body,
1446            "Decompressed body should match original"
1447        );
1448    }
1449
1450    /// Test that gzip-compressed JSON can be parsed via `response.json()`.
1451    ///
1452    /// Server returns gzipped JSON with `Content-Encoding: gzip`.
1453    /// Client should decompress and successfully parse the JSON.
1454    #[tokio::test]
1455    async fn test_gzip_decompression_json() {
1456        #[derive(serde::Deserialize, PartialEq, Debug)]
1457        struct TestData {
1458            name: String,
1459            value: i32,
1460            nested: NestedData,
1461        }
1462
1463        #[derive(serde::Deserialize, PartialEq, Debug)]
1464        struct NestedData {
1465            items: Vec<String>,
1466        }
1467
1468        let server = MockServer::start();
1469
1470        let json_body = r#"{"name":"test","value":42,"nested":{"items":["a","b","c"]}}"#;
1471        let compressed_body = gzip_compress(json_body.as_bytes());
1472
1473        let _m = server.mock(|when, then| {
1474            when.method(Method::GET).path("/gzip-json");
1475            then.status(200)
1476                .header("content-type", "application/json")
1477                .header("content-encoding", "gzip")
1478                .body(compressed_body);
1479        });
1480
1481        let client = test_client();
1482        let url = format!("{}/gzip-json", server.base_url());
1483
1484        let data: TestData = client.get(&url).send().await.unwrap().json().await.unwrap();
1485
1486        assert_eq!(data.name, "test");
1487        assert_eq!(data.value, 42);
1488        assert_eq!(data.nested.items, vec!["a", "b", "c"]);
1489    }
1490
1491    /// Test that body size limit is enforced on DECOMPRESSED bytes, not compressed.
1492    ///
1493    /// This protects against "zip bombs" where a small compressed payload
1494    /// expands to a huge decompressed size.
1495    ///
1496    /// The test creates a highly compressible payload (repeated 'x' chars)
1497    /// that compresses small but expands beyond the `max_body_size` limit.
1498    #[tokio::test]
1499    async fn test_gzip_decompression_body_size_limit() {
1500        let server = MockServer::start();
1501
1502        // Create a body that compresses well but is large when decompressed.
1503        // 100KB of repeated 'x' compresses to a few hundred bytes.
1504        let large_decompressed = vec![b'x'; 100 * 1024]; // 100KB
1505        let compressed_body = gzip_compress(&large_decompressed);
1506
1507        // Verify compression is significant (sanity check)
1508        assert!(
1509            compressed_body.len() < 2000,
1510            "Compressed body should be small (got {} bytes)",
1511            compressed_body.len()
1512        );
1513
1514        let _m = server.mock(|when, then| {
1515            when.method(Method::GET).path("/gzip-bomb");
1516            then.status(200)
1517                .header("content-encoding", "gzip")
1518                .body(compressed_body);
1519        });
1520
1521        // Create client with 10KB body limit - smaller than decompressed size
1522        let client = HttpClientBuilder::new()
1523            .retry(None)
1524            .max_body_size(10 * 1024) // 10KB limit
1525            .build()
1526            .unwrap();
1527
1528        let url = format!("{}/gzip-bomb", server.base_url());
1529        let result = client.get(&url).send().await.unwrap().bytes().await;
1530
1531        // Should fail with BodyTooLarge because decompressed size exceeds limit
1532        match result {
1533            Err(HttpError::BodyTooLarge { limit, actual }) => {
1534                assert_eq!(limit, 10 * 1024, "Limit should be 10KB");
1535                assert!(
1536                    actual > limit,
1537                    "Actual size ({actual}) should exceed limit ({limit})"
1538                );
1539            }
1540            Err(other) => panic!("Expected BodyTooLarge error, got: {other:?}"),
1541            Ok(body) => panic!(
1542                "Expected BodyTooLarge error, but got {} bytes of body",
1543                body.len()
1544            ),
1545        }
1546    }
1547
1548    /// Test that Accept-Encoding header is automatically set by the client.
1549    ///
1550    /// The `DecompressionLayer` automatically adds `Accept-Encoding: gzip, br, deflate`
1551    /// to outgoing requests.
1552    #[tokio::test]
1553    async fn test_accept_encoding_header_sent() {
1554        let server = MockServer::start();
1555
1556        // Mock that requires Accept-Encoding header to be present
1557        let mock = server.mock(|when, then| {
1558            when.method(Method::GET)
1559                .path("/check-accept-encoding")
1560                .header_exists("accept-encoding");
1561            then.status(200).body("ok");
1562        });
1563
1564        let client = test_client();
1565        let url = format!("{}/check-accept-encoding", server.base_url());
1566
1567        let resp = client.get(&url).send().await.unwrap();
1568        assert_eq!(resp.status(), hyper::StatusCode::OK);
1569
1570        // Verify the mock was hit (meaning Accept-Encoding was present)
1571        assert_eq!(
1572            mock.calls(),
1573            1,
1574            "Request should have included Accept-Encoding header"
1575        );
1576    }
1577
1578    /// Test that non-compressed responses still work normally.
1579    ///
1580    /// When server doesn't return Content-Encoding, the body should pass through unchanged.
1581    #[tokio::test]
1582    async fn test_no_compression_passthrough() {
1583        let server = MockServer::start();
1584
1585        let plain_body = b"This is plain text, not compressed";
1586
1587        let _m = server.mock(|when, then| {
1588            when.method(Method::GET).path("/plain");
1589            then.status(200)
1590                .header("content-type", "text/plain")
1591                .body(plain_body.as_slice());
1592        });
1593
1594        let client = test_client();
1595        let url = format!("{}/plain", server.base_url());
1596
1597        let body = client
1598            .get(&url)
1599            .send()
1600            .await
1601            .unwrap()
1602            .bytes()
1603            .await
1604            .unwrap();
1605
1606        assert_eq!(
1607            body.as_ref(),
1608            plain_body,
1609            "Plain body should pass through unchanged"
1610        );
1611    }
1612
1613    /// Test that `checked_bytes` works correctly with gzip decompression.
1614    #[tokio::test]
1615    async fn test_gzip_decompression_checked_bytes() {
1616        let server = MockServer::start();
1617
1618        let original_body = b"Checked bytes test with gzip";
1619        let compressed_body = gzip_compress(original_body);
1620
1621        let _m = server.mock(|when, then| {
1622            when.method(Method::GET).path("/gzip-checked");
1623            then.status(200)
1624                .header("content-encoding", "gzip")
1625                .body(compressed_body);
1626        });
1627
1628        let client = test_client();
1629        let url = format!("{}/gzip-checked", server.base_url());
1630
1631        let body = client
1632            .get(&url)
1633            .send()
1634            .await
1635            .unwrap()
1636            .checked_bytes()
1637            .await
1638            .unwrap();
1639
1640        assert_eq!(
1641            body.as_ref(),
1642            original_body,
1643            "checked_bytes should return decompressed content"
1644        );
1645    }
1646
1647    /// Test that `text()` method works correctly with gzip decompression.
1648    #[tokio::test]
1649    async fn test_gzip_decompression_text() {
1650        let server = MockServer::start();
1651
1652        let original_text = "Hello, World! \u{1F600}"; // Contains emoji
1653        let compressed_body = gzip_compress(original_text.as_bytes());
1654
1655        let _m = server.mock(|when, then| {
1656            when.method(Method::GET).path("/gzip-text");
1657            then.status(200)
1658                .header("content-type", "text/plain; charset=utf-8")
1659                .header("content-encoding", "gzip")
1660                .body(compressed_body);
1661        });
1662
1663        let client = test_client();
1664        let url = format!("{}/gzip-text", server.base_url());
1665
1666        let text = client.get(&url).send().await.unwrap().text().await.unwrap();
1667
1668        assert_eq!(
1669            text, original_text,
1670            "text() should return decompressed UTF-8 content"
1671        );
1672    }
1673
1674    // ==========================================================================
1675    // Buffer Error Mapping Tests
1676    // ==========================================================================
1677
1678    /// Test that `map_buffer_error` returns inner `HttpError` when present.
1679    #[test]
1680    fn test_map_buffer_error_passes_through_http_error() {
1681        let http_err = HttpError::Timeout(std::time::Duration::from_secs(10));
1682        let boxed: tower::BoxError = Box::new(http_err);
1683        let result = map_buffer_error(boxed);
1684
1685        assert!(
1686            matches!(result, HttpError::Timeout(_)),
1687            "Should pass through HttpError::Timeout, got: {result:?}"
1688        );
1689    }
1690
1691    /// Test that `map_buffer_error` returns `ServiceClosed` for non-HttpError.
1692    ///
1693    /// This covers the case where buffer is closed or worker panicked.
1694    /// The error log is emitted (verified by code coverage, not assertion).
1695    #[test]
1696    fn test_map_buffer_error_returns_service_closed_for_unknown_error() {
1697        // Simulate a buffer closed error (any non-HttpError box)
1698        let other_err: tower::BoxError = Box::new(std::io::Error::new(
1699            std::io::ErrorKind::BrokenPipe,
1700            "buffer worker died",
1701        ));
1702        let result = map_buffer_error(other_err);
1703
1704        assert!(
1705            matches!(result, HttpError::ServiceClosed),
1706            "Should return ServiceClosed for non-HttpError, got: {result:?}"
1707        );
1708    }
1709
1710    // ==========================================================================
1711    // Status-based Retry Integration Tests
1712    //
1713    // These tests verify that retry-on-status works END-TO-END with real HTTP
1714    // responses (not just mock services that return Err directly).
1715    //
1716    // Key insight: hyper returns Ok(Response) for all HTTP statuses.
1717    // RetryLayer handles retries on Ok(Response) by checking status codes,
1718    // then returns Ok(Response) with the final status after retries exhaust.
1719    // send() NEVER returns Err(HttpStatus) - that's only created by error_for_status().
1720    // ==========================================================================
1721
1722    /// Test: GET request with 500 errors is retried.
1723    ///
1724    /// Server always returns 500. Asserts total calls == `max_retries` + 1.
1725    /// After retries exhaust, returns Ok(Response) with 500 status.
1726    #[tokio::test]
1727    async fn test_status_retry_get_500_retried() {
1728        use crate::config::{ExponentialBackoff, HttpClientConfig, RetryConfig};
1729
1730        let server = MockServer::start();
1731        let mock = server.mock(|when, then| {
1732            when.method(Method::GET).path("/retry-500");
1733            then.status(500).body("server error");
1734        });
1735
1736        let config = HttpClientConfig {
1737            transport: crate::config::TransportSecurity::AllowInsecureHttp,
1738            retry: Some(RetryConfig {
1739                max_retries: 2, // 1 initial + 2 retries = 3 total attempts
1740                backoff: ExponentialBackoff::fast(),
1741                ..RetryConfig::default() // 500 is in idempotent_retry
1742            }),
1743            rate_limit: None,
1744            ..Default::default()
1745        };
1746
1747        let client = HttpClientBuilder::with_config(config).build().unwrap();
1748        let url = format!("{}/retry-500", server.base_url());
1749
1750        let result = client.get(&url).send().await;
1751
1752        // GET on 500 SHOULD be retried (GET is idempotent, 500 is in idempotent_retry)
1753        assert_eq!(
1754            mock.calls(),
1755            3,
1756            "GET should retry on 500; expected 3 calls (1 + 2 retries), got {}",
1757            mock.calls()
1758        );
1759
1760        // After retries exhaust: returns Ok(Response) with 500 status
1761        let response = result.expect("send() should return Ok(Response) after retries exhaust");
1762        assert_eq!(response.status(), hyper::StatusCode::INTERNAL_SERVER_ERROR);
1763
1764        // User can convert to error via error_for_status()
1765        let err = response.error_for_status().unwrap_err();
1766        assert!(
1767            matches!(err, HttpError::HttpStatus { status, .. } if status == hyper::StatusCode::INTERNAL_SERVER_ERROR)
1768        );
1769    }
1770
1771    /// Test: POST request with 500 is NOT retried and returns Ok(Response).
1772    ///
1773    /// With default retry config, 500 is only retried for idempotent methods.
1774    /// POST is not idempotent, so:
1775    /// 1. No retry (calls == 1)
1776    /// 2. Returns Ok(Response) with status 500 (not converted to Err)
1777    ///
1778    /// User can use `.error_for_status()` or `.json()` to handle the error.
1779    #[tokio::test]
1780    async fn test_status_retry_post_500_not_retried() {
1781        use crate::config::{ExponentialBackoff, HttpClientConfig, RetryConfig};
1782
1783        let server = MockServer::start();
1784        let mock = server.mock(|when, then| {
1785            when.method(Method::POST).path("/post-500");
1786            then.status(500).body("server error");
1787        });
1788
1789        let config = HttpClientConfig {
1790            transport: crate::config::TransportSecurity::AllowInsecureHttp,
1791            retry: Some(RetryConfig {
1792                max_retries: 3,
1793                backoff: ExponentialBackoff::fast(),
1794                ..RetryConfig::default() // 500 is in idempotent_retry, not always_retry
1795            }),
1796            rate_limit: None,
1797            ..Default::default()
1798        };
1799
1800        let client = HttpClientBuilder::with_config(config).build().unwrap();
1801        let url = format!("{}/post-500", server.base_url());
1802
1803        let result = client.post(&url).send().await;
1804
1805        // POST on 500 should NOT be retried (only idempotent methods)
1806        assert_eq!(
1807            mock.calls(),
1808            1,
1809            "POST should not be retried on 500; expected 1 call, got {}",
1810            mock.calls()
1811        );
1812
1813        // Result should be Ok(Response) with status 500 - NOT converted to error
1814        // because 500 is not retryable for non-idempotent methods
1815        let response = result.expect("POST + 500 should return Ok(Response), not Err");
1816        assert_eq!(
1817            response.status(),
1818            hyper::StatusCode::INTERNAL_SERVER_ERROR,
1819            "Response should have 500 status"
1820        );
1821
1822        // User can still use error_for_status() to convert to error if needed
1823    }
1824
1825    /// Test: POST request with 429 IS retried (`always_retry` policy).
1826    ///
1827    /// 429 (Too Many Requests) is in `always_retry` set, so it's retried
1828    /// regardless of HTTP method. Asserts calls == `max_retries` + 1.
1829    /// After retries exhaust, returns Ok(Response) with 429 status.
1830    #[tokio::test]
1831    async fn test_status_retry_post_429_retried() {
1832        use crate::config::{ExponentialBackoff, HttpClientConfig, RetryConfig};
1833
1834        let server = MockServer::start();
1835        let mock = server.mock(|when, then| {
1836            when.method(Method::POST).path("/post-429");
1837            then.status(429).body("rate limited");
1838        });
1839
1840        let config = HttpClientConfig {
1841            transport: crate::config::TransportSecurity::AllowInsecureHttp,
1842            retry: Some(RetryConfig {
1843                max_retries: 2, // 1 initial + 2 retries = 3 total
1844                backoff: ExponentialBackoff::fast(),
1845                ..RetryConfig::default() // 429 is in always_retry
1846            }),
1847            rate_limit: None,
1848            ..Default::default()
1849        };
1850
1851        let client = HttpClientBuilder::with_config(config).build().unwrap();
1852        let url = format!("{}/post-429", server.base_url());
1853
1854        let result = client.post(&url).send().await;
1855
1856        // POST on 429 SHOULD be retried (429 is in always_retry)
1857        assert_eq!(
1858            mock.calls(),
1859            3,
1860            "POST should retry on 429; expected 3 calls (1 + 2 retries), got {}",
1861            mock.calls()
1862        );
1863
1864        // After retries exhaust: returns Ok(Response) with 429 status
1865        let response = result.expect("send() should return Ok(Response) after retries exhaust");
1866        assert_eq!(response.status(), hyper::StatusCode::TOO_MANY_REQUESTS);
1867
1868        // User can convert to error via error_for_status()
1869        let err = response.error_for_status().unwrap_err();
1870        assert!(
1871            matches!(err, HttpError::HttpStatus { status, .. } if status == hyper::StatusCode::TOO_MANY_REQUESTS)
1872        );
1873    }
1874
1875    /// Test: Retry-After header is preserved and accessible via `error_for_status()`.
1876    ///
1877    /// Server returns 429 with `Retry-After: 60`. `send()` returns Ok(Response).
1878    /// User calls `error_for_status()` which parses Retry-After from headers.
1879    #[tokio::test]
1880    async fn test_status_retry_extracts_retry_after_header() {
1881        use crate::config::{ExponentialBackoff, HttpClientConfig, RetryConfig};
1882
1883        let server = MockServer::start();
1884        let _mock = server.mock(|when, then| {
1885            when.method(Method::GET).path("/retry-after");
1886            then.status(429)
1887                .header("Retry-After", "60")
1888                .header("Content-Type", "application/json")
1889                .body(r#"{"error": "rate limited"}"#);
1890        });
1891
1892        let config = HttpClientConfig {
1893            transport: crate::config::TransportSecurity::AllowInsecureHttp,
1894            retry: Some(RetryConfig {
1895                max_retries: 0, // No retries - we want to see the response immediately
1896                backoff: ExponentialBackoff::fast(),
1897                ..RetryConfig::default()
1898            }),
1899            rate_limit: None,
1900            ..Default::default()
1901        };
1902
1903        let client = HttpClientBuilder::with_config(config).build().unwrap();
1904        let url = format!("{}/retry-after", server.base_url());
1905
1906        let result = client.get(&url).send().await;
1907
1908        // send() returns Ok(Response) - status codes don't become Err
1909        let response = result.expect("send() should return Ok(Response)");
1910        assert_eq!(response.status(), hyper::StatusCode::TOO_MANY_REQUESTS);
1911
1912        // error_for_status() extracts Retry-After and Content-Type
1913        match response.error_for_status() {
1914            Err(HttpError::HttpStatus {
1915                status,
1916                retry_after,
1917                content_type,
1918                ..
1919            }) => {
1920                assert_eq!(status, hyper::StatusCode::TOO_MANY_REQUESTS);
1921                assert_eq!(
1922                    retry_after,
1923                    Some(std::time::Duration::from_mins(1)),
1924                    "Should extract Retry-After header"
1925                );
1926                assert_eq!(
1927                    content_type,
1928                    Some("application/json".to_owned()),
1929                    "Should extract Content-Type header"
1930                );
1931            }
1932            other => panic!("Expected HttpStatus error from error_for_status(), got: {other:?}"),
1933        }
1934    }
1935
1936    // NOTE: test_status_retry_honors_retry_after_timing was removed because it relied
1937    // on seconds-scale elapsed time assertions which are flaky in CI environments.
1938    // The Retry-After header parsing and usage is tested at the unit level in:
1939    // - response::tests::test_parse_retry_after_*
1940    // - layers::tests::test_retry_layer_uses_retry_after_header (50ms, fast)
1941    // - test_status_retry_extracts_retry_after_header (verifies field extraction)
1942
1943    /// Test: Retry delay ignores Retry-After when `ignore_retry_after=true`.
1944    ///
1945    /// Server always returns 429 with `Retry-After: 10`. Config has fast backoff.
1946    /// Elapsed time should be fast (< 1s), not ~20s (2 * 10s).
1947    #[tokio::test]
1948    async fn test_status_retry_ignores_retry_after_when_configured() {
1949        use crate::config::{ExponentialBackoff, HttpClientConfig, RetryConfig};
1950
1951        let server = MockServer::start();
1952        let mock = server.mock(|when, then| {
1953            when.method(Method::GET).path("/ignore-retry-after");
1954            then.status(429)
1955                .header("Retry-After", "10") // 10 seconds
1956                .body("rate limited");
1957        });
1958
1959        let config = HttpClientConfig {
1960            transport: crate::config::TransportSecurity::AllowInsecureHttp,
1961            retry: Some(RetryConfig {
1962                max_retries: 2,
1963                backoff: ExponentialBackoff::fast(), // 1ms initial
1964                ignore_retry_after: true,            // Ignore Retry-After header
1965                ..RetryConfig::default()
1966            }),
1967            rate_limit: None,
1968            ..Default::default()
1969        };
1970
1971        let client = HttpClientBuilder::with_config(config).build().unwrap();
1972        let url = format!("{}/ignore-retry-after", server.base_url());
1973
1974        let start = std::time::Instant::now();
1975        let _result = client.get(&url).send().await;
1976        let elapsed = start.elapsed();
1977
1978        // With ignore_retry_after=true and fast backoff, should be very fast
1979        // NOT 2 * 10s = 20s from Retry-After
1980        assert!(
1981            elapsed < std::time::Duration::from_secs(2),
1982            "Should have used fast backoff, not 10s Retry-After; elapsed: {elapsed:?}"
1983        );
1984
1985        // Verify we made 3 calls (1 initial + 2 retries)
1986        assert_eq!(mock.calls(), 3, "Expected 3 calls, got {}", mock.calls());
1987    }
1988
1989    /// Test: Non-retryable status (404) is NOT converted to error by `StatusToErrorLayer`.
1990    ///
1991    /// 404 is not in retry triggers, so it passes through as Ok(Response).
1992    /// User can still use `.error_for_status()` or `.json()` to check.
1993    #[tokio::test]
1994    async fn test_non_retryable_status_passes_through() {
1995        use crate::config::{ExponentialBackoff, HttpClientConfig, RetryConfig};
1996
1997        let server = MockServer::start();
1998        let mock = server.mock(|when, then| {
1999            when.method(Method::GET).path("/not-found");
2000            then.status(404)
2001                .header("content-type", "application/json")
2002                .body(r#"{"error": "not found"}"#);
2003        });
2004
2005        let config = HttpClientConfig {
2006            transport: crate::config::TransportSecurity::AllowInsecureHttp,
2007            retry: Some(RetryConfig {
2008                max_retries: 3,
2009                backoff: ExponentialBackoff::fast(),
2010                ..RetryConfig::default()
2011            }),
2012            rate_limit: None,
2013            ..Default::default()
2014        };
2015
2016        let client = HttpClientBuilder::with_config(config).build().unwrap();
2017        let url = format!("{}/not-found", server.base_url());
2018
2019        // send() should succeed (404 is not a retryable error)
2020        let result = client.get(&url).send().await;
2021
2022        // Only called once - no retry
2023        assert_eq!(
2024            mock.calls(),
2025            1,
2026            "404 should not trigger retry; expected 1 call, got {}",
2027            mock.calls()
2028        );
2029
2030        // Response is Ok, but status is 404
2031        let response = result.expect("send() should succeed for 404");
2032        assert_eq!(response.status(), hyper::StatusCode::NOT_FOUND);
2033
2034        // User can check status manually if needed via error_for_status
2035    }
2036
2037    /// Test: Multiple retries exhausted returns Ok(Response) with final status.
2038    ///
2039    /// Server always returns 500. After `max_retries` (2) + initial = 3 attempts,
2040    /// returns Ok(Response) with 500 status. User can use `error_for_status()`.
2041    #[tokio::test]
2042    async fn test_status_retry_exhausted_returns_ok_response() {
2043        use crate::config::{ExponentialBackoff, HttpClientConfig, RetryConfig};
2044
2045        let server = MockServer::start();
2046        let mock = server.mock(|when, then| {
2047            when.method(Method::GET).path("/always-500");
2048            then.status(500).body("always fails");
2049        });
2050
2051        let config = HttpClientConfig {
2052            transport: crate::config::TransportSecurity::AllowInsecureHttp,
2053            retry: Some(RetryConfig {
2054                max_retries: 2, // 1 initial + 2 retries = 3 total
2055                backoff: ExponentialBackoff::fast(),
2056                ..RetryConfig::default()
2057            }),
2058            rate_limit: None,
2059            ..Default::default()
2060        };
2061
2062        let client = HttpClientBuilder::with_config(config).build().unwrap();
2063        let url = format!("{}/always-500", server.base_url());
2064
2065        let result = client.get(&url).send().await;
2066
2067        // Should have tried 3 times (1 initial + 2 retries)
2068        assert_eq!(
2069            mock.calls(),
2070            3,
2071            "Expected 3 calls (1 initial + 2 retries), got {}",
2072            mock.calls()
2073        );
2074
2075        // After retries exhaust: returns Ok(Response) with 500 status
2076        let response = result.expect("send() should return Ok(Response) after retries exhaust");
2077        assert_eq!(response.status(), hyper::StatusCode::INTERNAL_SERVER_ERROR);
2078
2079        // User can convert to error via error_for_status()
2080        let err = response.error_for_status().unwrap_err();
2081        assert!(
2082            matches!(err, HttpError::HttpStatus { status, .. } if status == hyper::StatusCode::INTERNAL_SERVER_ERROR)
2083        );
2084    }
2085
2086    /// Test: Without retry config, retryable statuses pass through as Ok(Response).
2087    ///
2088    /// When retry is disabled (None), `StatusToErrorLayer` is not added.
2089    /// 500 returns Ok(Response), not Err(HttpStatus).
2090    #[tokio::test]
2091    async fn test_no_retry_config_status_passes_through() {
2092        use crate::config::HttpClientConfig;
2093
2094        let server = MockServer::start();
2095        let mock = server.mock(|when, then| {
2096            when.method(Method::GET).path("/no-retry");
2097            then.status(500).body("server error");
2098        });
2099
2100        let config = HttpClientConfig {
2101            transport: crate::config::TransportSecurity::AllowInsecureHttp,
2102            retry: None, // No retry - StatusToErrorLayer not added
2103            rate_limit: None,
2104            ..Default::default()
2105        };
2106
2107        let client = HttpClientBuilder::with_config(config).build().unwrap();
2108        let url = format!("{}/no-retry", server.base_url());
2109
2110        let result = client.get(&url).send().await;
2111
2112        // Only called once (no retry)
2113        assert_eq!(mock.calls(), 1);
2114
2115        // Response is Ok (500 passes through without StatusToErrorLayer)
2116        let response = result.expect("send() should succeed when retry disabled");
2117        assert_eq!(response.status(), hyper::StatusCode::INTERNAL_SERVER_ERROR);
2118
2119        // User can use error_for_status() to convert to error
2120        let err = response.error_for_status().unwrap_err();
2121        assert!(
2122            matches!(err, HttpError::HttpStatus { status, .. } if status == hyper::StatusCode::INTERNAL_SERVER_ERROR)
2123        );
2124    }
2125
2126    // ==========================================================================
2127    // URL Scheme Validation Tests
2128    // ==========================================================================
2129
2130    /// Test: http:// URL rejected when transport security is `TlsOnly`
2131    #[tokio::test]
2132    async fn test_url_scheme_http_rejected_with_tls_only() {
2133        let client = HttpClientBuilder::new()
2134            .transport(crate::config::TransportSecurity::TlsOnly)
2135            .retry(None)
2136            .build()
2137            .unwrap();
2138
2139        // Try to send a request to http:// URL
2140        let result = client.get("http://example.com/test").send().await;
2141
2142        // Should fail with InvalidScheme error
2143        match result {
2144            Err(HttpError::InvalidScheme { scheme, reason }) => {
2145                assert_eq!(scheme, "http");
2146                assert!(
2147                    reason.contains("TlsOnly"),
2148                    "Error should mention TlsOnly: {reason}"
2149                );
2150            }
2151            Err(other) => panic!("Expected InvalidScheme error, got: {other:?}"),
2152            Ok(_) => panic!("Expected InvalidScheme error, but request succeeded"),
2153        }
2154    }
2155
2156    /// Test: http:// URL allowed when transport security is `AllowInsecureHttp`
2157    #[tokio::test]
2158    async fn test_url_scheme_http_allowed_with_allow_insecure() {
2159        let server = MockServer::start();
2160        let _m = server.mock(|when, then| {
2161            when.method(Method::GET).path("/test");
2162            then.status(200).body("ok");
2163        });
2164
2165        let client = HttpClientBuilder::new()
2166            .transport(crate::config::TransportSecurity::AllowInsecureHttp)
2167            .retry(None)
2168            .build()
2169            .unwrap();
2170
2171        let url = format!("{}/test", server.base_url()); // http://127.0.0.1:xxxx
2172        let result = client.get(&url).send().await;
2173
2174        assert!(result.is_ok(), "http:// should be allowed: {result:?}");
2175    }
2176
2177    /// Test: https:// URL always allowed regardless of transport security
2178    #[tokio::test]
2179    async fn test_url_scheme_https_always_allowed() {
2180        // Note: We can't actually test HTTPS without a real server,
2181        // but we can verify the validation passes and fails later on connection
2182        let client = HttpClientBuilder::new()
2183            .transport(crate::config::TransportSecurity::TlsOnly)
2184            .retry(None)
2185            .build()
2186            .unwrap();
2187
2188        // The scheme validation should pass (not InvalidScheme)
2189        // but the actual connection will fail because example.com won't respond
2190        let result = client.get("https://localhost:0/test").send().await;
2191
2192        // Should NOT be InvalidScheme - should be a transport/connection error
2193        if let Err(HttpError::InvalidScheme { .. }) = result {
2194            panic!("https:// should not trigger InvalidScheme error")
2195        }
2196        // Any other error (transport, timeout, etc.) or Ok is expected
2197    }
2198
2199    /// Test: Invalid scheme (e.g., ftp://) rejected
2200    ///
2201    /// Note: transport security is left at preset default — `ftp://` is rejected
2202    /// independently of `TlsOnly`/`AllowInsecureHttp`, so the test runs identically
2203    /// under both `--features fips` (`TlsOnly` default) and without.
2204    #[tokio::test]
2205    async fn test_url_scheme_invalid_rejected() {
2206        let client = HttpClientBuilder::new().retry(None).build().unwrap();
2207
2208        let result = client.get("ftp://files.example.com/file.txt").send().await;
2209
2210        match result {
2211            Err(HttpError::InvalidScheme { scheme, reason }) => {
2212                assert_eq!(scheme, "ftp");
2213                assert!(
2214                    reason.contains("http://") || reason.contains("https://"),
2215                    "Error should mention supported schemes: {reason}"
2216                );
2217            }
2218            Err(other) => panic!("Expected InvalidScheme error, got: {other:?}"),
2219            Ok(_) => panic!("Expected InvalidScheme error, but request succeeded"),
2220        }
2221    }
2222
2223    /// Test: Missing scheme rejected (now returns `InvalidUri` with proper parsing)
2224    ///
2225    /// Note: transport security is left at preset default — a missing scheme is
2226    /// rejected independently of `TlsOnly`/`AllowInsecureHttp`, so the test runs
2227    /// identically under both `--features fips` (`TlsOnly` default) and without.
2228    #[tokio::test]
2229    async fn test_url_scheme_missing_rejected() {
2230        let client = HttpClientBuilder::new().retry(None).build().unwrap();
2231
2232        let result = client.get("example.com/test").send().await;
2233
2234        match result {
2235            Err(HttpError::InvalidUri { url, reason, kind }) => {
2236                // With proper URI parsing, this is an invalid URI (no scheme)
2237                assert_eq!(url, "example.com/test");
2238                assert!(!reason.is_empty(), "Should have a reason for invalid URI");
2239                assert_eq!(kind, crate::error::InvalidUriKind::ParseError);
2240            }
2241            Err(other) => panic!("Expected InvalidUri error, got: {other:?}"),
2242            Ok(_) => panic!("Expected InvalidUri error, but request succeeded"),
2243        }
2244    }
2245}