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
14pub type ServiceFuture =
16 Pin<Box<dyn Future<Output = Result<Response<ResponseBody>, HttpError>> + Send>>;
17
18pub type BufferedService = Buffer<Request<Full<Bytes>>, ServiceFuture>;
21
22#[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 pub fn new() -> Result<Self, HttpError> {
67 HttpClientBuilder::new().build()
68 }
69
70 #[must_use]
72 pub fn builder() -> HttpClientBuilder {
73 HttpClientBuilder::new()
74 }
75
76 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 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 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 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 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
232pub fn map_buffer_error(err: tower::BoxError) -> HttpError {
237 match err.downcast::<HttpError>() {
239 Ok(http_err) => *http_err,
240 Err(err) => {
241 tracing::error!(
248 error = %err,
249 "buffer worker closed unexpectedly; service unavailable"
250 );
251 HttpError::ServiceClosed
252 }
253 }
254}
255
256pub async fn try_acquire_buffer_slot(service: &mut BufferedService) -> Result<(), HttpError> {
261 use std::task::Poll;
262
263 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), })
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), }
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); 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) .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 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 #[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 #[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 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 for handle in handles {
512 let resp = handle.await.unwrap().unwrap();
513 assert_eq!(resp.status(), hyper::StatusCode::OK);
514 }
515 }
516
517 #[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 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 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 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 assert!(success_count > 0, "at least one request should succeed");
584 assert_eq!(success_count + overloaded_count, 10);
586 }
587
588 #[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 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 let client1 = client.clone();
617 let url1 = url.clone();
618 let handle1 = tokio::spawn(async move { client1.get(&url1).send().await });
619
620 tokio::time::sleep(std::time::Duration::from_millis(10)).await;
622
623 let result2 = tokio::time::timeout(
625 std::time::Duration::from_millis(50),
626 client.get(&url).send(),
627 )
628 .await;
629
630 let inner_result = result2.expect("request should not timeout waiting for buffer");
632 match inner_result {
633 Err(HttpError::Overloaded) | Ok(_) => {}
635 Err(e) => panic!("unexpected error: {e:?}"),
636 }
637
638 _ = handle1.await;
640 }
641
642 #[tokio::test]
644 async fn test_large_body_no_deadlock() {
645 let server = MockServer::start();
646 let large_body = "x".repeat(100 * 1024); 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) .build()
656 .unwrap();
657
658 let url = format!("{}/large", server.base_url());
659
660 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 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 #[tokio::test]
692 async fn test_token_endpoint_post_not_retried() {
693 use crate::config::HttpClientConfig;
694
695 let server = MockServer::start();
696
697 let mock = server.mock(|when, then| {
699 when.method(Method::POST).path("/token");
700 then.status(500).body("server error");
701 });
702
703 let mut config = HttpClientConfig::token_endpoint();
705 config.transport = crate::config::TransportSecurity::AllowInsecureHttp; let client = HttpClientBuilder::with_config(config).build().unwrap();
708 let url = format!("{}/token", server.base_url());
709
710 let result = client
712 .post(&url)
713 .form(&[("grant_type", "client_credentials"), ("client_id", "test")])
714 .unwrap()
715 .send()
716 .await;
717
718 assert!(result.is_ok()); let response = result.unwrap();
721 assert_eq!(response.status(), hyper::StatusCode::INTERNAL_SERVER_ERROR);
722
723 assert_eq!(
726 mock.calls(),
727 1,
728 "POST should not be retried; expected 1 call, got {}",
729 mock.calls()
730 );
731 }
732
733 #[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 #[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 .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") .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 #[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 .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") .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 #[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 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 match result {
1136 Err(HttpError::HttpStatus {
1137 status,
1138 body_preview,
1139 ..
1140 }) => {
1141 assert_eq!(status, hyper::StatusCode::INTERNAL_SERVER_ERROR);
1142 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 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 #[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 #[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 #[tokio::test]
1254 async fn test_gzip_decompression_body_size_limit() {
1255 let server = MockServer::start();
1256
1257 let large_decompressed = vec![b'x'; 100 * 1024]; let compressed_body = gzip_compress(&large_decompressed);
1261
1262 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 let client = HttpClientBuilder::new()
1278 .retry(None)
1279 .max_body_size(10 * 1024) .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 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 #[tokio::test]
1308 async fn test_accept_encoding_header_sent() {
1309 let server = MockServer::start();
1310
1311 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 assert_eq!(
1327 mock.calls(),
1328 1,
1329 "Request should have included Accept-Encoding header"
1330 );
1331 }
1332
1333 #[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 #[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 #[tokio::test]
1404 async fn test_gzip_decompression_text() {
1405 let server = MockServer::start();
1406
1407 let original_text = "Hello, World! \u{1F600}"; 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 #[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]
1451 fn test_map_buffer_error_returns_service_closed_for_unknown_error() {
1452 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 #[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, backoff: ExponentialBackoff::fast(),
1496 ..RetryConfig::default() }),
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 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 let response = result.expect("send() should return Ok(Response) after retries exhaust");
1517 assert_eq!(response.status(), hyper::StatusCode::INTERNAL_SERVER_ERROR);
1518
1519 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 #[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() }),
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 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 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 }
1579
1580 #[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, backoff: ExponentialBackoff::fast(),
1600 ..RetryConfig::default() }),
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 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 let response = result.expect("send() should return Ok(Response) after retries exhaust");
1621 assert_eq!(response.status(), hyper::StatusCode::TOO_MANY_REQUESTS);
1622
1623 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 #[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, 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 let response = result.expect("send() should return Ok(Response)");
1665 assert_eq!(response.status(), hyper::StatusCode::TOO_MANY_REQUESTS);
1666
1667 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 #[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") .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(), ignore_retry_after: true, ..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 assert!(
1736 elapsed < std::time::Duration::from_secs(2),
1737 "Should have used fast backoff, not 10s Retry-After; elapsed: {elapsed:?}"
1738 );
1739
1740 assert_eq!(mock.calls(), 3, "Expected 3 calls, got {}", mock.calls());
1742 }
1743
1744 #[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 let result = client.get(&url).send().await;
1776
1777 assert_eq!(
1779 mock.calls(),
1780 1,
1781 "404 should not trigger retry; expected 1 call, got {}",
1782 mock.calls()
1783 );
1784
1785 let response = result.expect("send() should succeed for 404");
1787 assert_eq!(response.status(), hyper::StatusCode::NOT_FOUND);
1788
1789 }
1791
1792 #[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, 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 assert_eq!(
1824 mock.calls(),
1825 3,
1826 "Expected 3 calls (1 initial + 2 retries), got {}",
1827 mock.calls()
1828 );
1829
1830 let response = result.expect("send() should return Ok(Response) after retries exhaust");
1832 assert_eq!(response.status(), hyper::StatusCode::INTERNAL_SERVER_ERROR);
1833
1834 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 #[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, 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 assert_eq!(mock.calls(), 1);
1869
1870 let response = result.expect("send() should succeed when retry disabled");
1872 assert_eq!(response.status(), hyper::StatusCode::INTERNAL_SERVER_ERROR);
1873
1874 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 #[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 let result = client.get("http://example.com/test").send().await;
1896
1897 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 #[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()); let result = client.get(&url).send().await;
1928
1929 assert!(result.is_ok(), "http:// should be allowed: {result:?}");
1930 }
1931
1932 #[tokio::test]
1934 async fn test_url_scheme_https_always_allowed() {
1935 let client = HttpClientBuilder::new()
1938 .transport(crate::config::TransportSecurity::TlsOnly)
1939 .retry(None)
1940 .build()
1941 .unwrap();
1942
1943 let result = client.get("https://localhost:0/test").send().await;
1946
1947 if let Err(HttpError::InvalidScheme { .. }) = result {
1949 panic!("https:// should not trigger InvalidScheme error")
1950 }
1951 }
1953
1954 #[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 #[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 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}