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