1use crate::config::{
2 HttpClientConfig, RedirectConfig, RetryConfig, TlsRootConfig, TransportSecurity,
3};
4use crate::error::HttpError;
5use crate::layers::{OtelLayer, RetryLayer, SecureRedirectPolicy, UserAgentLayer};
6use crate::response::ResponseBody;
7use crate::tls;
8use bytes::Bytes;
9use http::Response;
10use http_body_util::{BodyExt, Full};
11use hyper_rustls::HttpsConnector;
12use hyper_util::client::legacy::Client;
13use hyper_util::client::legacy::connect::HttpConnector;
14use hyper_util::rt::{TokioExecutor, TokioTimer};
15use std::time::Duration;
16use tower::buffer::Buffer;
17use tower::limit::ConcurrencyLimitLayer;
18use tower::load_shed::LoadShedLayer;
19use tower::timeout::TimeoutLayer;
20use tower::util::BoxCloneService;
21use tower::{ServiceBuilder, ServiceExt};
22use tower_http::decompression::DecompressionLayer;
23use tower_http::follow_redirect::FollowRedirectLayer;
24
25type InnerService =
27 BoxCloneService<http::Request<Full<Bytes>>, http::Response<ResponseBody>, HttpError>;
28
29pub struct HttpClientBuilder {
31 config: HttpClientConfig,
32 auth_layer: Option<Box<dyn FnOnce(InnerService) -> InnerService + Send>>,
33}
34
35impl HttpClientBuilder {
36 #[must_use]
38 pub fn new() -> Self {
39 Self {
40 config: HttpClientConfig::default(),
41 auth_layer: None,
42 }
43 }
44
45 #[must_use]
47 pub fn with_config(config: HttpClientConfig) -> Self {
48 Self {
49 config,
50 auth_layer: None,
51 }
52 }
53
54 #[must_use]
59 pub fn timeout(mut self, timeout: Duration) -> Self {
60 self.config.request_timeout = timeout;
61 self
62 }
63
64 #[must_use]
70 pub fn total_timeout(mut self, timeout: Duration) -> Self {
71 self.config.total_timeout = Some(timeout);
72 self
73 }
74
75 #[must_use]
77 pub fn user_agent(mut self, user_agent: impl Into<String>) -> Self {
78 self.config.user_agent = user_agent.into();
79 self
80 }
81
82 #[must_use]
84 pub fn retry(mut self, retry: Option<RetryConfig>) -> Self {
85 self.config.retry = retry;
86 self
87 }
88
89 #[must_use]
91 pub fn max_body_size(mut self, size: usize) -> Self {
92 self.config.max_body_size = size;
93 self
94 }
95
96 #[must_use]
100 pub fn transport(mut self, transport: TransportSecurity) -> Self {
101 self.config.transport = transport;
102 self
103 }
104
105 #[must_use]
123 #[cfg(any(debug_assertions, feature = "allow-insecure-http"))]
124 pub fn allow_insecure_http(mut self) -> Self {
125 tracing::warn!(
126 target: "modkit_http::security",
127 "allow_insecure_http() called - HTTP traffic will NOT be encrypted"
128 );
129 self.config.transport = TransportSecurity::AllowInsecureHttp;
130 self
131 }
132
133 #[must_use]
138 pub fn with_otel(mut self) -> Self {
139 self.config.otel = true;
140 self
141 }
142
143 #[must_use]
151 pub fn with_auth_layer(
152 mut self,
153 wrap: impl FnOnce(InnerService) -> InnerService + Send + 'static,
154 ) -> Self {
155 self.auth_layer = Some(Box::new(wrap));
156 self
157 }
158
159 #[must_use]
168 pub fn buffer_capacity(mut self, capacity: usize) -> Self {
169 self.config.buffer_capacity = capacity.max(1);
171 self
172 }
173
174 #[must_use]
179 pub fn max_redirects(mut self, max_redirects: usize) -> Self {
180 self.config.redirect.max_redirects = max_redirects;
181 self
182 }
183
184 #[must_use]
189 pub fn no_redirects(mut self) -> Self {
190 self.config.redirect = RedirectConfig::disabled();
191 self
192 }
193
194 #[must_use]
209 pub fn redirect(mut self, config: RedirectConfig) -> Self {
210 self.config.redirect = config;
211 self
212 }
213
214 #[must_use]
221 pub fn pool_idle_timeout(mut self, timeout: Option<Duration>) -> Self {
222 self.config.pool_idle_timeout = timeout;
223 self
224 }
225
226 #[must_use]
234 pub fn pool_max_idle_per_host(mut self, max: usize) -> Self {
235 self.config.pool_max_idle_per_host = max;
236 self
237 }
238
239 pub fn build(self) -> Result<crate::HttpClient, HttpError> {
244 if self.config.transport == TransportSecurity::AllowInsecureHttp {
246 tracing::warn!(
247 "insecure HTTP enabled (TransportSecurity::AllowInsecureHttp); \
248 use only for testing with mock servers"
249 );
250 }
251
252 let timeout = self.config.request_timeout;
253 let total_timeout = self.config.total_timeout;
254
255 let https = build_https_connector(self.config.tls_roots, self.config.transport)?;
257
258 let mut client_builder = Client::builder(TokioExecutor::new());
260
261 client_builder
264 .pool_timer(TokioTimer::new())
265 .pool_max_idle_per_host(self.config.pool_max_idle_per_host)
266 .http2_only(false); if let Some(idle_timeout) = self.config.pool_idle_timeout {
270 client_builder.pool_idle_timeout(idle_timeout);
271 }
272
273 let hyper_client = client_builder.build::<_, Full<Bytes>>(https);
274
275 let ua_layer = UserAgentLayer::try_new(&self.config.user_agent)?;
277
278 let redirect_policy = SecureRedirectPolicy::new(self.config.redirect.clone());
310
311 let service = ServiceBuilder::new()
313 .layer(TimeoutLayer::new(timeout))
314 .layer(ua_layer)
315 .layer(DecompressionLayer::new())
316 .layer(FollowRedirectLayer::with_policy(redirect_policy))
317 .service(hyper_client);
318
319 let service = service.map_response(map_decompression_response);
325
326 let service = service.map_err(move |e: tower::BoxError| map_tower_error(e, timeout));
328
329 let mut boxed_service = service.boxed_clone();
331
332 if let Some(wrap) = self.auth_layer {
335 boxed_service = wrap(boxed_service);
336 }
337
338 if let Some(ref retry_config) = self.config.retry {
350 let retry_layer = RetryLayer::with_total_timeout(retry_config.clone(), total_timeout);
351 let retry_service = ServiceBuilder::new()
352 .layer(retry_layer)
353 .service(boxed_service);
354 boxed_service = retry_service.boxed_clone();
355 }
356
357 if let Some(rate_limit) = self.config.rate_limit
361 && rate_limit.max_concurrent_requests < usize::MAX
362 {
363 let limited_service = ServiceBuilder::new()
364 .layer(LoadShedLayer::new())
365 .layer(ConcurrencyLimitLayer::new(
366 rate_limit.max_concurrent_requests,
367 ))
368 .service(boxed_service);
369 let limited_service = limited_service.map_err(map_load_shed_error);
371 boxed_service = limited_service.boxed_clone();
372 }
373
374 if self.config.otel {
378 let otel_service = ServiceBuilder::new()
379 .layer(OtelLayer::new())
380 .service(boxed_service);
381 boxed_service = otel_service.boxed_clone();
382 }
383
384 let buffer_capacity = self.config.buffer_capacity.max(1);
388 let buffered_service: crate::client::BufferedService =
389 Buffer::new(boxed_service, buffer_capacity);
390
391 Ok(crate::HttpClient {
392 service: buffered_service,
393 max_body_size: self.config.max_body_size,
394 transport_security: self.config.transport,
395 })
396 }
397}
398
399impl Default for HttpClientBuilder {
400 fn default() -> Self {
401 Self::new()
402 }
403}
404
405fn map_tower_error(err: tower::BoxError, timeout: Duration) -> HttpError {
411 if err.is::<tower::timeout::error::Elapsed>() {
412 return HttpError::Timeout(timeout);
413 }
414
415 match err.downcast::<HttpError>() {
417 Ok(http_err) => *http_err,
418 Err(other) => HttpError::Transport(other),
419 }
420}
421
422fn map_load_shed_error(err: tower::BoxError) -> HttpError {
424 if err.is::<tower::load_shed::error::Overloaded>() {
425 HttpError::Overloaded
426 } else {
427 match err.downcast::<HttpError>() {
429 Ok(http_err) => *http_err,
430 Err(err) => HttpError::Transport(err),
431 }
432 }
433}
434
435fn map_decompression_response<B>(response: Response<B>) -> Response<ResponseBody>
440where
441 B: hyper::body::Body<Data = Bytes> + Send + Sync + 'static,
442 B::Error: Into<Box<dyn std::error::Error + Send + Sync>>,
443{
444 let (parts, body) = response.into_parts();
445 let boxed_body: ResponseBody = body.map_err(Into::into).boxed();
449 Response::from_parts(parts, boxed_body)
450}
451
452fn build_https_connector(
466 tls_roots: TlsRootConfig,
467 transport: TransportSecurity,
468) -> Result<HttpsConnector<HttpConnector>, HttpError> {
469 let allow_http = transport == TransportSecurity::AllowInsecureHttp;
470
471 match tls_roots {
472 TlsRootConfig::WebPki => {
473 let provider = tls::get_crypto_provider();
474 let builder = hyper_rustls::HttpsConnectorBuilder::new()
475 .with_provider_and_webpki_roots(provider)
476 .map_err(|e| HttpError::Tls(Box::new(e)))?;
479 let connector = if allow_http {
480 builder.https_or_http().enable_all_versions().build()
481 } else {
482 builder.https_only().enable_all_versions().build()
483 };
484 Ok(connector)
485 }
486 TlsRootConfig::Native => {
487 let client_config = tls::native_roots_client_config()
488 .map_err(|e| HttpError::Tls(e.into()))?;
490 let builder = hyper_rustls::HttpsConnectorBuilder::new().with_tls_config(client_config);
491 let connector = if allow_http {
492 builder.https_or_http().enable_all_versions().build()
493 } else {
494 builder.https_only().enable_all_versions().build()
495 };
496 Ok(connector)
497 }
498 }
499}
500
501#[cfg(test)]
502#[cfg_attr(coverage_nightly, coverage(off))]
503mod tests {
504 use super::*;
505 use crate::config::DEFAULT_USER_AGENT;
506
507 #[test]
508 fn test_builder_default() {
509 let builder = HttpClientBuilder::new();
510 assert_eq!(builder.config.request_timeout, Duration::from_secs(30));
511 assert_eq!(builder.config.user_agent, DEFAULT_USER_AGENT);
512 assert!(builder.config.retry.is_some());
513 assert_eq!(builder.config.buffer_capacity, 1024);
514 }
515
516 #[test]
517 fn test_builder_with_config() {
518 let config = HttpClientConfig::minimal();
519 let builder = HttpClientBuilder::with_config(config);
520 assert_eq!(builder.config.request_timeout, Duration::from_secs(10));
521 }
522
523 #[test]
524 fn test_builder_timeout() {
525 let builder = HttpClientBuilder::new().timeout(Duration::from_secs(60));
526 assert_eq!(builder.config.request_timeout, Duration::from_secs(60));
527 }
528
529 #[test]
530 fn test_builder_user_agent() {
531 let builder = HttpClientBuilder::new().user_agent("custom/1.0");
532 assert_eq!(builder.config.user_agent, "custom/1.0");
533 }
534
535 #[test]
536 fn test_builder_retry() {
537 let builder = HttpClientBuilder::new().retry(None);
538 assert!(builder.config.retry.is_none());
539 }
540
541 #[test]
542 fn test_builder_max_body_size() {
543 let builder = HttpClientBuilder::new().max_body_size(1024);
544 assert_eq!(builder.config.max_body_size, 1024);
545 }
546
547 #[test]
548 fn test_builder_transport_security() {
549 let builder = HttpClientBuilder::new().transport(TransportSecurity::AllowInsecureHttp);
550 assert_eq!(
551 builder.config.transport,
552 TransportSecurity::AllowInsecureHttp
553 );
554
555 let builder = HttpClientBuilder::new().allow_insecure_http();
556 assert_eq!(
557 builder.config.transport,
558 TransportSecurity::AllowInsecureHttp
559 );
560
561 let builder = HttpClientBuilder::new();
562 assert_eq!(builder.config.transport, TransportSecurity::TlsOnly);
563 }
564
565 #[test]
566 fn test_builder_otel() {
567 let builder = HttpClientBuilder::new().with_otel();
568 assert!(builder.config.otel);
569 }
570
571 #[test]
572 fn test_builder_buffer_capacity() {
573 let builder = HttpClientBuilder::new().buffer_capacity(512);
574 assert_eq!(builder.config.buffer_capacity, 512);
575 }
576
577 #[test]
581 fn test_builder_buffer_capacity_zero_clamped() {
582 let builder = HttpClientBuilder::new().buffer_capacity(0);
583 assert_eq!(
584 builder.config.buffer_capacity, 1,
585 "buffer_capacity=0 should be clamped to 1"
586 );
587 }
588
589 #[tokio::test]
591 async fn test_builder_buffer_capacity_zero_in_config_clamped() {
592 let config = HttpClientConfig {
593 buffer_capacity: 0, ..Default::default()
595 };
596 let result = HttpClientBuilder::with_config(config).build();
597 assert!(
599 result.is_ok(),
600 "build() should succeed with capacity clamped to 1"
601 );
602 }
603
604 #[tokio::test]
605 async fn test_builder_build_with_otel() {
606 let client = HttpClientBuilder::new().with_otel().build();
607 assert!(client.is_ok());
608 }
609
610 #[tokio::test]
611 async fn test_builder_with_auth_layer() {
612 let client = HttpClientBuilder::new()
613 .allow_insecure_http()
614 .with_auth_layer(|svc| svc) .build();
616 assert!(client.is_ok());
617 }
618
619 #[tokio::test]
620 async fn test_builder_build() {
621 let client = HttpClientBuilder::new().build();
622 assert!(client.is_ok());
623 }
624
625 #[tokio::test]
626 async fn test_builder_build_with_insecure_http() {
627 let client = HttpClientBuilder::new().allow_insecure_http().build();
628 assert!(client.is_ok());
629 }
630
631 #[tokio::test]
632 async fn test_builder_build_with_sse_config() {
633 use crate::config::HttpClientConfig;
634 let config = HttpClientConfig::sse();
635 let client = HttpClientBuilder::with_config(config).build();
636 assert!(client.is_ok(), "SSE config should build successfully");
637 }
638
639 #[tokio::test]
640 async fn test_builder_build_invalid_user_agent() {
641 let client = HttpClientBuilder::new()
642 .user_agent("invalid\x00agent")
643 .build();
644 assert!(client.is_err());
645 }
646
647 #[tokio::test]
648 async fn test_builder_default_uses_webpki_roots() {
649 let builder = HttpClientBuilder::new();
650 assert_eq!(builder.config.tls_roots, TlsRootConfig::WebPki);
651 let client = builder.build();
653 assert!(client.is_ok());
654 }
655
656 #[tokio::test]
657 async fn test_builder_native_roots() {
658 let config = HttpClientConfig {
659 tls_roots: TlsRootConfig::Native,
660 ..Default::default()
661 };
662 let result = HttpClientBuilder::with_config(config).build();
663
664 match &result {
668 Ok(_) => {
669 }
671 Err(HttpError::Tls(err)) => {
672 let msg = err.to_string();
674 assert!(
675 msg.contains("native root") || msg.contains("certificate"),
676 "TLS error should mention certificates: {msg}"
677 );
678 }
679 Err(other) => {
680 panic!("Unexpected error type: {other:?}");
681 }
682 }
683 }
684
685 #[tokio::test]
686 async fn test_builder_webpki_roots_https_only() {
687 let config = HttpClientConfig {
688 tls_roots: TlsRootConfig::WebPki,
689 transport: TransportSecurity::TlsOnly,
690 ..Default::default()
691 };
692 let client = HttpClientBuilder::with_config(config).build();
693 assert!(client.is_ok());
694 }
695
696 #[tokio::test]
702 async fn test_http2_enabled_for_all_configurations() {
703 let client = HttpClientBuilder::new().allow_insecure_http().build();
705 assert!(
706 client.is_ok(),
707 "WebPki + AllowInsecureHttp should build with HTTP/2 enabled"
708 );
709
710 let client = HttpClientBuilder::new()
712 .transport(TransportSecurity::TlsOnly)
713 .build();
714 assert!(
715 client.is_ok(),
716 "WebPki + TlsOnly should build with HTTP/2 enabled"
717 );
718
719 let config = HttpClientConfig {
721 tls_roots: TlsRootConfig::Native,
722 transport: TransportSecurity::AllowInsecureHttp,
723 ..Default::default()
724 };
725 let client = HttpClientBuilder::with_config(config).build();
726 assert!(
727 client.is_ok(),
728 "Native + AllowInsecureHttp should build with HTTP/2 enabled"
729 );
730
731 let config = HttpClientConfig {
733 tls_roots: TlsRootConfig::Native,
734 transport: TransportSecurity::TlsOnly,
735 ..Default::default()
736 };
737 let client = HttpClientBuilder::with_config(config).build();
738 assert!(
739 client.is_ok(),
740 "Native + TlsOnly should build with HTTP/2 enabled"
741 );
742 }
743
744 #[tokio::test]
749 async fn test_load_shedding_returns_overloaded_error() {
750 use bytes::Bytes;
751 use http::{Request, Response};
752 use http_body_util::Full;
753 use std::future::Future;
754 use std::pin::Pin;
755 use std::sync::Arc;
756 use std::sync::atomic::{AtomicUsize, Ordering};
757 use std::task::{Context, Poll};
758 use tower::Service;
759 use tower::ServiceExt;
760
761 #[derive(Clone)]
763 struct SlotHoldingService {
764 active: Arc<AtomicUsize>,
765 }
766
767 impl Service<Request<Full<Bytes>>> for SlotHoldingService {
768 type Response = Response<Full<Bytes>>;
769 type Error = HttpError;
770 type Future = Pin<Box<dyn Future<Output = Result<Self::Response, Self::Error>> + Send>>;
771
772 fn poll_ready(&mut self, _: &mut Context<'_>) -> Poll<Result<(), Self::Error>> {
773 Poll::Ready(Ok(()))
774 }
775
776 fn call(&mut self, _: Request<Full<Bytes>>) -> Self::Future {
777 self.active.fetch_add(1, Ordering::SeqCst);
778 Box::pin(std::future::pending())
780 }
781 }
782
783 let active = Arc::new(AtomicUsize::new(0));
784
785 let service = tower::ServiceBuilder::new()
787 .layer(LoadShedLayer::new())
788 .layer(ConcurrencyLimitLayer::new(1))
789 .service(SlotHoldingService {
790 active: active.clone(),
791 });
792
793 let service = service.map_err(map_load_shed_error);
794
795 let req1 = Request::builder()
797 .uri("http://test")
798 .body(Full::new(Bytes::new()))
799 .unwrap();
800 let mut svc1 = service.clone();
801
802 let svc1_ready = svc1.ready().await.unwrap();
803 let _pending_fut = svc1_ready.call(req1);
804
805 tokio::time::sleep(Duration::from_millis(10)).await;
807 assert_eq!(
808 active.load(Ordering::SeqCst),
809 1,
810 "First request should be active"
811 );
812
813 let req2 = Request::builder()
815 .uri("http://test")
816 .body(Full::new(Bytes::new()))
817 .unwrap();
818
819 let mut svc2 = service.clone();
820
821 let result = tokio::time::timeout(Duration::from_millis(100), async {
823 match svc2.ready().await {
825 Ok(ready_svc) => ready_svc.call(req2).await,
826 Err(e) => Err(e),
827 }
828 })
829 .await;
830
831 assert!(result.is_ok(), "Request should not hang");
833 let err = result.unwrap().unwrap_err();
834 assert!(
835 matches!(err, HttpError::Overloaded),
836 "Expected Overloaded error, got: {err:?}"
837 );
838 }
839
840 #[tokio::test]
842 async fn test_insecure_http_warning_emitted() {
843 use std::sync::{Arc, Mutex};
844 use tracing_subscriber::layer::SubscriberExt;
845
846 #[derive(Clone, Default)]
848 struct WarningCapture {
849 warnings: Arc<Mutex<Vec<String>>>,
850 }
851
852 impl<S: tracing::Subscriber> tracing_subscriber::Layer<S> for WarningCapture {
853 fn on_event(
854 &self,
855 event: &tracing::Event<'_>,
856 _ctx: tracing_subscriber::layer::Context<'_, S>,
857 ) {
858 if *event.metadata().level() == tracing::Level::WARN {
859 let mut visitor = MessageVisitor(String::new());
860 event.record(&mut visitor);
861 self.warnings.lock().unwrap().push(visitor.0);
862 }
863 }
864 }
865
866 struct MessageVisitor(String);
867 impl tracing::field::Visit for MessageVisitor {
868 fn record_debug(&mut self, field: &tracing::field::Field, value: &dyn std::fmt::Debug) {
869 if field.name() == "message" {
870 self.0 = format!("{value:?}");
871 }
872 }
873 }
874
875 let capture = WarningCapture::default();
876 let warnings = capture.warnings.clone();
877
878 let subscriber = tracing_subscriber::registry().with(capture);
879
880 tracing::subscriber::with_default(subscriber, || {
882 let _ = HttpClientBuilder::new().allow_insecure_http().build();
883 });
884
885 let captured = warnings.lock().unwrap();
886 assert!(
888 !captured.is_empty(),
889 "expected at least one warning, got: {:?}",
890 *captured
891 );
892 assert!(
893 captured
894 .iter()
895 .any(|w| w.contains("insecure HTTP") || w.contains("HTTP traffic")),
896 "warning should mention insecure HTTP: {:?}",
897 *captured
898 );
899 }
900
901 #[tokio::test]
903 async fn test_tls_only_no_warning() {
904 use std::sync::{Arc, Mutex};
905 use tracing_subscriber::layer::SubscriberExt;
906
907 #[derive(Clone, Default)]
908 struct WarningCapture {
909 warnings: Arc<Mutex<Vec<String>>>,
910 }
911
912 impl<S: tracing::Subscriber> tracing_subscriber::Layer<S> for WarningCapture {
913 fn on_event(
914 &self,
915 event: &tracing::Event<'_>,
916 _ctx: tracing_subscriber::layer::Context<'_, S>,
917 ) {
918 if *event.metadata().level() == tracing::Level::WARN {
919 let mut visitor = MessageVisitor(String::new());
920 event.record(&mut visitor);
921 if visitor.0.contains("insecure HTTP") {
922 self.warnings.lock().unwrap().push(visitor.0);
923 }
924 }
925 }
926 }
927
928 struct MessageVisitor(String);
929 impl tracing::field::Visit for MessageVisitor {
930 fn record_debug(&mut self, field: &tracing::field::Field, value: &dyn std::fmt::Debug) {
931 if field.name() == "message" {
932 self.0 = format!("{value:?}");
933 }
934 }
935 }
936
937 let capture = WarningCapture::default();
938 let warnings = capture.warnings.clone();
939
940 let subscriber = tracing_subscriber::registry().with(capture);
941
942 tracing::subscriber::with_default(subscriber, || {
944 let _ = HttpClientBuilder::new()
945 .transport(TransportSecurity::TlsOnly)
946 .build();
947 });
948
949 let captured = warnings.lock().unwrap();
950 assert!(
951 captured.is_empty(),
952 "no insecure HTTP warning expected, got: {:?}",
953 *captured
954 );
955 }
956
957 #[test]
963 fn test_map_tower_error_preserves_overloaded() {
964 let http_err = HttpError::Overloaded;
965 let boxed: tower::BoxError = Box::new(http_err);
966 let result = map_tower_error(boxed, Duration::from_secs(30));
967
968 assert!(
969 matches!(result, HttpError::Overloaded),
970 "Should preserve HttpError::Overloaded, got: {result:?}"
971 );
972 }
973
974 #[test]
976 fn test_map_tower_error_preserves_service_closed() {
977 let http_err = HttpError::ServiceClosed;
978 let boxed: tower::BoxError = Box::new(http_err);
979 let result = map_tower_error(boxed, Duration::from_secs(30));
980
981 assert!(
982 matches!(result, HttpError::ServiceClosed),
983 "Should preserve HttpError::ServiceClosed, got: {result:?}"
984 );
985 }
986
987 #[test]
989 fn test_map_tower_error_preserves_timeout_attempt() {
990 let original_duration = Duration::from_secs(5);
991 let http_err = HttpError::Timeout(original_duration);
992 let boxed: tower::BoxError = Box::new(http_err);
993 let result = map_tower_error(boxed, Duration::from_secs(30));
995
996 match result {
997 HttpError::Timeout(d) => {
998 assert_eq!(
999 d, original_duration,
1000 "Should preserve original timeout duration"
1001 );
1002 }
1003 other => panic!("Should preserve HttpError::Timeout, got: {other:?}"),
1004 }
1005 }
1006
1007 #[test]
1009 fn test_map_tower_error_wraps_unknown_as_transport() {
1010 let other_err: tower::BoxError = Box::new(std::io::Error::new(
1011 std::io::ErrorKind::ConnectionRefused,
1012 "connection refused",
1013 ));
1014 let result = map_tower_error(other_err, Duration::from_secs(30));
1015
1016 assert!(
1017 matches!(result, HttpError::Transport(_)),
1018 "Should wrap unknown errors as Transport, got: {result:?}"
1019 );
1020 }
1021}