Skip to main content

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