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