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