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()
287 .allow_insecure_http()
288 .retry(None)
289 .build()
290 .unwrap()
291 }
292
293 #[tokio::test]
294 async fn test_http_client_get() {
295 let server = MockServer::start();
296 let _m = server.mock(|when, then| {
297 when.method(Method::GET).path("/test");
298 then.status(200).json_body(json!({"success": true}));
299 });
300
301 let client = test_client();
302 let url = format!("{}/test", server.base_url());
303 let resp = client.get(&url).send().await.unwrap();
304
305 assert_eq!(resp.status(), hyper::StatusCode::OK);
306 }
307
308 #[tokio::test]
309 async fn test_http_client_post() {
310 let server = MockServer::start();
311 let _m = server.mock(|when, then| {
312 when.method(Method::POST).path("/action");
313 then.status(200).json_body(json!({"ok": true}));
314 });
315
316 let client = test_client();
317 let url = format!("{}/action", server.base_url());
318 let resp = client.post(&url).send().await.unwrap();
319
320 assert_eq!(resp.status(), hyper::StatusCode::OK);
321 }
322
323 #[tokio::test]
324 async fn test_http_client_post_form() {
325 let server = MockServer::start();
326 let _m = server.mock(|when, then| {
327 when.method(Method::POST)
328 .path("/submit")
329 .header("content-type", "application/x-www-form-urlencoded")
330 .body("key1=value1&key2=value2");
331 then.status(200).json_body(json!({"received": true}));
332 });
333
334 let client = test_client();
335 let url = format!("{}/submit", server.base_url());
336
337 let resp = client
338 .post(&url)
339 .form(&[("key1", "value1"), ("key2", "value2")])
340 .unwrap()
341 .send()
342 .await
343 .unwrap();
344 assert_eq!(resp.status(), hyper::StatusCode::OK);
345 }
346
347 #[tokio::test]
348 async fn test_json_body_parsing() {
349 #[derive(serde::Deserialize)]
350 struct TestResponse {
351 name: String,
352 value: i32,
353 }
354
355 let server = MockServer::start();
356 let _m = server.mock(|when, then| {
357 when.method(Method::GET).path("/json");
358 then.status(200)
359 .json_body(json!({"name": "test", "value": 42}));
360 });
361
362 let client = test_client();
363 let url = format!("{}/json", server.base_url());
364
365 let data: TestResponse = client.get(&url).send().await.unwrap().json().await.unwrap();
366 assert_eq!(data.name, "test");
367 assert_eq!(data.value, 42);
368 }
369
370 #[tokio::test]
371 async fn test_body_size_limit() {
372 let server = MockServer::start();
373 let large_body = "x".repeat(1024 * 1024); let _m = server.mock(|when, then| {
375 when.method(Method::GET).path("/large");
376 then.status(200).body(&large_body);
377 });
378
379 let client = HttpClientBuilder::new()
380 .allow_insecure_http()
381 .retry(None)
382 .max_body_size(1024) .build()
384 .unwrap();
385
386 let url = format!("{}/large", server.base_url());
387 let result = client.get(&url).send().await.unwrap().bytes().await;
388
389 assert!(matches!(result, Err(HttpError::BodyTooLarge { .. })));
390 }
391
392 #[tokio::test]
393 async fn test_custom_user_agent() {
394 let server = MockServer::start();
395 let _m = server.mock(|when, then| {
396 when.method(Method::GET)
397 .path("/test")
398 .header("user-agent", "custom/1.0");
399 then.status(200);
400 });
401
402 let client = HttpClientBuilder::new()
403 .allow_insecure_http()
404 .retry(None)
405 .user_agent("custom/1.0")
406 .build()
407 .unwrap();
408
409 let url = format!("{}/test", server.base_url());
410 let resp = client.get(&url).send().await.unwrap();
411 assert_eq!(resp.status(), hyper::StatusCode::OK);
412 }
413
414 #[tokio::test]
415 async fn test_non_2xx_returns_http_status_error() {
416 let server = MockServer::start();
417 let _m = server.mock(|when, then| {
418 when.method(Method::GET).path("/error");
419 then.status(404)
420 .header("content-type", "application/json")
421 .body(r#"{"error": "not found"}"#);
422 });
423
424 let client = test_client();
425 let url = format!("{}/error", server.base_url());
426
427 let result: Result<serde_json::Value, _> =
428 client.get(&url).send().await.unwrap().json().await;
429 match result {
430 Err(HttpError::HttpStatus {
431 status,
432 body_preview,
433 content_type,
434 ..
435 }) => {
436 assert_eq!(status, hyper::StatusCode::NOT_FOUND);
437 assert!(body_preview.contains("not found"));
438 assert_eq!(content_type, Some("application/json".to_owned()));
439 }
440 other => panic!("Expected HttpStatus error, got: {other:?}"),
441 }
442 }
443
444 #[tokio::test]
445 async fn test_checked_body_success() {
446 let server = MockServer::start();
447 let _m = server.mock(|when, then| {
448 when.method(Method::GET).path("/data");
449 then.status(200).body("hello world");
450 });
451
452 let client = test_client();
453 let url = format!("{}/data", server.base_url());
454
455 let body = client
456 .get(&url)
457 .send()
458 .await
459 .unwrap()
460 .checked_bytes()
461 .await
462 .unwrap();
463 assert_eq!(&body[..], b"hello world");
464 }
465
466 #[tokio::test]
467 async fn test_client_is_clone() {
468 let client = test_client();
469 let client2 = client.clone();
470
471 let server = MockServer::start();
473 let _m = server.mock(|when, then| {
474 when.method(Method::GET).path("/test");
475 then.status(200);
476 });
477
478 let url = format!("{}/test", server.base_url());
479 let resp1 = client.get(&url).send().await.unwrap();
480 let resp2 = client2.get(&url).send().await.unwrap();
481
482 assert_eq!(resp1.status(), hyper::StatusCode::OK);
483 assert_eq!(resp2.status(), hyper::StatusCode::OK);
484 }
485
486 #[test]
490 fn test_http_client_is_send_sync() {
491 fn assert_send_sync<T: Send + Sync>() {}
492 assert_send_sync::<HttpClient>();
493 }
494
495 #[tokio::test]
497 async fn test_concurrent_requests_50() {
498 let server = MockServer::start();
499 let _m = server.mock(|when, then| {
500 when.method(Method::GET).path("/concurrent");
501 then.status(200).body("ok");
502 });
503
504 let client = test_client();
505 let url = format!("{}/concurrent", server.base_url());
506
507 let handles: Vec<_> = (0..50)
509 .map(|_| {
510 let client = client.clone();
511 let url = url.clone();
512 tokio::spawn(async move { client.get(&url).send().await })
513 })
514 .collect();
515
516 for handle in handles {
518 let resp = handle.await.unwrap().unwrap();
519 assert_eq!(resp.status(), hyper::StatusCode::OK);
520 }
521 }
522
523 #[tokio::test]
531 async fn test_small_buffer_capacity_no_deadlock() {
532 use crate::config::HttpClientConfig;
533
534 let server = MockServer::start();
535 let _m = server.mock(|when, then| {
536 when.method(Method::GET).path("/test");
537 then.status(200).body("ok");
538 });
539
540 let config = HttpClientConfig {
542 transport: crate::config::TransportSecurity::AllowInsecureHttp,
543 retry: None,
544 rate_limit: None,
545 buffer_capacity: 2,
546 ..Default::default()
547 };
548
549 let client = HttpClientBuilder::with_config(config).build().unwrap();
550 let url = format!("{}/test", server.base_url());
551
552 let handles: Vec<_> = (0..10)
554 .map(|_| {
555 let client = client.clone();
556 let url = url.clone();
557 tokio::spawn(async move { client.get(&url).send().await })
558 })
559 .collect();
560
561 let timeout_result = tokio::time::timeout(std::time::Duration::from_secs(10), async {
563 let mut results = Vec::new();
564 for handle in handles {
565 results.push(handle.await);
566 }
567 results
568 })
569 .await;
570
571 let results = timeout_result.expect("requests should complete within timeout");
572
573 let mut success_count = 0;
574 let mut overloaded_count = 0;
575 for result in results {
576 match result.unwrap() {
577 Ok(resp) => {
578 assert_eq!(resp.status(), hyper::StatusCode::OK);
579 success_count += 1;
580 }
581 Err(HttpError::Overloaded) => {
582 overloaded_count += 1;
583 }
584 Err(e) => panic!("unexpected error: {e:?}"),
585 }
586 }
587
588 assert!(success_count > 0, "at least one request should succeed");
590 assert_eq!(success_count + overloaded_count, 10);
592 }
593
594 #[tokio::test]
599 async fn test_buffer_overflow_returns_overloaded() {
600 use crate::config::HttpClientConfig;
601
602 let server = MockServer::start();
603
604 let _m = server.mock(|when, then| {
605 when.method(Method::GET).path("/slow");
606 then.status(200).body("ok");
607 });
608
609 let config = HttpClientConfig {
611 transport: crate::config::TransportSecurity::AllowInsecureHttp,
612 retry: None,
613 rate_limit: None,
614 buffer_capacity: 1,
615 ..Default::default()
616 };
617
618 let client = HttpClientBuilder::with_config(config).build().unwrap();
619 let url = format!("{}/slow", server.base_url());
620
621 let client1 = client.clone();
623 let url1 = url.clone();
624 let handle1 = tokio::spawn(async move { client1.get(&url1).send().await });
625
626 tokio::time::sleep(std::time::Duration::from_millis(10)).await;
628
629 let result2 = tokio::time::timeout(
631 std::time::Duration::from_millis(50),
632 client.get(&url).send(),
633 )
634 .await;
635
636 let inner_result = result2.expect("request should not timeout waiting for buffer");
638 match inner_result {
639 Err(HttpError::Overloaded) | Ok(_) => {}
641 Err(e) => panic!("unexpected error: {e:?}"),
642 }
643
644 _ = handle1.await;
646 }
647
648 #[tokio::test]
650 async fn test_large_body_no_deadlock() {
651 let server = MockServer::start();
652 let large_body = "x".repeat(100 * 1024); let _m = server.mock(|when, then| {
654 when.method(Method::GET).path("/large");
655 then.status(200).body(&large_body);
656 });
657
658 let client = HttpClientBuilder::new()
659 .allow_insecure_http()
660 .retry(None)
661 .max_body_size(1024 * 1024) .build()
663 .unwrap();
664
665 let url = format!("{}/large", server.base_url());
666
667 let handles: Vec<_> = (0..5)
669 .map(|_| {
670 let client = client.clone();
671 let url = url.clone();
672 tokio::spawn(async move { client.get(&url).send().await?.checked_bytes().await })
673 })
674 .collect();
675
676 let timeout_result = tokio::time::timeout(std::time::Duration::from_secs(10), async {
678 let mut results = Vec::new();
679 for handle in handles {
680 results.push(handle.await);
681 }
682 results
683 })
684 .await;
685
686 let results = timeout_result.expect("body reads should complete within timeout");
687 for result in results {
688 let body = result.unwrap().unwrap();
689 assert_eq!(body.len(), 100 * 1024);
690 }
691 }
692
693 #[tokio::test]
699 async fn test_token_endpoint_post_not_retried() {
700 use crate::config::HttpClientConfig;
701
702 let server = MockServer::start();
703
704 let mock = server.mock(|when, then| {
706 when.method(Method::POST).path("/token");
707 then.status(500).body("server error");
708 });
709
710 let mut config = HttpClientConfig::token_endpoint();
712 config.transport = crate::config::TransportSecurity::AllowInsecureHttp; let client = HttpClientBuilder::with_config(config).build().unwrap();
715 let url = format!("{}/token", server.base_url());
716
717 let result = client
719 .post(&url)
720 .form(&[("grant_type", "client_credentials"), ("client_id", "test")])
721 .unwrap()
722 .send()
723 .await;
724
725 assert!(result.is_ok()); let response = result.unwrap();
728 assert_eq!(response.status(), hyper::StatusCode::INTERNAL_SERVER_ERROR);
729
730 assert_eq!(
733 mock.calls(),
734 1,
735 "POST should not be retried; expected 1 call, got {}",
736 mock.calls()
737 );
738 }
739
740 #[tokio::test]
746 async fn test_http_client_put() {
747 let server = MockServer::start();
748 let _m = server.mock(|when, then| {
749 when.method(Method::PUT).path("/resource");
750 then.status(200).json_body(json!({"updated": true}));
751 });
752
753 let client = test_client();
754 let url = format!("{}/resource", server.base_url());
755 let resp = client.put(&url).send().await.unwrap();
756
757 assert_eq!(resp.status(), hyper::StatusCode::OK);
758 }
759
760 #[tokio::test]
761 async fn test_http_client_put_form() {
762 let server = MockServer::start();
763 let _m = server.mock(|when, then| {
764 when.method(Method::PUT)
765 .path("/resource")
766 .header("content-type", "application/x-www-form-urlencoded")
767 .body("name=updated&value=123");
768 then.status(200).json_body(json!({"updated": true}));
769 });
770
771 let client = test_client();
772 let url = format!("{}/resource", server.base_url());
773
774 let resp = client
775 .put(&url)
776 .form(&[("name", "updated"), ("value", "123")])
777 .unwrap()
778 .send()
779 .await
780 .unwrap();
781 assert_eq!(resp.status(), hyper::StatusCode::OK);
782 }
783
784 #[tokio::test]
785 async fn test_http_client_patch() {
786 let server = MockServer::start();
787 let _m = server.mock(|when, then| {
788 when.method(Method::PATCH).path("/resource/1");
789 then.status(200).json_body(json!({"patched": true}));
790 });
791
792 let client = test_client();
793 let url = format!("{}/resource/1", server.base_url());
794 let resp = client.patch(&url).send().await.unwrap();
795
796 assert_eq!(resp.status(), hyper::StatusCode::OK);
797 }
798
799 #[tokio::test]
800 async fn test_http_client_patch_form() {
801 let server = MockServer::start();
802 let _m = server.mock(|when, then| {
803 when.method(Method::PATCH)
804 .path("/resource/1")
805 .header("content-type", "application/x-www-form-urlencoded")
806 .body("field=patched");
807 then.status(200).json_body(json!({"patched": true}));
808 });
809
810 let client = test_client();
811 let url = format!("{}/resource/1", server.base_url());
812
813 let resp = client
814 .patch(&url)
815 .form(&[("field", "patched")])
816 .unwrap()
817 .send()
818 .await
819 .unwrap();
820 assert_eq!(resp.status(), hyper::StatusCode::OK);
821 }
822
823 #[tokio::test]
824 async fn test_http_client_delete() {
825 let server = MockServer::start();
826 let _m = server.mock(|when, then| {
827 when.method(Method::DELETE).path("/resource/42");
828 then.status(204);
829 });
830
831 let client = test_client();
832 let url = format!("{}/resource/42", server.base_url());
833 let resp = client.delete(&url).send().await.unwrap();
834
835 assert_eq!(resp.status(), hyper::StatusCode::NO_CONTENT);
836 }
837
838 #[tokio::test]
839 async fn test_http_client_delete_returns_200() {
840 let server = MockServer::start();
841 let _m = server.mock(|when, then| {
842 when.method(Method::DELETE).path("/resource/99");
843 then.status(200).json_body(json!({"deleted": true}));
844 });
845
846 let client = test_client();
847 let url = format!("{}/resource/99", server.base_url());
848 let resp = client.delete(&url).send().await.unwrap();
849
850 assert_eq!(resp.status(), hyper::StatusCode::OK);
851 }
852
853 #[tokio::test]
854 async fn test_put_form_with_custom_headers() {
855 let server = MockServer::start();
856 let _m = server.mock(|when, then| {
857 when.method(Method::PUT)
858 .path("/api/data")
859 .header("content-type", "application/x-www-form-urlencoded")
860 .header("x-custom-header", "custom-value")
861 .body("key=value");
862 then.status(200);
863 });
864
865 let client = test_client();
866 let url = format!("{}/api/data", server.base_url());
867
868 let resp = client
869 .put(&url)
870 .header("x-custom-header", "custom-value")
871 .form(&[("key", "value")])
872 .unwrap()
873 .send()
874 .await
875 .unwrap();
876 assert_eq!(resp.status(), hyper::StatusCode::OK);
877 }
878
879 #[tokio::test]
880 async fn test_patch_form_with_custom_headers() {
881 let server = MockServer::start();
882 let _m = server.mock(|when, then| {
883 when.method(Method::PATCH)
884 .path("/api/data")
885 .header("content-type", "application/x-www-form-urlencoded")
886 .header("authorization", "Bearer token123")
887 .body("status=active");
888 then.status(200);
889 });
890
891 let client = test_client();
892 let url = format!("{}/api/data", server.base_url());
893
894 let resp = client
895 .patch(&url)
896 .header("authorization", "Bearer token123")
897 .form(&[("status", "active")])
898 .unwrap()
899 .send()
900 .await
901 .unwrap();
902 assert_eq!(resp.status(), hyper::StatusCode::OK);
903 }
904
905 #[tokio::test]
906 async fn test_request_builder_json_body() {
907 #[derive(serde::Serialize)]
908 struct CreateUser {
909 name: String,
910 email: String,
911 }
912
913 let server = MockServer::start();
914 let _m = server.mock(|when, then| {
915 when.method(Method::POST)
916 .path("/users")
917 .header("content-type", "application/json")
918 .json_body(json!({"name": "Alice", "email": "alice@example.com"}));
919 then.status(201).json_body(json!({"id": 1}));
920 });
921
922 let client = test_client();
923 let url = format!("{}/users", server.base_url());
924
925 let resp = client
926 .post(&url)
927 .json(&CreateUser {
928 name: "Alice".into(),
929 email: "alice@example.com".into(),
930 })
931 .unwrap()
932 .send()
933 .await
934 .unwrap();
935 assert_eq!(resp.status(), hyper::StatusCode::CREATED);
936 }
937
938 #[tokio::test]
939 async fn test_request_builder_body_bytes() {
940 let server = MockServer::start();
941 let _m = server.mock(|when, then| {
942 when.method(Method::POST)
943 .path("/upload")
944 .body("raw binary data");
945 then.status(200);
946 });
947
948 let client = test_client();
949 let url = format!("{}/upload", server.base_url());
950
951 let resp = client
952 .post(&url)
953 .body_bytes(bytes::Bytes::from("raw binary data"))
954 .send()
955 .await
956 .unwrap();
957 assert_eq!(resp.status(), hyper::StatusCode::OK);
958 }
959
960 #[tokio::test]
966 async fn test_content_type_not_duplicated_with_json() {
967 #[derive(serde::Serialize)]
968 struct TestData {
969 value: i32,
970 }
971
972 let server = MockServer::start();
973 let mock = server.mock(|when, then| {
974 when.method(Method::POST)
975 .path("/custom-content-type")
976 .header("content-type", "application/vnd.custom+json");
978 then.status(200);
979 });
980
981 let client = test_client();
982 let url = format!("{}/custom-content-type", server.base_url());
983
984 let resp = client
985 .post(&url)
986 .header("content-type", "application/vnd.custom+json") .json(&TestData { value: 42 })
988 .unwrap()
989 .send()
990 .await
991 .unwrap();
992
993 assert_eq!(resp.status(), hyper::StatusCode::OK);
994 assert_eq!(
995 mock.calls(),
996 1,
997 "Request with custom Content-Type should match"
998 );
999 }
1000
1001 #[tokio::test]
1003 async fn test_content_type_not_duplicated_with_form() {
1004 let server = MockServer::start();
1005 let mock = server.mock(|when, then| {
1006 when.method(Method::POST)
1007 .path("/custom-form-type")
1008 .header("content-type", "application/x-custom-form");
1010 then.status(200);
1011 });
1012
1013 let client = test_client();
1014 let url = format!("{}/custom-form-type", server.base_url());
1015
1016 let resp = client
1017 .post(&url)
1018 .header("content-type", "application/x-custom-form") .form(&[("key", "value")])
1020 .unwrap()
1021 .send()
1022 .await
1023 .unwrap();
1024
1025 assert_eq!(resp.status(), hyper::StatusCode::OK);
1026 assert_eq!(
1027 mock.calls(),
1028 1,
1029 "Request with custom Content-Type should match"
1030 );
1031 }
1032
1033 #[tokio::test]
1034 async fn test_request_builder_body_string() {
1035 let server = MockServer::start();
1036 let _m = server.mock(|when, then| {
1037 when.method(Method::POST)
1038 .path("/text")
1039 .body("Hello, World!");
1040 then.status(200);
1041 });
1042
1043 let client = test_client();
1044 let url = format!("{}/text", server.base_url());
1045
1046 let resp = client
1047 .post(&url)
1048 .body_string("Hello, World!".into())
1049 .send()
1050 .await
1051 .unwrap();
1052 assert_eq!(resp.status(), hyper::StatusCode::OK);
1053 }
1054
1055 #[tokio::test]
1056 async fn test_response_text_method() {
1057 let server = MockServer::start();
1058 let _m = server.mock(|when, then| {
1059 when.method(Method::GET).path("/text");
1060 then.status(200).body("Hello, World!");
1061 });
1062
1063 let client = test_client();
1064 let url = format!("{}/text", server.base_url());
1065
1066 let text = client.get(&url).send().await.unwrap().text().await.unwrap();
1067 assert_eq!(text, "Hello, World!");
1068 }
1069
1070 #[tokio::test]
1071 async fn test_request_builder_multiple_headers() {
1072 let server = MockServer::start();
1073 let _m = server.mock(|when, then| {
1074 when.method(Method::GET)
1075 .path("/headers")
1076 .header("x-first", "one")
1077 .header("x-second", "two");
1078 then.status(200);
1079 });
1080
1081 let client = test_client();
1082 let url = format!("{}/headers", server.base_url());
1083
1084 let resp = client
1085 .get(&url)
1086 .header("x-first", "one")
1087 .header("x-second", "two")
1088 .send()
1089 .await
1090 .unwrap();
1091 assert_eq!(resp.status(), hyper::StatusCode::OK);
1092 }
1093
1094 #[tokio::test]
1095 async fn test_request_builder_headers_vec() {
1096 let server = MockServer::start();
1097 let _m = server.mock(|when, then| {
1098 when.method(Method::GET)
1099 .path("/headers")
1100 .header("x-first", "one")
1101 .header("x-second", "two");
1102 then.status(200);
1103 });
1104
1105 let client = test_client();
1106 let url = format!("{}/headers", server.base_url());
1107
1108 let resp = client
1109 .get(&url)
1110 .headers(vec![
1111 ("x-first".to_owned(), "one".to_owned()),
1112 ("x-second".to_owned(), "two".to_owned()),
1113 ])
1114 .send()
1115 .await
1116 .unwrap();
1117 assert_eq!(resp.status(), hyper::StatusCode::OK);
1118 }
1119
1120 #[tokio::test]
1123 async fn test_error_response_with_large_body_returns_http_status() {
1124 use crate::security::ERROR_BODY_PREVIEW_LIMIT;
1125
1126 let server = MockServer::start();
1127
1128 let large_body = "x".repeat(ERROR_BODY_PREVIEW_LIMIT + 1000);
1130
1131 let _m = server.mock(|when, then| {
1132 when.method(Method::GET).path("/error-with-large-body");
1133 then.status(500).body(&large_body);
1134 });
1135
1136 let client = test_client();
1137 let url = format!("{}/error-with-large-body", server.base_url());
1138
1139 let result = client.get(&url).send().await.unwrap().checked_bytes().await;
1140
1141 match result {
1143 Err(HttpError::HttpStatus {
1144 status,
1145 body_preview,
1146 ..
1147 }) => {
1148 assert_eq!(status, hyper::StatusCode::INTERNAL_SERVER_ERROR);
1149 assert_eq!(body_preview, "<body too large for preview>");
1151 }
1152 Err(HttpError::BodyTooLarge { .. }) => {
1153 panic!("Should return HttpStatus, not BodyTooLarge for non-2xx responses");
1154 }
1155 Err(other) => panic!("Unexpected error: {other:?}"),
1156 Ok(_) => panic!("Should have returned an error for 500 status"),
1157 }
1158 }
1159
1160 fn gzip_compress(data: &[u8]) -> Vec<u8> {
1166 use flate2::Compression;
1167 use flate2::write::GzEncoder;
1168 use std::io::Write;
1169
1170 let mut encoder = GzEncoder::new(Vec::new(), Compression::default());
1171 encoder.write_all(data).unwrap();
1172 encoder.finish().unwrap()
1173 }
1174
1175 #[tokio::test]
1180 async fn test_gzip_decompression_basic() {
1181 let server = MockServer::start();
1182
1183 let original_body = b"Hello, this is a test body that will be gzip compressed!";
1184 let compressed_body = gzip_compress(original_body);
1185
1186 let _m = server.mock(|when, then| {
1187 when.method(Method::GET).path("/gzip");
1188 then.status(200)
1189 .header("content-encoding", "gzip")
1190 .body(compressed_body);
1191 });
1192
1193 let client = test_client();
1194 let url = format!("{}/gzip", server.base_url());
1195
1196 let body = client
1197 .get(&url)
1198 .send()
1199 .await
1200 .unwrap()
1201 .bytes()
1202 .await
1203 .unwrap();
1204
1205 assert_eq!(
1206 body.as_ref(),
1207 original_body,
1208 "Decompressed body should match original"
1209 );
1210 }
1211
1212 #[tokio::test]
1217 async fn test_gzip_decompression_json() {
1218 #[derive(serde::Deserialize, PartialEq, Debug)]
1219 struct TestData {
1220 name: String,
1221 value: i32,
1222 nested: NestedData,
1223 }
1224
1225 #[derive(serde::Deserialize, PartialEq, Debug)]
1226 struct NestedData {
1227 items: Vec<String>,
1228 }
1229
1230 let server = MockServer::start();
1231
1232 let json_body = r#"{"name":"test","value":42,"nested":{"items":["a","b","c"]}}"#;
1233 let compressed_body = gzip_compress(json_body.as_bytes());
1234
1235 let _m = server.mock(|when, then| {
1236 when.method(Method::GET).path("/gzip-json");
1237 then.status(200)
1238 .header("content-type", "application/json")
1239 .header("content-encoding", "gzip")
1240 .body(compressed_body);
1241 });
1242
1243 let client = test_client();
1244 let url = format!("{}/gzip-json", server.base_url());
1245
1246 let data: TestData = client.get(&url).send().await.unwrap().json().await.unwrap();
1247
1248 assert_eq!(data.name, "test");
1249 assert_eq!(data.value, 42);
1250 assert_eq!(data.nested.items, vec!["a", "b", "c"]);
1251 }
1252
1253 #[tokio::test]
1261 async fn test_gzip_decompression_body_size_limit() {
1262 let server = MockServer::start();
1263
1264 let large_decompressed = vec![b'x'; 100 * 1024]; let compressed_body = gzip_compress(&large_decompressed);
1268
1269 assert!(
1271 compressed_body.len() < 2000,
1272 "Compressed body should be small (got {} bytes)",
1273 compressed_body.len()
1274 );
1275
1276 let _m = server.mock(|when, then| {
1277 when.method(Method::GET).path("/gzip-bomb");
1278 then.status(200)
1279 .header("content-encoding", "gzip")
1280 .body(compressed_body);
1281 });
1282
1283 let client = HttpClientBuilder::new()
1285 .allow_insecure_http()
1286 .retry(None)
1287 .max_body_size(10 * 1024) .build()
1289 .unwrap();
1290
1291 let url = format!("{}/gzip-bomb", server.base_url());
1292 let result = client.get(&url).send().await.unwrap().bytes().await;
1293
1294 match result {
1296 Err(HttpError::BodyTooLarge { limit, actual }) => {
1297 assert_eq!(limit, 10 * 1024, "Limit should be 10KB");
1298 assert!(
1299 actual > limit,
1300 "Actual size ({actual}) should exceed limit ({limit})"
1301 );
1302 }
1303 Err(other) => panic!("Expected BodyTooLarge error, got: {other:?}"),
1304 Ok(body) => panic!(
1305 "Expected BodyTooLarge error, but got {} bytes of body",
1306 body.len()
1307 ),
1308 }
1309 }
1310
1311 #[tokio::test]
1316 async fn test_accept_encoding_header_sent() {
1317 let server = MockServer::start();
1318
1319 let mock = server.mock(|when, then| {
1321 when.method(Method::GET)
1322 .path("/check-accept-encoding")
1323 .header_exists("accept-encoding");
1324 then.status(200).body("ok");
1325 });
1326
1327 let client = test_client();
1328 let url = format!("{}/check-accept-encoding", server.base_url());
1329
1330 let resp = client.get(&url).send().await.unwrap();
1331 assert_eq!(resp.status(), hyper::StatusCode::OK);
1332
1333 assert_eq!(
1335 mock.calls(),
1336 1,
1337 "Request should have included Accept-Encoding header"
1338 );
1339 }
1340
1341 #[tokio::test]
1345 async fn test_no_compression_passthrough() {
1346 let server = MockServer::start();
1347
1348 let plain_body = b"This is plain text, not compressed";
1349
1350 let _m = server.mock(|when, then| {
1351 when.method(Method::GET).path("/plain");
1352 then.status(200)
1353 .header("content-type", "text/plain")
1354 .body(plain_body.as_slice());
1355 });
1356
1357 let client = test_client();
1358 let url = format!("{}/plain", server.base_url());
1359
1360 let body = client
1361 .get(&url)
1362 .send()
1363 .await
1364 .unwrap()
1365 .bytes()
1366 .await
1367 .unwrap();
1368
1369 assert_eq!(
1370 body.as_ref(),
1371 plain_body,
1372 "Plain body should pass through unchanged"
1373 );
1374 }
1375
1376 #[tokio::test]
1378 async fn test_gzip_decompression_checked_bytes() {
1379 let server = MockServer::start();
1380
1381 let original_body = b"Checked bytes test with gzip";
1382 let compressed_body = gzip_compress(original_body);
1383
1384 let _m = server.mock(|when, then| {
1385 when.method(Method::GET).path("/gzip-checked");
1386 then.status(200)
1387 .header("content-encoding", "gzip")
1388 .body(compressed_body);
1389 });
1390
1391 let client = test_client();
1392 let url = format!("{}/gzip-checked", server.base_url());
1393
1394 let body = client
1395 .get(&url)
1396 .send()
1397 .await
1398 .unwrap()
1399 .checked_bytes()
1400 .await
1401 .unwrap();
1402
1403 assert_eq!(
1404 body.as_ref(),
1405 original_body,
1406 "checked_bytes should return decompressed content"
1407 );
1408 }
1409
1410 #[tokio::test]
1412 async fn test_gzip_decompression_text() {
1413 let server = MockServer::start();
1414
1415 let original_text = "Hello, World! \u{1F600}"; let compressed_body = gzip_compress(original_text.as_bytes());
1417
1418 let _m = server.mock(|when, then| {
1419 when.method(Method::GET).path("/gzip-text");
1420 then.status(200)
1421 .header("content-type", "text/plain; charset=utf-8")
1422 .header("content-encoding", "gzip")
1423 .body(compressed_body);
1424 });
1425
1426 let client = test_client();
1427 let url = format!("{}/gzip-text", server.base_url());
1428
1429 let text = client.get(&url).send().await.unwrap().text().await.unwrap();
1430
1431 assert_eq!(
1432 text, original_text,
1433 "text() should return decompressed UTF-8 content"
1434 );
1435 }
1436
1437 #[test]
1443 fn test_map_buffer_error_passes_through_http_error() {
1444 let http_err = HttpError::Timeout(std::time::Duration::from_secs(10));
1445 let boxed: tower::BoxError = Box::new(http_err);
1446 let result = map_buffer_error(boxed);
1447
1448 assert!(
1449 matches!(result, HttpError::Timeout(_)),
1450 "Should pass through HttpError::Timeout, got: {result:?}"
1451 );
1452 }
1453
1454 #[test]
1459 fn test_map_buffer_error_returns_service_closed_for_unknown_error() {
1460 let other_err: tower::BoxError = Box::new(std::io::Error::new(
1462 std::io::ErrorKind::BrokenPipe,
1463 "buffer worker died",
1464 ));
1465 let result = map_buffer_error(other_err);
1466
1467 assert!(
1468 matches!(result, HttpError::ServiceClosed),
1469 "Should return ServiceClosed for non-HttpError, got: {result:?}"
1470 );
1471 }
1472
1473 #[tokio::test]
1490 async fn test_status_retry_get_500_retried() {
1491 use crate::config::{ExponentialBackoff, HttpClientConfig, RetryConfig};
1492
1493 let server = MockServer::start();
1494 let mock = server.mock(|when, then| {
1495 when.method(Method::GET).path("/retry-500");
1496 then.status(500).body("server error");
1497 });
1498
1499 let config = HttpClientConfig {
1500 transport: crate::config::TransportSecurity::AllowInsecureHttp,
1501 retry: Some(RetryConfig {
1502 max_retries: 2, backoff: ExponentialBackoff::fast(),
1504 ..RetryConfig::default() }),
1506 rate_limit: None,
1507 ..Default::default()
1508 };
1509
1510 let client = HttpClientBuilder::with_config(config).build().unwrap();
1511 let url = format!("{}/retry-500", server.base_url());
1512
1513 let result = client.get(&url).send().await;
1514
1515 assert_eq!(
1517 mock.calls(),
1518 3,
1519 "GET should retry on 500; expected 3 calls (1 + 2 retries), got {}",
1520 mock.calls()
1521 );
1522
1523 let response = result.expect("send() should return Ok(Response) after retries exhaust");
1525 assert_eq!(response.status(), hyper::StatusCode::INTERNAL_SERVER_ERROR);
1526
1527 let err = response.error_for_status().unwrap_err();
1529 assert!(
1530 matches!(err, HttpError::HttpStatus { status, .. } if status == hyper::StatusCode::INTERNAL_SERVER_ERROR)
1531 );
1532 }
1533
1534 #[tokio::test]
1543 async fn test_status_retry_post_500_not_retried() {
1544 use crate::config::{ExponentialBackoff, HttpClientConfig, RetryConfig};
1545
1546 let server = MockServer::start();
1547 let mock = server.mock(|when, then| {
1548 when.method(Method::POST).path("/post-500");
1549 then.status(500).body("server error");
1550 });
1551
1552 let config = HttpClientConfig {
1553 transport: crate::config::TransportSecurity::AllowInsecureHttp,
1554 retry: Some(RetryConfig {
1555 max_retries: 3,
1556 backoff: ExponentialBackoff::fast(),
1557 ..RetryConfig::default() }),
1559 rate_limit: None,
1560 ..Default::default()
1561 };
1562
1563 let client = HttpClientBuilder::with_config(config).build().unwrap();
1564 let url = format!("{}/post-500", server.base_url());
1565
1566 let result = client.post(&url).send().await;
1567
1568 assert_eq!(
1570 mock.calls(),
1571 1,
1572 "POST should not be retried on 500; expected 1 call, got {}",
1573 mock.calls()
1574 );
1575
1576 let response = result.expect("POST + 500 should return Ok(Response), not Err");
1579 assert_eq!(
1580 response.status(),
1581 hyper::StatusCode::INTERNAL_SERVER_ERROR,
1582 "Response should have 500 status"
1583 );
1584
1585 }
1587
1588 #[tokio::test]
1594 async fn test_status_retry_post_429_retried() {
1595 use crate::config::{ExponentialBackoff, HttpClientConfig, RetryConfig};
1596
1597 let server = MockServer::start();
1598 let mock = server.mock(|when, then| {
1599 when.method(Method::POST).path("/post-429");
1600 then.status(429).body("rate limited");
1601 });
1602
1603 let config = HttpClientConfig {
1604 transport: crate::config::TransportSecurity::AllowInsecureHttp,
1605 retry: Some(RetryConfig {
1606 max_retries: 2, backoff: ExponentialBackoff::fast(),
1608 ..RetryConfig::default() }),
1610 rate_limit: None,
1611 ..Default::default()
1612 };
1613
1614 let client = HttpClientBuilder::with_config(config).build().unwrap();
1615 let url = format!("{}/post-429", server.base_url());
1616
1617 let result = client.post(&url).send().await;
1618
1619 assert_eq!(
1621 mock.calls(),
1622 3,
1623 "POST should retry on 429; expected 3 calls (1 + 2 retries), got {}",
1624 mock.calls()
1625 );
1626
1627 let response = result.expect("send() should return Ok(Response) after retries exhaust");
1629 assert_eq!(response.status(), hyper::StatusCode::TOO_MANY_REQUESTS);
1630
1631 let err = response.error_for_status().unwrap_err();
1633 assert!(
1634 matches!(err, HttpError::HttpStatus { status, .. } if status == hyper::StatusCode::TOO_MANY_REQUESTS)
1635 );
1636 }
1637
1638 #[tokio::test]
1643 async fn test_status_retry_extracts_retry_after_header() {
1644 use crate::config::{ExponentialBackoff, HttpClientConfig, RetryConfig};
1645
1646 let server = MockServer::start();
1647 let _mock = server.mock(|when, then| {
1648 when.method(Method::GET).path("/retry-after");
1649 then.status(429)
1650 .header("Retry-After", "60")
1651 .header("Content-Type", "application/json")
1652 .body(r#"{"error": "rate limited"}"#);
1653 });
1654
1655 let config = HttpClientConfig {
1656 transport: crate::config::TransportSecurity::AllowInsecureHttp,
1657 retry: Some(RetryConfig {
1658 max_retries: 0, backoff: ExponentialBackoff::fast(),
1660 ..RetryConfig::default()
1661 }),
1662 rate_limit: None,
1663 ..Default::default()
1664 };
1665
1666 let client = HttpClientBuilder::with_config(config).build().unwrap();
1667 let url = format!("{}/retry-after", server.base_url());
1668
1669 let result = client.get(&url).send().await;
1670
1671 let response = result.expect("send() should return Ok(Response)");
1673 assert_eq!(response.status(), hyper::StatusCode::TOO_MANY_REQUESTS);
1674
1675 match response.error_for_status() {
1677 Err(HttpError::HttpStatus {
1678 status,
1679 retry_after,
1680 content_type,
1681 ..
1682 }) => {
1683 assert_eq!(status, hyper::StatusCode::TOO_MANY_REQUESTS);
1684 assert_eq!(
1685 retry_after,
1686 Some(std::time::Duration::from_secs(60)),
1687 "Should extract Retry-After header"
1688 );
1689 assert_eq!(
1690 content_type,
1691 Some("application/json".to_owned()),
1692 "Should extract Content-Type header"
1693 );
1694 }
1695 other => panic!("Expected HttpStatus error from error_for_status(), got: {other:?}"),
1696 }
1697 }
1698
1699 #[tokio::test]
1711 async fn test_status_retry_ignores_retry_after_when_configured() {
1712 use crate::config::{ExponentialBackoff, HttpClientConfig, RetryConfig};
1713
1714 let server = MockServer::start();
1715 let mock = server.mock(|when, then| {
1716 when.method(Method::GET).path("/ignore-retry-after");
1717 then.status(429)
1718 .header("Retry-After", "10") .body("rate limited");
1720 });
1721
1722 let config = HttpClientConfig {
1723 transport: crate::config::TransportSecurity::AllowInsecureHttp,
1724 retry: Some(RetryConfig {
1725 max_retries: 2,
1726 backoff: ExponentialBackoff::fast(), ignore_retry_after: true, ..RetryConfig::default()
1729 }),
1730 rate_limit: None,
1731 ..Default::default()
1732 };
1733
1734 let client = HttpClientBuilder::with_config(config).build().unwrap();
1735 let url = format!("{}/ignore-retry-after", server.base_url());
1736
1737 let start = std::time::Instant::now();
1738 let _result = client.get(&url).send().await;
1739 let elapsed = start.elapsed();
1740
1741 assert!(
1744 elapsed < std::time::Duration::from_secs(2),
1745 "Should have used fast backoff, not 10s Retry-After; elapsed: {elapsed:?}"
1746 );
1747
1748 assert_eq!(mock.calls(), 3, "Expected 3 calls, got {}", mock.calls());
1750 }
1751
1752 #[tokio::test]
1757 async fn test_non_retryable_status_passes_through() {
1758 use crate::config::{ExponentialBackoff, HttpClientConfig, RetryConfig};
1759
1760 let server = MockServer::start();
1761 let mock = server.mock(|when, then| {
1762 when.method(Method::GET).path("/not-found");
1763 then.status(404)
1764 .header("content-type", "application/json")
1765 .body(r#"{"error": "not found"}"#);
1766 });
1767
1768 let config = HttpClientConfig {
1769 transport: crate::config::TransportSecurity::AllowInsecureHttp,
1770 retry: Some(RetryConfig {
1771 max_retries: 3,
1772 backoff: ExponentialBackoff::fast(),
1773 ..RetryConfig::default()
1774 }),
1775 rate_limit: None,
1776 ..Default::default()
1777 };
1778
1779 let client = HttpClientBuilder::with_config(config).build().unwrap();
1780 let url = format!("{}/not-found", server.base_url());
1781
1782 let result = client.get(&url).send().await;
1784
1785 assert_eq!(
1787 mock.calls(),
1788 1,
1789 "404 should not trigger retry; expected 1 call, got {}",
1790 mock.calls()
1791 );
1792
1793 let response = result.expect("send() should succeed for 404");
1795 assert_eq!(response.status(), hyper::StatusCode::NOT_FOUND);
1796
1797 }
1799
1800 #[tokio::test]
1805 async fn test_status_retry_exhausted_returns_ok_response() {
1806 use crate::config::{ExponentialBackoff, HttpClientConfig, RetryConfig};
1807
1808 let server = MockServer::start();
1809 let mock = server.mock(|when, then| {
1810 when.method(Method::GET).path("/always-500");
1811 then.status(500).body("always fails");
1812 });
1813
1814 let config = HttpClientConfig {
1815 transport: crate::config::TransportSecurity::AllowInsecureHttp,
1816 retry: Some(RetryConfig {
1817 max_retries: 2, backoff: ExponentialBackoff::fast(),
1819 ..RetryConfig::default()
1820 }),
1821 rate_limit: None,
1822 ..Default::default()
1823 };
1824
1825 let client = HttpClientBuilder::with_config(config).build().unwrap();
1826 let url = format!("{}/always-500", server.base_url());
1827
1828 let result = client.get(&url).send().await;
1829
1830 assert_eq!(
1832 mock.calls(),
1833 3,
1834 "Expected 3 calls (1 initial + 2 retries), got {}",
1835 mock.calls()
1836 );
1837
1838 let response = result.expect("send() should return Ok(Response) after retries exhaust");
1840 assert_eq!(response.status(), hyper::StatusCode::INTERNAL_SERVER_ERROR);
1841
1842 let err = response.error_for_status().unwrap_err();
1844 assert!(
1845 matches!(err, HttpError::HttpStatus { status, .. } if status == hyper::StatusCode::INTERNAL_SERVER_ERROR)
1846 );
1847 }
1848
1849 #[tokio::test]
1854 async fn test_no_retry_config_status_passes_through() {
1855 use crate::config::HttpClientConfig;
1856
1857 let server = MockServer::start();
1858 let mock = server.mock(|when, then| {
1859 when.method(Method::GET).path("/no-retry");
1860 then.status(500).body("server error");
1861 });
1862
1863 let config = HttpClientConfig {
1864 transport: crate::config::TransportSecurity::AllowInsecureHttp,
1865 retry: None, rate_limit: None,
1867 ..Default::default()
1868 };
1869
1870 let client = HttpClientBuilder::with_config(config).build().unwrap();
1871 let url = format!("{}/no-retry", server.base_url());
1872
1873 let result = client.get(&url).send().await;
1874
1875 assert_eq!(mock.calls(), 1);
1877
1878 let response = result.expect("send() should succeed when retry disabled");
1880 assert_eq!(response.status(), hyper::StatusCode::INTERNAL_SERVER_ERROR);
1881
1882 let err = response.error_for_status().unwrap_err();
1884 assert!(
1885 matches!(err, HttpError::HttpStatus { status, .. } if status == hyper::StatusCode::INTERNAL_SERVER_ERROR)
1886 );
1887 }
1888
1889 #[tokio::test]
1895 async fn test_url_scheme_http_rejected_with_tls_only() {
1896 use crate::config::HttpClientConfig;
1897
1898 let config = HttpClientConfig {
1900 retry: None,
1901 rate_limit: None,
1902 ..Default::default()
1903 };
1904 assert_eq!(config.transport, crate::config::TransportSecurity::TlsOnly);
1905
1906 let client = HttpClientBuilder::with_config(config).build().unwrap();
1907
1908 let result = client.get("http://example.com/test").send().await;
1910
1911 match result {
1913 Err(HttpError::InvalidScheme { scheme, reason }) => {
1914 assert_eq!(scheme, "http");
1915 assert!(
1916 reason.contains("TlsOnly"),
1917 "Error should mention TlsOnly: {reason}"
1918 );
1919 }
1920 Err(other) => panic!("Expected InvalidScheme error, got: {other:?}"),
1921 Ok(_) => panic!("Expected InvalidScheme error, but request succeeded"),
1922 }
1923 }
1924
1925 #[tokio::test]
1927 async fn test_url_scheme_http_allowed_with_allow_insecure() {
1928 let server = MockServer::start();
1929 let _m = server.mock(|when, then| {
1930 when.method(Method::GET).path("/test");
1931 then.status(200).body("ok");
1932 });
1933
1934 let client = HttpClientBuilder::new()
1935 .allow_insecure_http()
1936 .retry(None)
1937 .build()
1938 .unwrap();
1939
1940 let url = format!("{}/test", server.base_url()); let result = client.get(&url).send().await;
1942
1943 assert!(result.is_ok(), "http:// should be allowed: {result:?}");
1944 }
1945
1946 #[tokio::test]
1948 async fn test_url_scheme_https_always_allowed() {
1949 let client = HttpClientBuilder::new().retry(None).build().unwrap();
1952
1953 let result = client.get("https://localhost:0/test").send().await;
1956
1957 if let Err(HttpError::InvalidScheme { .. }) = result {
1959 panic!("https:// should not trigger InvalidScheme error")
1960 }
1961 }
1963
1964 #[tokio::test]
1966 async fn test_url_scheme_invalid_rejected() {
1967 let client = HttpClientBuilder::new()
1968 .allow_insecure_http() .retry(None)
1970 .build()
1971 .unwrap();
1972
1973 let result = client.get("ftp://files.example.com/file.txt").send().await;
1974
1975 match result {
1976 Err(HttpError::InvalidScheme { scheme, reason }) => {
1977 assert_eq!(scheme, "ftp");
1978 assert!(
1979 reason.contains("http://") || reason.contains("https://"),
1980 "Error should mention supported schemes: {reason}"
1981 );
1982 }
1983 Err(other) => panic!("Expected InvalidScheme error, got: {other:?}"),
1984 Ok(_) => panic!("Expected InvalidScheme error, but request succeeded"),
1985 }
1986 }
1987
1988 #[tokio::test]
1990 async fn test_url_scheme_missing_rejected() {
1991 let client = HttpClientBuilder::new()
1992 .allow_insecure_http()
1993 .retry(None)
1994 .build()
1995 .unwrap();
1996
1997 let result = client.get("example.com/test").send().await;
1998
1999 match result {
2000 Err(HttpError::InvalidUri { url, reason, kind }) => {
2001 assert_eq!(url, "example.com/test");
2003 assert!(!reason.is_empty(), "Should have a reason for invalid URI");
2004 assert_eq!(kind, crate::error::InvalidUriKind::ParseError);
2005 }
2006 Err(other) => panic!("Expected InvalidUri error, got: {other:?}"),
2007 Ok(_) => panic!("Expected InvalidUri error, but request succeeded"),
2008 }
2009 }
2010}