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    /// Test small buffer capacity with fail-fast behavior
649    ///
650    /// With fail-fast buffer semantics, some requests may fail with Overloaded
651    /// when buffer is full. This test verifies:
652    /// 1. No deadlock (all complete within timeout)
653    /// 2. At least some requests succeed
654    /// 3. Failed requests get Overloaded error (not other errors)
655    #[tokio::test]
656    async fn test_small_buffer_capacity_no_deadlock() {
657        use crate::config::HttpClientConfig;
658
659        let server = MockServer::start();
660        let _m = server.mock(|when, then| {
661            when.method(Method::GET).path("/test");
662            then.status(200).body("ok");
663        });
664
665        // Create client with very small buffer (capacity 2)
666        let config = HttpClientConfig {
667            transport: crate::config::TransportSecurity::AllowInsecureHttp,
668            retry: None,
669            rate_limit: None,
670            buffer_capacity: 2,
671            ..Default::default()
672        };
673
674        let client = HttpClientBuilder::with_config(config).build().unwrap();
675        let url = format!("{}/test", server.base_url());
676
677        // Fire 10 concurrent requests - some may fail with Overloaded (fail-fast)
678        let handles: Vec<_> = (0..10)
679            .map(|_| {
680                let client = client.clone();
681                let url = url.clone();
682                tokio::spawn(async move { client.get(&url).send().await })
683            })
684            .collect();
685
686        // All should complete (not hang) within timeout
687        let timeout_result = tokio::time::timeout(std::time::Duration::from_secs(10), async {
688            let mut results = Vec::new();
689            for handle in handles {
690                results.push(handle.await);
691            }
692            results
693        })
694        .await;
695
696        let results = timeout_result.expect("requests should complete within timeout");
697
698        let mut success_count = 0;
699        let mut overloaded_count = 0;
700        for result in results {
701            match result.unwrap() {
702                Ok(resp) => {
703                    assert_eq!(resp.status(), hyper::StatusCode::OK);
704                    success_count += 1;
705                }
706                Err(HttpError::Overloaded) => {
707                    overloaded_count += 1;
708                }
709                Err(e) => panic!("unexpected error: {e:?}"),
710            }
711        }
712
713        // At least some should succeed (buffer processes requests)
714        assert!(success_count > 0, "at least one request should succeed");
715        // Total should be 10
716        assert_eq!(success_count + overloaded_count, 10);
717    }
718
719    /// Test buffer overflow returns Overloaded error immediately (fail-fast)
720    ///
721    /// Verifies that when buffer is full and inner service is blocked,
722    /// new requests fail immediately with Overloaded instead of hanging.
723    #[tokio::test]
724    async fn test_buffer_overflow_returns_overloaded() {
725        use crate::config::HttpClientConfig;
726
727        let server = MockServer::start();
728
729        let _m = server.mock(|when, then| {
730            when.method(Method::GET).path("/slow");
731            then.status(200).body("ok");
732        });
733
734        // Create client with buffer capacity of 1
735        let config = HttpClientConfig {
736            transport: crate::config::TransportSecurity::AllowInsecureHttp,
737            retry: None,
738            rate_limit: None,
739            buffer_capacity: 1,
740            ..Default::default()
741        };
742
743        let client = HttpClientBuilder::with_config(config).build().unwrap();
744        let url = format!("{}/slow", server.base_url());
745
746        // First request - will occupy the single buffer slot
747        let client1 = client.clone();
748        let url1 = url.clone();
749        let handle1 = tokio::spawn(async move { client1.get(&url1).send().await });
750
751        // Give first request time to acquire buffer slot
752        tokio::time::sleep(std::time::Duration::from_millis(10)).await;
753
754        // Second request - should fail immediately with Overloaded (buffer full)
755        let result2 = tokio::time::timeout(
756            std::time::Duration::from_millis(50),
757            client.get(&url).send(),
758        )
759        .await;
760
761        // Should complete immediately (not timeout) with Overloaded
762        let inner_result = result2.expect("request should not timeout waiting for buffer");
763        match inner_result {
764            // Expected: buffer full (fail-fast) or request got through (timing dependent)
765            Err(HttpError::Overloaded) | Ok(_) => {}
766            Err(e) => panic!("unexpected error: {e:?}"),
767        }
768
769        // Let first request complete
770        _ = handle1.await;
771    }
772
773    /// Test that large body reading doesn't cause deadlock
774    #[tokio::test]
775    async fn test_large_body_no_deadlock() {
776        let server = MockServer::start();
777        let large_body = "x".repeat(100 * 1024); // 100KB
778        let _m = server.mock(|when, then| {
779            when.method(Method::GET).path("/large");
780            then.status(200).body(&large_body);
781        });
782
783        let client = HttpClientBuilder::new()
784            .retry(None)
785            .max_body_size(1024 * 1024) // 1MB limit
786            .build()
787            .unwrap();
788
789        let url = format!("{}/large", server.base_url());
790
791        // Fire multiple concurrent requests that read large bodies
792        let handles: Vec<_> = (0..5)
793            .map(|_| {
794                let client = client.clone();
795                let url = url.clone();
796                tokio::spawn(async move { client.get(&url).send().await?.checked_bytes().await })
797            })
798            .collect();
799
800        // All should complete
801        let timeout_result = tokio::time::timeout(std::time::Duration::from_secs(10), async {
802            let mut results = Vec::new();
803            for handle in handles {
804                results.push(handle.await);
805            }
806            results
807        })
808        .await;
809
810        let results = timeout_result.expect("body reads should complete within timeout");
811        for result in results {
812            let body = result.unwrap().unwrap();
813            assert_eq!(body.len(), 100 * 1024);
814        }
815    }
816
817    /// Test that `token_endpoint` config does NOT retry POST requests
818    ///
819    /// `OAuth2` token endpoints use POST, and we must not retry POST to avoid
820    /// duplicate token requests. This test verifies the retry config in
821    /// `HttpClientConfig::token_endpoint()` only retries GET.
822    #[tokio::test]
823    async fn test_token_endpoint_post_not_retried() {
824        use crate::config::HttpClientConfig;
825
826        let server = MockServer::start();
827
828        // Mock that always returns 500 (retriable error)
829        let mock = server.mock(|when, then| {
830            when.method(Method::POST).path("/token");
831            then.status(500).body("server error");
832        });
833
834        // Use token_endpoint config (retry enabled but only for GET)
835        let mut config = HttpClientConfig::token_endpoint();
836        config.transport = crate::config::TransportSecurity::AllowInsecureHttp; // Allow HTTP for test server
837
838        let client = HttpClientBuilder::with_config(config).build().unwrap();
839        let url = format!("{}/token", server.base_url());
840
841        // POST form to token endpoint
842        let result = client
843            .post(&url)
844            .form(&[("grant_type", "client_credentials"), ("client_id", "test")])
845            .unwrap()
846            .send()
847            .await;
848
849        // Request should fail (500 error)
850        assert!(result.is_ok()); // HTTP request succeeded
851        let response = result.unwrap();
852        assert_eq!(response.status(), hyper::StatusCode::INTERNAL_SERVER_ERROR);
853
854        // Verify mock was called exactly once (no retries)
855        // httpmock tracks calls internally
856        assert_eq!(
857            mock.calls(),
858            1,
859            "POST should not be retried; expected 1 call, got {}",
860            mock.calls()
861        );
862    }
863
864    // NOTE: GET retry behavior is tested at the layer level in
865    // `layers::tests::test_retry_layer_retries_transport_errors` which uses
866    // a mock service to simulate transport errors. HTTP status codes (like 500)
867    // don't trigger retries since they're returned as Ok(Response), not Err.
868
869    #[tokio::test]
870    async fn test_http_client_put() {
871        let server = MockServer::start();
872        let _m = server.mock(|when, then| {
873            when.method(Method::PUT).path("/resource");
874            then.status(200).json_body(json!({"updated": true}));
875        });
876
877        let client = test_client();
878        let url = format!("{}/resource", server.base_url());
879        let resp = client.put(&url).send().await.unwrap();
880
881        assert_eq!(resp.status(), hyper::StatusCode::OK);
882    }
883
884    #[tokio::test]
885    async fn test_http_client_put_form() {
886        let server = MockServer::start();
887        let _m = server.mock(|when, then| {
888            when.method(Method::PUT)
889                .path("/resource")
890                .header("content-type", "application/x-www-form-urlencoded")
891                .body("name=updated&value=123");
892            then.status(200).json_body(json!({"updated": true}));
893        });
894
895        let client = test_client();
896        let url = format!("{}/resource", server.base_url());
897
898        let resp = client
899            .put(&url)
900            .form(&[("name", "updated"), ("value", "123")])
901            .unwrap()
902            .send()
903            .await
904            .unwrap();
905        assert_eq!(resp.status(), hyper::StatusCode::OK);
906    }
907
908    #[tokio::test]
909    async fn test_http_client_patch() {
910        let server = MockServer::start();
911        let _m = server.mock(|when, then| {
912            when.method(Method::PATCH).path("/resource/1");
913            then.status(200).json_body(json!({"patched": true}));
914        });
915
916        let client = test_client();
917        let url = format!("{}/resource/1", server.base_url());
918        let resp = client.patch(&url).send().await.unwrap();
919
920        assert_eq!(resp.status(), hyper::StatusCode::OK);
921    }
922
923    #[tokio::test]
924    async fn test_http_client_patch_form() {
925        let server = MockServer::start();
926        let _m = server.mock(|when, then| {
927            when.method(Method::PATCH)
928                .path("/resource/1")
929                .header("content-type", "application/x-www-form-urlencoded")
930                .body("field=patched");
931            then.status(200).json_body(json!({"patched": true}));
932        });
933
934        let client = test_client();
935        let url = format!("{}/resource/1", server.base_url());
936
937        let resp = client
938            .patch(&url)
939            .form(&[("field", "patched")])
940            .unwrap()
941            .send()
942            .await
943            .unwrap();
944        assert_eq!(resp.status(), hyper::StatusCode::OK);
945    }
946
947    #[tokio::test]
948    async fn test_http_client_delete() {
949        let server = MockServer::start();
950        let _m = server.mock(|when, then| {
951            when.method(Method::DELETE).path("/resource/42");
952            then.status(204);
953        });
954
955        let client = test_client();
956        let url = format!("{}/resource/42", server.base_url());
957        let resp = client.delete(&url).send().await.unwrap();
958
959        assert_eq!(resp.status(), hyper::StatusCode::NO_CONTENT);
960    }
961
962    #[tokio::test]
963    async fn test_http_client_delete_returns_200() {
964        let server = MockServer::start();
965        let _m = server.mock(|when, then| {
966            when.method(Method::DELETE).path("/resource/99");
967            then.status(200).json_body(json!({"deleted": true}));
968        });
969
970        let client = test_client();
971        let url = format!("{}/resource/99", server.base_url());
972        let resp = client.delete(&url).send().await.unwrap();
973
974        assert_eq!(resp.status(), hyper::StatusCode::OK);
975    }
976
977    #[tokio::test]
978    async fn test_http_client_head_returns_headers_no_body() {
979        let server = MockServer::start();
980        let _m = server.mock(|when, then| {
981            when.method(Method::HEAD).path("/probe");
982            then.status(200)
983                .header("content-length", "12345")
984                .header("etag", "\"abc\"");
985        });
986
987        let client = test_client();
988        let url = format!("{}/probe", server.base_url());
989        let resp = client.head(&url).send().await.unwrap();
990
991        assert_eq!(resp.status(), hyper::StatusCode::OK);
992        assert_eq!(
993            resp.headers()
994                .get("content-length")
995                .and_then(|v| v.to_str().ok()),
996            Some("12345"),
997        );
998        let body = resp.bytes().await.unwrap();
999        assert!(body.is_empty(), "HEAD response body must be empty");
1000    }
1001
1002    #[tokio::test]
1003    async fn test_http_client_options_returns_allow_header() {
1004        let server = MockServer::start();
1005        let _m = server.mock(|when, then| {
1006            when.method(Method::OPTIONS).path("/resource");
1007            then.status(204).header("allow", "GET, POST, PUT, DELETE");
1008        });
1009
1010        let client = test_client();
1011        let url = format!("{}/resource", server.base_url());
1012        let resp = client.options(&url).send().await.unwrap();
1013
1014        assert_eq!(resp.status(), hyper::StatusCode::NO_CONTENT);
1015        assert_eq!(
1016            resp.headers().get("allow").and_then(|v| v.to_str().ok()),
1017            Some("GET, POST, PUT, DELETE"),
1018        );
1019    }
1020
1021    #[tokio::test]
1022    async fn test_put_form_with_custom_headers() {
1023        let server = MockServer::start();
1024        let _m = server.mock(|when, then| {
1025            when.method(Method::PUT)
1026                .path("/api/data")
1027                .header("content-type", "application/x-www-form-urlencoded")
1028                .header("x-custom-header", "custom-value")
1029                .body("key=value");
1030            then.status(200);
1031        });
1032
1033        let client = test_client();
1034        let url = format!("{}/api/data", server.base_url());
1035
1036        let resp = client
1037            .put(&url)
1038            .header("x-custom-header", "custom-value")
1039            .form(&[("key", "value")])
1040            .unwrap()
1041            .send()
1042            .await
1043            .unwrap();
1044        assert_eq!(resp.status(), hyper::StatusCode::OK);
1045    }
1046
1047    #[tokio::test]
1048    async fn test_patch_form_with_custom_headers() {
1049        let server = MockServer::start();
1050        let _m = server.mock(|when, then| {
1051            when.method(Method::PATCH)
1052                .path("/api/data")
1053                .header("content-type", "application/x-www-form-urlencoded")
1054                .header("authorization", "Bearer token123")
1055                .body("status=active");
1056            then.status(200);
1057        });
1058
1059        let client = test_client();
1060        let url = format!("{}/api/data", server.base_url());
1061
1062        let resp = client
1063            .patch(&url)
1064            .header("authorization", "Bearer token123")
1065            .form(&[("status", "active")])
1066            .unwrap()
1067            .send()
1068            .await
1069            .unwrap();
1070        assert_eq!(resp.status(), hyper::StatusCode::OK);
1071    }
1072
1073    #[tokio::test]
1074    async fn test_request_builder_json_body() {
1075        #[derive(serde::Serialize)]
1076        struct CreateUser {
1077            name: String,
1078            email: String,
1079        }
1080
1081        let server = MockServer::start();
1082        let _m = server.mock(|when, then| {
1083            when.method(Method::POST)
1084                .path("/users")
1085                .header("content-type", "application/json")
1086                .json_body(json!({"name": "Alice", "email": "alice@example.com"}));
1087            then.status(201).json_body(json!({"id": 1}));
1088        });
1089
1090        let client = test_client();
1091        let url = format!("{}/users", server.base_url());
1092
1093        let resp = client
1094            .post(&url)
1095            .json(&CreateUser {
1096                name: "Alice".into(),
1097                email: "alice@example.com".into(),
1098            })
1099            .unwrap()
1100            .send()
1101            .await
1102            .unwrap();
1103        assert_eq!(resp.status(), hyper::StatusCode::CREATED);
1104    }
1105
1106    #[tokio::test]
1107    async fn test_request_builder_body_bytes() {
1108        let server = MockServer::start();
1109        let _m = server.mock(|when, then| {
1110            when.method(Method::POST)
1111                .path("/upload")
1112                .body("raw binary data");
1113            then.status(200);
1114        });
1115
1116        let client = test_client();
1117        let url = format!("{}/upload", server.base_url());
1118
1119        let resp = client
1120            .post(&url)
1121            .body_bytes(bytes::Bytes::from("raw binary data"))
1122            .send()
1123            .await
1124            .unwrap();
1125        assert_eq!(resp.status(), hyper::StatusCode::OK);
1126    }
1127
1128    /// Test that user-provided Content-Type is not duplicated when using `json()`.
1129    ///
1130    /// When the user supplies a Content-Type header before calling `.json()`,
1131    /// the default `application/json` should NOT be added. The final request
1132    /// should have exactly one Content-Type header with the user's value.
1133    #[tokio::test]
1134    async fn test_content_type_not_duplicated_with_json() {
1135        #[derive(serde::Serialize)]
1136        struct TestData {
1137            value: i32,
1138        }
1139
1140        let server = MockServer::start();
1141        let mock = server.mock(|when, then| {
1142            when.method(Method::POST)
1143                .path("/custom-content-type")
1144                // Match the custom Content-Type (not application/json)
1145                .header("content-type", "application/vnd.custom+json");
1146            then.status(200);
1147        });
1148
1149        let client = test_client();
1150        let url = format!("{}/custom-content-type", server.base_url());
1151
1152        let resp = client
1153            .post(&url)
1154            .header("content-type", "application/vnd.custom+json") // Custom Content-Type
1155            .json(&TestData { value: 42 })
1156            .unwrap()
1157            .send()
1158            .await
1159            .unwrap();
1160
1161        assert_eq!(resp.status(), hyper::StatusCode::OK);
1162        assert_eq!(
1163            mock.calls(),
1164            1,
1165            "Request with custom Content-Type should match"
1166        );
1167    }
1168
1169    /// Test that user-provided Content-Type is not duplicated when using `form()`.
1170    #[tokio::test]
1171    async fn test_content_type_not_duplicated_with_form() {
1172        let server = MockServer::start();
1173        let mock = server.mock(|when, then| {
1174            when.method(Method::POST)
1175                .path("/custom-form-type")
1176                // Match the custom Content-Type (not application/x-www-form-urlencoded)
1177                .header("content-type", "application/x-custom-form");
1178            then.status(200);
1179        });
1180
1181        let client = test_client();
1182        let url = format!("{}/custom-form-type", server.base_url());
1183
1184        let resp = client
1185            .post(&url)
1186            .header("content-type", "application/x-custom-form") // Custom Content-Type
1187            .form(&[("key", "value")])
1188            .unwrap()
1189            .send()
1190            .await
1191            .unwrap();
1192
1193        assert_eq!(resp.status(), hyper::StatusCode::OK);
1194        assert_eq!(
1195            mock.calls(),
1196            1,
1197            "Request with custom Content-Type should match"
1198        );
1199    }
1200
1201    #[tokio::test]
1202    async fn test_request_builder_body_string() {
1203        let server = MockServer::start();
1204        let _m = server.mock(|when, then| {
1205            when.method(Method::POST)
1206                .path("/text")
1207                .body("Hello, World!");
1208            then.status(200);
1209        });
1210
1211        let client = test_client();
1212        let url = format!("{}/text", server.base_url());
1213
1214        let resp = client
1215            .post(&url)
1216            .body_string("Hello, World!".into())
1217            .send()
1218            .await
1219            .unwrap();
1220        assert_eq!(resp.status(), hyper::StatusCode::OK);
1221    }
1222
1223    #[tokio::test]
1224    async fn test_response_text_method() {
1225        let server = MockServer::start();
1226        let _m = server.mock(|when, then| {
1227            when.method(Method::GET).path("/text");
1228            then.status(200).body("Hello, World!");
1229        });
1230
1231        let client = test_client();
1232        let url = format!("{}/text", server.base_url());
1233
1234        let text = client.get(&url).send().await.unwrap().text().await.unwrap();
1235        assert_eq!(text, "Hello, World!");
1236    }
1237
1238    #[tokio::test]
1239    async fn test_request_builder_multiple_headers() {
1240        let server = MockServer::start();
1241        let _m = server.mock(|when, then| {
1242            when.method(Method::GET)
1243                .path("/headers")
1244                .header("x-first", "one")
1245                .header("x-second", "two");
1246            then.status(200);
1247        });
1248
1249        let client = test_client();
1250        let url = format!("{}/headers", server.base_url());
1251
1252        let resp = client
1253            .get(&url)
1254            .header("x-first", "one")
1255            .header("x-second", "two")
1256            .send()
1257            .await
1258            .unwrap();
1259        assert_eq!(resp.status(), hyper::StatusCode::OK);
1260    }
1261
1262    #[tokio::test]
1263    async fn test_request_builder_headers_vec() {
1264        let server = MockServer::start();
1265        let _m = server.mock(|when, then| {
1266            when.method(Method::GET)
1267                .path("/headers")
1268                .header("x-first", "one")
1269                .header("x-second", "two");
1270            then.status(200);
1271        });
1272
1273        let client = test_client();
1274        let url = format!("{}/headers", server.base_url());
1275
1276        let resp = client
1277            .get(&url)
1278            .headers(vec![
1279                ("x-first".to_owned(), "one".to_owned()),
1280                ("x-second".to_owned(), "two".to_owned()),
1281            ])
1282            .send()
1283            .await
1284            .unwrap();
1285        assert_eq!(resp.status(), hyper::StatusCode::OK);
1286    }
1287
1288    /// Test that `checked_bytes` returns `HttpStatus` error (not `BodyTooLarge`) when
1289    /// a non-2xx response has a body larger than the preview limit.
1290    #[tokio::test]
1291    async fn test_error_response_with_large_body_returns_http_status() {
1292        use crate::security::ERROR_BODY_PREVIEW_LIMIT;
1293
1294        let server = MockServer::start();
1295
1296        // Create a body larger than ERROR_BODY_PREVIEW_LIMIT (8KB)
1297        let large_body = "x".repeat(ERROR_BODY_PREVIEW_LIMIT + 1000);
1298
1299        let _m = server.mock(|when, then| {
1300            when.method(Method::GET).path("/error-with-large-body");
1301            then.status(500).body(&large_body);
1302        });
1303
1304        let client = test_client();
1305        let url = format!("{}/error-with-large-body", server.base_url());
1306
1307        let result = client.get(&url).send().await.unwrap().checked_bytes().await;
1308
1309        // Should return HttpStatus error, NOT BodyTooLarge
1310        match result {
1311            Err(HttpError::HttpStatus {
1312                status,
1313                body_preview,
1314                ..
1315            }) => {
1316                assert_eq!(status, hyper::StatusCode::INTERNAL_SERVER_ERROR);
1317                // Body preview should indicate it was too large
1318                assert_eq!(body_preview, "<body too large for preview>");
1319            }
1320            Err(HttpError::BodyTooLarge { .. }) => {
1321                panic!("Should return HttpStatus, not BodyTooLarge for non-2xx responses");
1322            }
1323            Err(other) => panic!("Unexpected error: {other:?}"),
1324            Ok(_) => panic!("Should have returned an error for 500 status"),
1325        }
1326    }
1327
1328    // ==========================================================================
1329    // Gzip/Br/Deflate Decompression Tests
1330    // ==========================================================================
1331
1332    /// Helper to gzip-compress data
1333    fn gzip_compress(data: &[u8]) -> Vec<u8> {
1334        use flate2::Compression;
1335        use flate2::write::GzEncoder;
1336        use std::io::Write;
1337
1338        let mut encoder = GzEncoder::new(Vec::new(), Compression::default());
1339        encoder.write_all(data).unwrap();
1340        encoder.finish().unwrap()
1341    }
1342
1343    /// Test that gzip-encoded response is automatically decompressed.
1344    ///
1345    /// Server returns a gzip-compressed body with `Content-Encoding: gzip`.
1346    /// Client should automatically decompress and return the original bytes.
1347    #[tokio::test]
1348    async fn test_gzip_decompression_basic() {
1349        let server = MockServer::start();
1350
1351        let original_body = b"Hello, this is a test body that will be gzip compressed!";
1352        let compressed_body = gzip_compress(original_body);
1353
1354        let _m = server.mock(|when, then| {
1355            when.method(Method::GET).path("/gzip");
1356            then.status(200)
1357                .header("content-encoding", "gzip")
1358                .body(compressed_body);
1359        });
1360
1361        let client = test_client();
1362        let url = format!("{}/gzip", server.base_url());
1363
1364        let body = client
1365            .get(&url)
1366            .send()
1367            .await
1368            .unwrap()
1369            .bytes()
1370            .await
1371            .unwrap();
1372
1373        assert_eq!(
1374            body.as_ref(),
1375            original_body,
1376            "Decompressed body should match original"
1377        );
1378    }
1379
1380    /// Test that gzip-compressed JSON can be parsed via `response.json()`.
1381    ///
1382    /// Server returns gzipped JSON with `Content-Encoding: gzip`.
1383    /// Client should decompress and successfully parse the JSON.
1384    #[tokio::test]
1385    async fn test_gzip_decompression_json() {
1386        #[derive(serde::Deserialize, PartialEq, Debug)]
1387        struct TestData {
1388            name: String,
1389            value: i32,
1390            nested: NestedData,
1391        }
1392
1393        #[derive(serde::Deserialize, PartialEq, Debug)]
1394        struct NestedData {
1395            items: Vec<String>,
1396        }
1397
1398        let server = MockServer::start();
1399
1400        let json_body = r#"{"name":"test","value":42,"nested":{"items":["a","b","c"]}}"#;
1401        let compressed_body = gzip_compress(json_body.as_bytes());
1402
1403        let _m = server.mock(|when, then| {
1404            when.method(Method::GET).path("/gzip-json");
1405            then.status(200)
1406                .header("content-type", "application/json")
1407                .header("content-encoding", "gzip")
1408                .body(compressed_body);
1409        });
1410
1411        let client = test_client();
1412        let url = format!("{}/gzip-json", server.base_url());
1413
1414        let data: TestData = client.get(&url).send().await.unwrap().json().await.unwrap();
1415
1416        assert_eq!(data.name, "test");
1417        assert_eq!(data.value, 42);
1418        assert_eq!(data.nested.items, vec!["a", "b", "c"]);
1419    }
1420
1421    /// Test that body size limit is enforced on DECOMPRESSED bytes, not compressed.
1422    ///
1423    /// This protects against "zip bombs" where a small compressed payload
1424    /// expands to a huge decompressed size.
1425    ///
1426    /// The test creates a highly compressible payload (repeated 'x' chars)
1427    /// that compresses small but expands beyond the `max_body_size` limit.
1428    #[tokio::test]
1429    async fn test_gzip_decompression_body_size_limit() {
1430        let server = MockServer::start();
1431
1432        // Create a body that compresses well but is large when decompressed.
1433        // 100KB of repeated 'x' compresses to a few hundred bytes.
1434        let large_decompressed = vec![b'x'; 100 * 1024]; // 100KB
1435        let compressed_body = gzip_compress(&large_decompressed);
1436
1437        // Verify compression is significant (sanity check)
1438        assert!(
1439            compressed_body.len() < 2000,
1440            "Compressed body should be small (got {} bytes)",
1441            compressed_body.len()
1442        );
1443
1444        let _m = server.mock(|when, then| {
1445            when.method(Method::GET).path("/gzip-bomb");
1446            then.status(200)
1447                .header("content-encoding", "gzip")
1448                .body(compressed_body);
1449        });
1450
1451        // Create client with 10KB body limit - smaller than decompressed size
1452        let client = HttpClientBuilder::new()
1453            .retry(None)
1454            .max_body_size(10 * 1024) // 10KB limit
1455            .build()
1456            .unwrap();
1457
1458        let url = format!("{}/gzip-bomb", server.base_url());
1459        let result = client.get(&url).send().await.unwrap().bytes().await;
1460
1461        // Should fail with BodyTooLarge because decompressed size exceeds limit
1462        match result {
1463            Err(HttpError::BodyTooLarge { limit, actual }) => {
1464                assert_eq!(limit, 10 * 1024, "Limit should be 10KB");
1465                assert!(
1466                    actual > limit,
1467                    "Actual size ({actual}) should exceed limit ({limit})"
1468                );
1469            }
1470            Err(other) => panic!("Expected BodyTooLarge error, got: {other:?}"),
1471            Ok(body) => panic!(
1472                "Expected BodyTooLarge error, but got {} bytes of body",
1473                body.len()
1474            ),
1475        }
1476    }
1477
1478    /// Test that Accept-Encoding header is automatically set by the client.
1479    ///
1480    /// The `DecompressionLayer` automatically adds `Accept-Encoding: gzip, br, deflate`
1481    /// to outgoing requests.
1482    #[tokio::test]
1483    async fn test_accept_encoding_header_sent() {
1484        let server = MockServer::start();
1485
1486        // Mock that requires Accept-Encoding header to be present
1487        let mock = server.mock(|when, then| {
1488            when.method(Method::GET)
1489                .path("/check-accept-encoding")
1490                .header_exists("accept-encoding");
1491            then.status(200).body("ok");
1492        });
1493
1494        let client = test_client();
1495        let url = format!("{}/check-accept-encoding", server.base_url());
1496
1497        let resp = client.get(&url).send().await.unwrap();
1498        assert_eq!(resp.status(), hyper::StatusCode::OK);
1499
1500        // Verify the mock was hit (meaning Accept-Encoding was present)
1501        assert_eq!(
1502            mock.calls(),
1503            1,
1504            "Request should have included Accept-Encoding header"
1505        );
1506    }
1507
1508    /// Test that non-compressed responses still work normally.
1509    ///
1510    /// When server doesn't return Content-Encoding, the body should pass through unchanged.
1511    #[tokio::test]
1512    async fn test_no_compression_passthrough() {
1513        let server = MockServer::start();
1514
1515        let plain_body = b"This is plain text, not compressed";
1516
1517        let _m = server.mock(|when, then| {
1518            when.method(Method::GET).path("/plain");
1519            then.status(200)
1520                .header("content-type", "text/plain")
1521                .body(plain_body.as_slice());
1522        });
1523
1524        let client = test_client();
1525        let url = format!("{}/plain", server.base_url());
1526
1527        let body = client
1528            .get(&url)
1529            .send()
1530            .await
1531            .unwrap()
1532            .bytes()
1533            .await
1534            .unwrap();
1535
1536        assert_eq!(
1537            body.as_ref(),
1538            plain_body,
1539            "Plain body should pass through unchanged"
1540        );
1541    }
1542
1543    /// Test that `checked_bytes` works correctly with gzip decompression.
1544    #[tokio::test]
1545    async fn test_gzip_decompression_checked_bytes() {
1546        let server = MockServer::start();
1547
1548        let original_body = b"Checked bytes test with gzip";
1549        let compressed_body = gzip_compress(original_body);
1550
1551        let _m = server.mock(|when, then| {
1552            when.method(Method::GET).path("/gzip-checked");
1553            then.status(200)
1554                .header("content-encoding", "gzip")
1555                .body(compressed_body);
1556        });
1557
1558        let client = test_client();
1559        let url = format!("{}/gzip-checked", server.base_url());
1560
1561        let body = client
1562            .get(&url)
1563            .send()
1564            .await
1565            .unwrap()
1566            .checked_bytes()
1567            .await
1568            .unwrap();
1569
1570        assert_eq!(
1571            body.as_ref(),
1572            original_body,
1573            "checked_bytes should return decompressed content"
1574        );
1575    }
1576
1577    /// Test that `text()` method works correctly with gzip decompression.
1578    #[tokio::test]
1579    async fn test_gzip_decompression_text() {
1580        let server = MockServer::start();
1581
1582        let original_text = "Hello, World! \u{1F600}"; // Contains emoji
1583        let compressed_body = gzip_compress(original_text.as_bytes());
1584
1585        let _m = server.mock(|when, then| {
1586            when.method(Method::GET).path("/gzip-text");
1587            then.status(200)
1588                .header("content-type", "text/plain; charset=utf-8")
1589                .header("content-encoding", "gzip")
1590                .body(compressed_body);
1591        });
1592
1593        let client = test_client();
1594        let url = format!("{}/gzip-text", server.base_url());
1595
1596        let text = client.get(&url).send().await.unwrap().text().await.unwrap();
1597
1598        assert_eq!(
1599            text, original_text,
1600            "text() should return decompressed UTF-8 content"
1601        );
1602    }
1603
1604    // ==========================================================================
1605    // Buffer Error Mapping Tests
1606    // ==========================================================================
1607
1608    /// Test that `map_buffer_error` returns inner `HttpError` when present.
1609    #[test]
1610    fn test_map_buffer_error_passes_through_http_error() {
1611        let http_err = HttpError::Timeout(std::time::Duration::from_secs(10));
1612        let boxed: tower::BoxError = Box::new(http_err);
1613        let result = map_buffer_error(boxed);
1614
1615        assert!(
1616            matches!(result, HttpError::Timeout(_)),
1617            "Should pass through HttpError::Timeout, got: {result:?}"
1618        );
1619    }
1620
1621    /// Test that `map_buffer_error` returns `ServiceClosed` for non-HttpError.
1622    ///
1623    /// This covers the case where buffer is closed or worker panicked.
1624    /// The error log is emitted (verified by code coverage, not assertion).
1625    #[test]
1626    fn test_map_buffer_error_returns_service_closed_for_unknown_error() {
1627        // Simulate a buffer closed error (any non-HttpError box)
1628        let other_err: tower::BoxError = Box::new(std::io::Error::new(
1629            std::io::ErrorKind::BrokenPipe,
1630            "buffer worker died",
1631        ));
1632        let result = map_buffer_error(other_err);
1633
1634        assert!(
1635            matches!(result, HttpError::ServiceClosed),
1636            "Should return ServiceClosed for non-HttpError, got: {result:?}"
1637        );
1638    }
1639
1640    // ==========================================================================
1641    // Status-based Retry Integration Tests
1642    //
1643    // These tests verify that retry-on-status works END-TO-END with real HTTP
1644    // responses (not just mock services that return Err directly).
1645    //
1646    // Key insight: hyper returns Ok(Response) for all HTTP statuses.
1647    // RetryLayer handles retries on Ok(Response) by checking status codes,
1648    // then returns Ok(Response) with the final status after retries exhaust.
1649    // send() NEVER returns Err(HttpStatus) - that's only created by error_for_status().
1650    // ==========================================================================
1651
1652    /// Test: GET request with 500 errors is retried.
1653    ///
1654    /// Server always returns 500. Asserts total calls == `max_retries` + 1.
1655    /// After retries exhaust, returns Ok(Response) with 500 status.
1656    #[tokio::test]
1657    async fn test_status_retry_get_500_retried() {
1658        use crate::config::{ExponentialBackoff, HttpClientConfig, RetryConfig};
1659
1660        let server = MockServer::start();
1661        let mock = server.mock(|when, then| {
1662            when.method(Method::GET).path("/retry-500");
1663            then.status(500).body("server error");
1664        });
1665
1666        let config = HttpClientConfig {
1667            transport: crate::config::TransportSecurity::AllowInsecureHttp,
1668            retry: Some(RetryConfig {
1669                max_retries: 2, // 1 initial + 2 retries = 3 total attempts
1670                backoff: ExponentialBackoff::fast(),
1671                ..RetryConfig::default() // 500 is in idempotent_retry
1672            }),
1673            rate_limit: None,
1674            ..Default::default()
1675        };
1676
1677        let client = HttpClientBuilder::with_config(config).build().unwrap();
1678        let url = format!("{}/retry-500", server.base_url());
1679
1680        let result = client.get(&url).send().await;
1681
1682        // GET on 500 SHOULD be retried (GET is idempotent, 500 is in idempotent_retry)
1683        assert_eq!(
1684            mock.calls(),
1685            3,
1686            "GET should retry on 500; expected 3 calls (1 + 2 retries), got {}",
1687            mock.calls()
1688        );
1689
1690        // After retries exhaust: returns Ok(Response) with 500 status
1691        let response = result.expect("send() should return Ok(Response) after retries exhaust");
1692        assert_eq!(response.status(), hyper::StatusCode::INTERNAL_SERVER_ERROR);
1693
1694        // User can convert to error via error_for_status()
1695        let err = response.error_for_status().unwrap_err();
1696        assert!(
1697            matches!(err, HttpError::HttpStatus { status, .. } if status == hyper::StatusCode::INTERNAL_SERVER_ERROR)
1698        );
1699    }
1700
1701    /// Test: POST request with 500 is NOT retried and returns Ok(Response).
1702    ///
1703    /// With default retry config, 500 is only retried for idempotent methods.
1704    /// POST is not idempotent, so:
1705    /// 1. No retry (calls == 1)
1706    /// 2. Returns Ok(Response) with status 500 (not converted to Err)
1707    ///
1708    /// User can use `.error_for_status()` or `.json()` to handle the error.
1709    #[tokio::test]
1710    async fn test_status_retry_post_500_not_retried() {
1711        use crate::config::{ExponentialBackoff, HttpClientConfig, RetryConfig};
1712
1713        let server = MockServer::start();
1714        let mock = server.mock(|when, then| {
1715            when.method(Method::POST).path("/post-500");
1716            then.status(500).body("server error");
1717        });
1718
1719        let config = HttpClientConfig {
1720            transport: crate::config::TransportSecurity::AllowInsecureHttp,
1721            retry: Some(RetryConfig {
1722                max_retries: 3,
1723                backoff: ExponentialBackoff::fast(),
1724                ..RetryConfig::default() // 500 is in idempotent_retry, not always_retry
1725            }),
1726            rate_limit: None,
1727            ..Default::default()
1728        };
1729
1730        let client = HttpClientBuilder::with_config(config).build().unwrap();
1731        let url = format!("{}/post-500", server.base_url());
1732
1733        let result = client.post(&url).send().await;
1734
1735        // POST on 500 should NOT be retried (only idempotent methods)
1736        assert_eq!(
1737            mock.calls(),
1738            1,
1739            "POST should not be retried on 500; expected 1 call, got {}",
1740            mock.calls()
1741        );
1742
1743        // Result should be Ok(Response) with status 500 - NOT converted to error
1744        // because 500 is not retryable for non-idempotent methods
1745        let response = result.expect("POST + 500 should return Ok(Response), not Err");
1746        assert_eq!(
1747            response.status(),
1748            hyper::StatusCode::INTERNAL_SERVER_ERROR,
1749            "Response should have 500 status"
1750        );
1751
1752        // User can still use error_for_status() to convert to error if needed
1753    }
1754
1755    /// Test: POST request with 429 IS retried (`always_retry` policy).
1756    ///
1757    /// 429 (Too Many Requests) is in `always_retry` set, so it's retried
1758    /// regardless of HTTP method. Asserts calls == `max_retries` + 1.
1759    /// After retries exhaust, returns Ok(Response) with 429 status.
1760    #[tokio::test]
1761    async fn test_status_retry_post_429_retried() {
1762        use crate::config::{ExponentialBackoff, HttpClientConfig, RetryConfig};
1763
1764        let server = MockServer::start();
1765        let mock = server.mock(|when, then| {
1766            when.method(Method::POST).path("/post-429");
1767            then.status(429).body("rate limited");
1768        });
1769
1770        let config = HttpClientConfig {
1771            transport: crate::config::TransportSecurity::AllowInsecureHttp,
1772            retry: Some(RetryConfig {
1773                max_retries: 2, // 1 initial + 2 retries = 3 total
1774                backoff: ExponentialBackoff::fast(),
1775                ..RetryConfig::default() // 429 is in always_retry
1776            }),
1777            rate_limit: None,
1778            ..Default::default()
1779        };
1780
1781        let client = HttpClientBuilder::with_config(config).build().unwrap();
1782        let url = format!("{}/post-429", server.base_url());
1783
1784        let result = client.post(&url).send().await;
1785
1786        // POST on 429 SHOULD be retried (429 is in always_retry)
1787        assert_eq!(
1788            mock.calls(),
1789            3,
1790            "POST should retry on 429; expected 3 calls (1 + 2 retries), got {}",
1791            mock.calls()
1792        );
1793
1794        // After retries exhaust: returns Ok(Response) with 429 status
1795        let response = result.expect("send() should return Ok(Response) after retries exhaust");
1796        assert_eq!(response.status(), hyper::StatusCode::TOO_MANY_REQUESTS);
1797
1798        // User can convert to error via error_for_status()
1799        let err = response.error_for_status().unwrap_err();
1800        assert!(
1801            matches!(err, HttpError::HttpStatus { status, .. } if status == hyper::StatusCode::TOO_MANY_REQUESTS)
1802        );
1803    }
1804
1805    /// Test: Retry-After header is preserved and accessible via `error_for_status()`.
1806    ///
1807    /// Server returns 429 with `Retry-After: 60`. `send()` returns Ok(Response).
1808    /// User calls `error_for_status()` which parses Retry-After from headers.
1809    #[tokio::test]
1810    async fn test_status_retry_extracts_retry_after_header() {
1811        use crate::config::{ExponentialBackoff, HttpClientConfig, RetryConfig};
1812
1813        let server = MockServer::start();
1814        let _mock = server.mock(|when, then| {
1815            when.method(Method::GET).path("/retry-after");
1816            then.status(429)
1817                .header("Retry-After", "60")
1818                .header("Content-Type", "application/json")
1819                .body(r#"{"error": "rate limited"}"#);
1820        });
1821
1822        let config = HttpClientConfig {
1823            transport: crate::config::TransportSecurity::AllowInsecureHttp,
1824            retry: Some(RetryConfig {
1825                max_retries: 0, // No retries - we want to see the response immediately
1826                backoff: ExponentialBackoff::fast(),
1827                ..RetryConfig::default()
1828            }),
1829            rate_limit: None,
1830            ..Default::default()
1831        };
1832
1833        let client = HttpClientBuilder::with_config(config).build().unwrap();
1834        let url = format!("{}/retry-after", server.base_url());
1835
1836        let result = client.get(&url).send().await;
1837
1838        // send() returns Ok(Response) - status codes don't become Err
1839        let response = result.expect("send() should return Ok(Response)");
1840        assert_eq!(response.status(), hyper::StatusCode::TOO_MANY_REQUESTS);
1841
1842        // error_for_status() extracts Retry-After and Content-Type
1843        match response.error_for_status() {
1844            Err(HttpError::HttpStatus {
1845                status,
1846                retry_after,
1847                content_type,
1848                ..
1849            }) => {
1850                assert_eq!(status, hyper::StatusCode::TOO_MANY_REQUESTS);
1851                assert_eq!(
1852                    retry_after,
1853                    Some(std::time::Duration::from_mins(1)),
1854                    "Should extract Retry-After header"
1855                );
1856                assert_eq!(
1857                    content_type,
1858                    Some("application/json".to_owned()),
1859                    "Should extract Content-Type header"
1860                );
1861            }
1862            other => panic!("Expected HttpStatus error from error_for_status(), got: {other:?}"),
1863        }
1864    }
1865
1866    // NOTE: test_status_retry_honors_retry_after_timing was removed because it relied
1867    // on seconds-scale elapsed time assertions which are flaky in CI environments.
1868    // The Retry-After header parsing and usage is tested at the unit level in:
1869    // - response::tests::test_parse_retry_after_*
1870    // - layers::tests::test_retry_layer_uses_retry_after_header (50ms, fast)
1871    // - test_status_retry_extracts_retry_after_header (verifies field extraction)
1872
1873    /// Test: Retry delay ignores Retry-After when `ignore_retry_after=true`.
1874    ///
1875    /// Server always returns 429 with `Retry-After: 10`. Config has fast backoff.
1876    /// Elapsed time should be fast (< 1s), not ~20s (2 * 10s).
1877    #[tokio::test]
1878    async fn test_status_retry_ignores_retry_after_when_configured() {
1879        use crate::config::{ExponentialBackoff, HttpClientConfig, RetryConfig};
1880
1881        let server = MockServer::start();
1882        let mock = server.mock(|when, then| {
1883            when.method(Method::GET).path("/ignore-retry-after");
1884            then.status(429)
1885                .header("Retry-After", "10") // 10 seconds
1886                .body("rate limited");
1887        });
1888
1889        let config = HttpClientConfig {
1890            transport: crate::config::TransportSecurity::AllowInsecureHttp,
1891            retry: Some(RetryConfig {
1892                max_retries: 2,
1893                backoff: ExponentialBackoff::fast(), // 1ms initial
1894                ignore_retry_after: true,            // Ignore Retry-After header
1895                ..RetryConfig::default()
1896            }),
1897            rate_limit: None,
1898            ..Default::default()
1899        };
1900
1901        let client = HttpClientBuilder::with_config(config).build().unwrap();
1902        let url = format!("{}/ignore-retry-after", server.base_url());
1903
1904        let start = std::time::Instant::now();
1905        let _result = client.get(&url).send().await;
1906        let elapsed = start.elapsed();
1907
1908        // With ignore_retry_after=true and fast backoff, should be very fast
1909        // NOT 2 * 10s = 20s from Retry-After
1910        assert!(
1911            elapsed < std::time::Duration::from_secs(2),
1912            "Should have used fast backoff, not 10s Retry-After; elapsed: {elapsed:?}"
1913        );
1914
1915        // Verify we made 3 calls (1 initial + 2 retries)
1916        assert_eq!(mock.calls(), 3, "Expected 3 calls, got {}", mock.calls());
1917    }
1918
1919    /// Test: Non-retryable status (404) is NOT converted to error by `StatusToErrorLayer`.
1920    ///
1921    /// 404 is not in retry triggers, so it passes through as Ok(Response).
1922    /// User can still use `.error_for_status()` or `.json()` to check.
1923    #[tokio::test]
1924    async fn test_non_retryable_status_passes_through() {
1925        use crate::config::{ExponentialBackoff, HttpClientConfig, RetryConfig};
1926
1927        let server = MockServer::start();
1928        let mock = server.mock(|when, then| {
1929            when.method(Method::GET).path("/not-found");
1930            then.status(404)
1931                .header("content-type", "application/json")
1932                .body(r#"{"error": "not found"}"#);
1933        });
1934
1935        let config = HttpClientConfig {
1936            transport: crate::config::TransportSecurity::AllowInsecureHttp,
1937            retry: Some(RetryConfig {
1938                max_retries: 3,
1939                backoff: ExponentialBackoff::fast(),
1940                ..RetryConfig::default()
1941            }),
1942            rate_limit: None,
1943            ..Default::default()
1944        };
1945
1946        let client = HttpClientBuilder::with_config(config).build().unwrap();
1947        let url = format!("{}/not-found", server.base_url());
1948
1949        // send() should succeed (404 is not a retryable error)
1950        let result = client.get(&url).send().await;
1951
1952        // Only called once - no retry
1953        assert_eq!(
1954            mock.calls(),
1955            1,
1956            "404 should not trigger retry; expected 1 call, got {}",
1957            mock.calls()
1958        );
1959
1960        // Response is Ok, but status is 404
1961        let response = result.expect("send() should succeed for 404");
1962        assert_eq!(response.status(), hyper::StatusCode::NOT_FOUND);
1963
1964        // User can check status manually if needed via error_for_status
1965    }
1966
1967    /// Test: Multiple retries exhausted returns Ok(Response) with final status.
1968    ///
1969    /// Server always returns 500. After `max_retries` (2) + initial = 3 attempts,
1970    /// returns Ok(Response) with 500 status. User can use `error_for_status()`.
1971    #[tokio::test]
1972    async fn test_status_retry_exhausted_returns_ok_response() {
1973        use crate::config::{ExponentialBackoff, HttpClientConfig, RetryConfig};
1974
1975        let server = MockServer::start();
1976        let mock = server.mock(|when, then| {
1977            when.method(Method::GET).path("/always-500");
1978            then.status(500).body("always fails");
1979        });
1980
1981        let config = HttpClientConfig {
1982            transport: crate::config::TransportSecurity::AllowInsecureHttp,
1983            retry: Some(RetryConfig {
1984                max_retries: 2, // 1 initial + 2 retries = 3 total
1985                backoff: ExponentialBackoff::fast(),
1986                ..RetryConfig::default()
1987            }),
1988            rate_limit: None,
1989            ..Default::default()
1990        };
1991
1992        let client = HttpClientBuilder::with_config(config).build().unwrap();
1993        let url = format!("{}/always-500", server.base_url());
1994
1995        let result = client.get(&url).send().await;
1996
1997        // Should have tried 3 times (1 initial + 2 retries)
1998        assert_eq!(
1999            mock.calls(),
2000            3,
2001            "Expected 3 calls (1 initial + 2 retries), got {}",
2002            mock.calls()
2003        );
2004
2005        // After retries exhaust: returns Ok(Response) with 500 status
2006        let response = result.expect("send() should return Ok(Response) after retries exhaust");
2007        assert_eq!(response.status(), hyper::StatusCode::INTERNAL_SERVER_ERROR);
2008
2009        // User can convert to error via error_for_status()
2010        let err = response.error_for_status().unwrap_err();
2011        assert!(
2012            matches!(err, HttpError::HttpStatus { status, .. } if status == hyper::StatusCode::INTERNAL_SERVER_ERROR)
2013        );
2014    }
2015
2016    /// Test: Without retry config, retryable statuses pass through as Ok(Response).
2017    ///
2018    /// When retry is disabled (None), `StatusToErrorLayer` is not added.
2019    /// 500 returns Ok(Response), not Err(HttpStatus).
2020    #[tokio::test]
2021    async fn test_no_retry_config_status_passes_through() {
2022        use crate::config::HttpClientConfig;
2023
2024        let server = MockServer::start();
2025        let mock = server.mock(|when, then| {
2026            when.method(Method::GET).path("/no-retry");
2027            then.status(500).body("server error");
2028        });
2029
2030        let config = HttpClientConfig {
2031            transport: crate::config::TransportSecurity::AllowInsecureHttp,
2032            retry: None, // No retry - StatusToErrorLayer not added
2033            rate_limit: None,
2034            ..Default::default()
2035        };
2036
2037        let client = HttpClientBuilder::with_config(config).build().unwrap();
2038        let url = format!("{}/no-retry", server.base_url());
2039
2040        let result = client.get(&url).send().await;
2041
2042        // Only called once (no retry)
2043        assert_eq!(mock.calls(), 1);
2044
2045        // Response is Ok (500 passes through without StatusToErrorLayer)
2046        let response = result.expect("send() should succeed when retry disabled");
2047        assert_eq!(response.status(), hyper::StatusCode::INTERNAL_SERVER_ERROR);
2048
2049        // User can use error_for_status() to convert to error
2050        let err = response.error_for_status().unwrap_err();
2051        assert!(
2052            matches!(err, HttpError::HttpStatus { status, .. } if status == hyper::StatusCode::INTERNAL_SERVER_ERROR)
2053        );
2054    }
2055
2056    // ==========================================================================
2057    // URL Scheme Validation Tests
2058    // ==========================================================================
2059
2060    /// Test: http:// URL rejected when transport security is `TlsOnly`
2061    #[tokio::test]
2062    async fn test_url_scheme_http_rejected_with_tls_only() {
2063        let client = HttpClientBuilder::new()
2064            .transport(crate::config::TransportSecurity::TlsOnly)
2065            .retry(None)
2066            .build()
2067            .unwrap();
2068
2069        // Try to send a request to http:// URL
2070        let result = client.get("http://example.com/test").send().await;
2071
2072        // Should fail with InvalidScheme error
2073        match result {
2074            Err(HttpError::InvalidScheme { scheme, reason }) => {
2075                assert_eq!(scheme, "http");
2076                assert!(
2077                    reason.contains("TlsOnly"),
2078                    "Error should mention TlsOnly: {reason}"
2079                );
2080            }
2081            Err(other) => panic!("Expected InvalidScheme error, got: {other:?}"),
2082            Ok(_) => panic!("Expected InvalidScheme error, but request succeeded"),
2083        }
2084    }
2085
2086    /// Test: http:// URL allowed when transport security is `AllowInsecureHttp`
2087    #[tokio::test]
2088    async fn test_url_scheme_http_allowed_with_allow_insecure() {
2089        let server = MockServer::start();
2090        let _m = server.mock(|when, then| {
2091            when.method(Method::GET).path("/test");
2092            then.status(200).body("ok");
2093        });
2094
2095        let client = HttpClientBuilder::new()
2096            .transport(crate::config::TransportSecurity::AllowInsecureHttp)
2097            .retry(None)
2098            .build()
2099            .unwrap();
2100
2101        let url = format!("{}/test", server.base_url()); // http://127.0.0.1:xxxx
2102        let result = client.get(&url).send().await;
2103
2104        assert!(result.is_ok(), "http:// should be allowed: {result:?}");
2105    }
2106
2107    /// Test: https:// URL always allowed regardless of transport security
2108    #[tokio::test]
2109    async fn test_url_scheme_https_always_allowed() {
2110        // Note: We can't actually test HTTPS without a real server,
2111        // but we can verify the validation passes and fails later on connection
2112        let client = HttpClientBuilder::new()
2113            .transport(crate::config::TransportSecurity::TlsOnly)
2114            .retry(None)
2115            .build()
2116            .unwrap();
2117
2118        // The scheme validation should pass (not InvalidScheme)
2119        // but the actual connection will fail because example.com won't respond
2120        let result = client.get("https://localhost:0/test").send().await;
2121
2122        // Should NOT be InvalidScheme - should be a transport/connection error
2123        if let Err(HttpError::InvalidScheme { .. }) = result {
2124            panic!("https:// should not trigger InvalidScheme error")
2125        }
2126        // Any other error (transport, timeout, etc.) or Ok is expected
2127    }
2128
2129    /// Test: Invalid scheme (e.g., ftp://) rejected
2130    ///
2131    /// Note: transport security is left at preset default — `ftp://` is rejected
2132    /// independently of `TlsOnly`/`AllowInsecureHttp`, so the test runs identically
2133    /// under both `--features fips` (`TlsOnly` default) and without.
2134    #[tokio::test]
2135    async fn test_url_scheme_invalid_rejected() {
2136        let client = HttpClientBuilder::new().retry(None).build().unwrap();
2137
2138        let result = client.get("ftp://files.example.com/file.txt").send().await;
2139
2140        match result {
2141            Err(HttpError::InvalidScheme { scheme, reason }) => {
2142                assert_eq!(scheme, "ftp");
2143                assert!(
2144                    reason.contains("http://") || reason.contains("https://"),
2145                    "Error should mention supported schemes: {reason}"
2146                );
2147            }
2148            Err(other) => panic!("Expected InvalidScheme error, got: {other:?}"),
2149            Ok(_) => panic!("Expected InvalidScheme error, but request succeeded"),
2150        }
2151    }
2152
2153    /// Test: Missing scheme rejected (now returns `InvalidUri` with proper parsing)
2154    ///
2155    /// Note: transport security is left at preset default — a missing scheme is
2156    /// rejected independently of `TlsOnly`/`AllowInsecureHttp`, so the test runs
2157    /// identically under both `--features fips` (`TlsOnly` default) and without.
2158    #[tokio::test]
2159    async fn test_url_scheme_missing_rejected() {
2160        let client = HttpClientBuilder::new().retry(None).build().unwrap();
2161
2162        let result = client.get("example.com/test").send().await;
2163
2164        match result {
2165            Err(HttpError::InvalidUri { url, reason, kind }) => {
2166                // With proper URI parsing, this is an invalid URI (no scheme)
2167                assert_eq!(url, "example.com/test");
2168                assert!(!reason.is_empty(), "Should have a reason for invalid URI");
2169                assert_eq!(kind, crate::error::InvalidUriKind::ParseError);
2170            }
2171            Err(other) => panic!("Expected InvalidUri error, got: {other:?}"),
2172            Ok(_) => panic!("Expected InvalidUri error, but request succeeded"),
2173        }
2174    }
2175}