1use std::{borrow::Cow, collections::HashMap, str::FromStr, sync::Arc, time::Duration};
19
20use nautilus_core::collections::into_ustr_vec;
21use nautilus_cryptography::providers::install_cryptographic_provider;
22use reqwest::{
23 Method, Response, Url,
24 header::{HeaderMap, HeaderName, HeaderValue},
25};
26use ustr::Ustr;
27
28use super::{HttpClientError, HttpResponse, HttpStatus};
29use crate::ratelimiter::{RateLimiter, clock::MonotonicClock, quota::Quota};
30
31const DEFAULT_POOL_MAX_IDLE_PER_HOST: usize = 32;
33
34const DEFAULT_POOL_IDLE_TIMEOUT_SECS: u64 = 60;
36
37const DEFAULT_HTTP2_KEEP_ALIVE_SECS: u64 = 30;
39
40const DEFAULT_MAX_RESPONSE_BYTES: usize = 100 * 1024 * 1024;
46
47#[derive(Clone, Debug)]
57#[cfg_attr(
58 feature = "python",
59 pyo3::pyclass(module = "nautilus_trader.core.nautilus_pyo3.network", from_py_object)
60)]
61#[cfg_attr(
62 feature = "python",
63 pyo3_stub_gen::derive::gen_stub_pyclass(module = "nautilus_trader.network")
64)]
65pub struct HttpClient {
66 pub(crate) client: InnerHttpClient,
68 pub(crate) rate_limiters: Arc<[Arc<RateLimiter<Ustr, MonotonicClock>>]>,
70}
71
72impl HttpClient {
73 pub fn new(
80 headers: HashMap<String, String>,
81 header_keys: Vec<String>,
82 keyed_quotas: Vec<(String, Quota)>,
83 default_quota: Option<Quota>,
84 timeout_secs: Option<u64>,
85 proxy_url: Option<String>,
86 ) -> Result<Self, HttpClientError> {
87 let keyed_quotas = keyed_quotas
88 .into_iter()
89 .map(|(key, quota)| (Ustr::from(&key), quota))
90 .collect();
91
92 let rate_limiter = Arc::new(RateLimiter::new_with_quota(default_quota, keyed_quotas));
93
94 Self::new_with_rate_limiter(headers, header_keys, timeout_secs, proxy_url, rate_limiter)
95 }
96
97 pub fn new_with_rate_limiter(
110 headers: HashMap<String, String>,
111 header_keys: Vec<String>,
112 timeout_secs: Option<u64>,
113 proxy_url: Option<String>,
114 rate_limiter: Arc<RateLimiter<Ustr, MonotonicClock>>,
115 ) -> Result<Self, HttpClientError> {
116 Self::new_with_rate_limiters(
117 headers,
118 header_keys,
119 timeout_secs,
120 proxy_url,
121 vec![rate_limiter],
122 )
123 }
124
125 pub fn new_with_rate_limiters(
136 headers: HashMap<String, String>,
137 header_keys: Vec<String>,
138 timeout_secs: Option<u64>,
139 proxy_url: Option<String>,
140 rate_limiters: Vec<Arc<RateLimiter<Ustr, MonotonicClock>>>,
141 ) -> Result<Self, HttpClientError> {
142 install_cryptographic_provider();
143
144 let mut header_map = HeaderMap::new();
146
147 for (key, value) in headers {
148 let header_name = HeaderName::from_str(&key)
149 .map_err(|e| HttpClientError::Error(format!("Invalid header name '{key}': {e}")))?;
150 let header_value = HeaderValue::from_str(&value).map_err(|e| {
151 HttpClientError::Error(format!("Invalid header value '{value}': {e}"))
152 })?;
153 header_map.insert(header_name, header_value);
154 }
155
156 let mut client_builder = reqwest::Client::builder()
157 .default_headers(header_map)
158 .tcp_nodelay(true)
159 .pool_max_idle_per_host(DEFAULT_POOL_MAX_IDLE_PER_HOST)
160 .pool_idle_timeout(Duration::from_secs(DEFAULT_POOL_IDLE_TIMEOUT_SECS))
161 .http2_keep_alive_interval(Duration::from_secs(DEFAULT_HTTP2_KEEP_ALIVE_SECS))
162 .http2_keep_alive_while_idle(true)
163 .http2_adaptive_window(true);
164
165 if let Some(timeout_secs) = timeout_secs {
166 client_builder = client_builder.timeout(Duration::from_secs(timeout_secs));
167 }
168
169 if let Some(proxy_url) = proxy_url {
171 let proxy = reqwest::Proxy::all(&proxy_url)
172 .map_err(|_| HttpClientError::InvalidProxy("proxy URL is malformed".to_string()))?;
173 client_builder = client_builder.proxy(proxy);
174 }
175
176 let client = client_builder
177 .build()
178 .map_err(|e| HttpClientError::ClientBuildError(e.to_string()))?;
179
180 let (valid_keys, header_names): (Vec<String>, Vec<HeaderName>) = header_keys
183 .into_iter()
184 .map(|k| {
185 HeaderName::from_str(&k)
186 .map(|name| (k.clone(), name))
187 .map_err(|e| HttpClientError::Error(format!("Invalid header key '{k}': {e}")))
188 })
189 .collect::<Result<Vec<_>, _>>()?
190 .into_iter()
191 .unzip();
192
193 let client = InnerHttpClient {
194 client,
195 header_keys: Arc::from(valid_keys),
196 header_names: Arc::from(header_names),
197 max_response_bytes: DEFAULT_MAX_RESPONSE_BYTES,
198 };
199
200 Ok(Self {
201 client,
202 rate_limiters: rate_limiters.into(),
203 })
204 }
205
206 #[expect(clippy::too_many_arguments)]
216 pub async fn request(
217 &self,
218 method: Method,
219 url: String,
220 params: Option<&HashMap<String, Vec<String>>>,
221 headers: Option<HashMap<String, String>>,
222 body: Option<Vec<u8>>,
223 timeout_secs: Option<u64>,
224 keys: Option<Vec<String>>,
225 ) -> Result<HttpResponse, HttpClientError> {
226 let keys = keys.map(into_ustr_vec);
227
228 self.request_with_ustr_keys(method, url, params, headers, body, timeout_secs, keys)
229 .await
230 }
231
232 #[expect(clippy::too_many_arguments)]
242 pub async fn request_with_params<P: serde::Serialize>(
243 &self,
244 method: Method,
245 url: String,
246 params: Option<&P>,
247 headers: Option<HashMap<String, String>>,
248 body: Option<Vec<u8>>,
249 timeout_secs: Option<u64>,
250 keys: Option<Vec<String>>,
251 ) -> Result<HttpResponse, HttpClientError> {
252 let keys = keys.map(into_ustr_vec);
253 self.await_rate_limits(keys.as_deref()).await;
254
255 self.client
256 .send_request_with_query(method, url, params, headers, body, timeout_secs)
257 .await
258 }
259
260 #[expect(clippy::too_many_arguments)]
266 pub async fn request_with_ustr_keys(
267 &self,
268 method: Method,
269 url: String,
270 params: Option<&HashMap<String, Vec<String>>>,
271 headers: Option<HashMap<String, String>>,
272 body: Option<Vec<u8>>,
273 timeout_secs: Option<u64>,
274 keys: Option<Vec<Ustr>>,
275 ) -> Result<HttpResponse, HttpClientError> {
276 self.await_rate_limits(keys.as_deref()).await;
277
278 self.client
279 .send_request(method, url, params, headers, body, timeout_secs)
280 .await
281 }
282
283 pub(crate) async fn await_rate_limits(&self, keys: Option<&[Ustr]>) {
284 for rate_limiter in self.rate_limiters.iter() {
285 rate_limiter.await_keys_ready(keys).await;
286 }
287 }
288
289 pub async fn get(
295 &self,
296 url: String,
297 params: Option<&HashMap<String, Vec<String>>>,
298 headers: Option<HashMap<String, String>>,
299 timeout_secs: Option<u64>,
300 keys: Option<Vec<String>>,
301 ) -> Result<HttpResponse, HttpClientError> {
302 self.request(Method::GET, url, params, headers, None, timeout_secs, keys)
303 .await
304 }
305
306 pub async fn post(
312 &self,
313 url: String,
314 params: Option<&HashMap<String, Vec<String>>>,
315 headers: Option<HashMap<String, String>>,
316 body: Option<Vec<u8>>,
317 timeout_secs: Option<u64>,
318 keys: Option<Vec<String>>,
319 ) -> Result<HttpResponse, HttpClientError> {
320 self.request(Method::POST, url, params, headers, body, timeout_secs, keys)
321 .await
322 }
323
324 pub async fn patch(
330 &self,
331 url: String,
332 params: Option<&HashMap<String, Vec<String>>>,
333 headers: Option<HashMap<String, String>>,
334 body: Option<Vec<u8>>,
335 timeout_secs: Option<u64>,
336 keys: Option<Vec<String>>,
337 ) -> Result<HttpResponse, HttpClientError> {
338 self.request(
339 Method::PATCH,
340 url,
341 params,
342 headers,
343 body,
344 timeout_secs,
345 keys,
346 )
347 .await
348 }
349
350 pub async fn delete(
356 &self,
357 url: String,
358 params: Option<&HashMap<String, Vec<String>>>,
359 headers: Option<HashMap<String, String>>,
360 timeout_secs: Option<u64>,
361 keys: Option<Vec<String>>,
362 ) -> Result<HttpResponse, HttpClientError> {
363 self.request(
364 Method::DELETE,
365 url,
366 params,
367 headers,
368 None,
369 timeout_secs,
370 keys,
371 )
372 .await
373 }
374}
375
376#[derive(Clone, Debug)]
385pub struct InnerHttpClient {
386 pub(crate) client: reqwest::Client,
387 pub(crate) header_keys: Arc<[String]>,
388 pub(crate) header_names: Arc<[HeaderName]>,
389 pub(crate) max_response_bytes: usize,
391}
392
393impl InnerHttpClient {
394 pub async fn send_request(
400 &self,
401 method: Method,
402 url: String,
403 params: Option<&HashMap<String, Vec<String>>>,
404 headers: Option<HashMap<String, String>>,
405 body: Option<Vec<u8>>,
406 timeout_secs: Option<u64>,
407 ) -> Result<HttpResponse, HttpClientError> {
408 let full_url = encode_url_params(&url, params)?;
409 self.send_request_internal(
410 method,
411 full_url.as_ref(),
412 None::<&()>,
413 headers,
414 body,
415 timeout_secs,
416 )
417 .await
418 }
419
420 pub async fn send_request_with_query<Q: serde::Serialize>(
429 &self,
430 method: Method,
431 url: String,
432 query: Option<&Q>,
433 headers: Option<HashMap<String, String>>,
434 body: Option<Vec<u8>>,
435 timeout_secs: Option<u64>,
436 ) -> Result<HttpResponse, HttpClientError> {
437 self.send_request_internal(method, &url, query, headers, body, timeout_secs)
438 .await
439 }
440
441 async fn send_request_internal<Q: serde::Serialize>(
447 &self,
448 method: Method,
449 url: &str,
450 query: Option<&Q>,
451 headers: Option<HashMap<String, String>>,
452 body: Option<Vec<u8>>,
453 timeout_secs: Option<u64>,
454 ) -> Result<HttpResponse, HttpClientError> {
455 let reqwest_url =
456 Url::parse(url).map_err(|e| HttpClientError::from(format!("URL parse error: {e}")))?;
457
458 let mut request_builder = self.client.request(method, reqwest_url);
459
460 if let Some(headers) = headers {
461 let mut header_map = HeaderMap::with_capacity(headers.len());
462 for (header_key, header_value) in &headers {
463 let key = HeaderName::from_bytes(header_key.as_bytes())
464 .map_err(|e| HttpClientError::from(format!("Invalid header name: {e}")))?;
465
466 if let Some(old_value) = header_map.insert(
467 key.clone(),
468 header_value
469 .parse()
470 .map_err(|e| HttpClientError::from(format!("Invalid header value: {e}")))?,
471 ) {
472 log::trace!("Replaced header '{key}': old={old_value:?}, new={header_value}");
473 }
474 }
475 request_builder = request_builder.headers(header_map);
476 }
477
478 if let Some(q) = query {
479 request_builder = request_builder.query(q);
480 }
481
482 if let Some(timeout_secs) = timeout_secs {
483 request_builder = request_builder.timeout(Duration::new(timeout_secs, 0));
484 }
485
486 let request = match body {
487 Some(b) => request_builder
488 .body(b)
489 .build()
490 .map_err(HttpClientError::from)?,
491 None => request_builder.build().map_err(HttpClientError::from)?,
492 };
493
494 log::trace!("{} {}", request.method(), request.url());
495
496 let response = self
497 .client
498 .execute(request)
499 .await
500 .map_err(HttpClientError::from)?;
501
502 self.to_response(response).await
503 }
504
505 pub async fn to_response(&self, response: Response) -> Result<HttpResponse, HttpClientError> {
513 log::trace!("{response:?}");
514
515 let resp_headers = response.headers();
516 let mut headers =
517 HashMap::with_capacity(std::cmp::min(self.header_names.len(), resp_headers.len()));
518
519 for (name, key_str) in self.header_names.iter().zip(self.header_keys.iter()) {
520 if let Some(val) = resp_headers.get(name)
521 && let Ok(v) = val.to_str()
522 {
523 headers.insert(key_str.clone(), v.to_owned());
524 }
525 }
526
527 let status = HttpStatus::new(response.status());
528 let body = self.read_body_capped(response).await?;
529
530 Ok(HttpResponse {
531 status,
532 headers,
533 body,
534 })
535 }
536
537 async fn read_body_capped(
549 &self,
550 mut response: Response,
551 ) -> Result<bytes::Bytes, HttpClientError> {
552 let max = self.max_response_bytes;
553
554 if let Some(len) = response.content_length()
556 && len > max as u64
557 {
558 return Err(HttpClientError::Error(format!(
559 "HTTP response body of {len} bytes exceeds maximum of {max} bytes",
560 )));
561 }
562
563 let mut buf = bytes::BytesMut::new();
564 while let Some(chunk) = response.chunk().await.map_err(HttpClientError::from)? {
565 if buf.len() + chunk.len() > max {
566 return Err(HttpClientError::Error(format!(
567 "HTTP response body exceeds maximum of {max} bytes",
568 )));
569 }
570 buf.extend_from_slice(&chunk);
571 }
572
573 Ok(buf.freeze())
574 }
575}
576
577impl Default for InnerHttpClient {
578 fn default() -> Self {
582 install_cryptographic_provider();
583 let client = reqwest::Client::new();
584 Self {
585 client,
586 header_keys: Arc::default(),
587 header_names: Arc::default(),
588 max_response_bytes: DEFAULT_MAX_RESPONSE_BYTES,
589 }
590 }
591}
592
593fn encode_url_params<'a>(
599 url: &'a str,
600 params: Option<&HashMap<String, Vec<String>>>,
601) -> Result<Cow<'a, str>, HttpClientError> {
602 let Some(params) = params else {
603 return Ok(Cow::Borrowed(url));
604 };
605
606 let pairs: Vec<(&str, &str)> = params
607 .iter()
608 .flat_map(|(key, values)| {
609 values
610 .iter()
611 .map(move |value| (key.as_str(), value.as_str()))
612 })
613 .collect();
614
615 if pairs.is_empty() {
616 return Ok(Cow::Borrowed(url));
617 }
618
619 let query_string = serde_urlencoded::to_string(pairs)
620 .map_err(|e| HttpClientError::Error(format!("Failed to encode params: {e}")))?;
621
622 let separator = if url.contains('?') { '&' } else { '?' };
623 Ok(Cow::Owned(format!("{url}{separator}{query_string}")))
624}
625
626#[cfg(test)]
627#[cfg(target_os = "linux")] mod tests {
629 use std::{net::SocketAddr, num::NonZeroU32};
630
631 use axum::{
632 Router,
633 routing::{delete, get, patch, post},
634 serve,
635 };
636 use base64::{Engine as _, engine::general_purpose::STANDARD as BASE64};
637 use http::status::StatusCode;
638 use rstest::rstest;
639 use tokio::{
640 io::{AsyncReadExt, AsyncWriteExt},
641 sync::oneshot,
642 };
643
644 use super::*;
645
646 fn create_router() -> Router {
647 Router::new()
648 .route("/get", get(|| async { "hello-world!" }))
649 .route("/post", post(|| async { StatusCode::OK }))
650 .route("/patch", patch(|| async { StatusCode::OK }))
651 .route("/delete", delete(|| async { StatusCode::OK }))
652 .route("/notfound", get(|| async { StatusCode::NOT_FOUND }))
653 .route(
654 "/slow",
655 get(|| async {
656 tokio::time::sleep(Duration::from_secs(2)).await;
657 "Eventually responded"
658 }),
659 )
660 .route(
661 "/large",
662 get(|| async { "x".repeat(1024 * 1024) }),
664 )
665 }
666
667 async fn start_test_server() -> Result<SocketAddr, Box<dyn std::error::Error + Send + Sync>> {
668 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
669 let addr = listener.local_addr().unwrap();
670
671 tokio::spawn(async move {
672 serve(listener, create_router()).await.unwrap();
673 });
674
675 Ok(addr)
676 }
677
678 async fn spawn_rejecting_connect_proxy() -> (SocketAddr, oneshot::Receiver<String>) {
679 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
680 let addr = listener.local_addr().unwrap();
681 let (request_tx, request_rx) = oneshot::channel();
682
683 tokio::spawn(async move {
684 let (mut stream, _) = listener.accept().await.unwrap();
685 let mut request = Vec::new();
686 let mut chunk = [0u8; 1024];
687 loop {
688 let read = stream.read(&mut chunk).await.unwrap();
689 if read == 0 {
690 break;
691 }
692 request.extend_from_slice(&chunk[..read]);
693 if request.windows(4).any(|window| window == b"\r\n\r\n") {
694 break;
695 }
696 }
697 request_tx
698 .send(String::from_utf8(request).unwrap())
699 .unwrap();
700 stream
701 .write_all(
702 b"HTTP/1.1 407 Proxy Authentication Required\r\nContent-Length: 0\r\n\r\n",
703 )
704 .await
705 .unwrap();
706 });
707
708 (addr, request_rx)
709 }
710
711 #[tokio::test]
712 async fn test_http_client_awaits_multiple_rate_limiters() {
713 let quota = Quota::per_minute(NonZeroU32::MIN);
714 let request_key = Ustr::from("scope:request");
715 let order_key = Ustr::from("scope:order");
716 let request_limiter = Arc::new(RateLimiter::new_with_quota(
717 None,
718 vec![(request_key, quota)],
719 ));
720 let order_limiter = Arc::new(RateLimiter::new_with_quota(None, vec![(order_key, quota)]));
721 let client = HttpClient::new_with_rate_limiters(
722 HashMap::new(),
723 Vec::new(),
724 None,
725 None,
726 vec![Arc::clone(&request_limiter), Arc::clone(&order_limiter)],
727 )
728 .unwrap();
729
730 client
731 .await_rate_limits(Some(&[request_key, order_key]))
732 .await;
733
734 assert!(request_limiter.check_key(&request_key).is_err());
735 assert!(order_limiter.check_key(&order_key).is_err());
736 }
737
738 #[tokio::test]
739 async fn test_get() {
740 let addr = start_test_server().await.unwrap();
741 let url = format!("http://{addr}");
742
743 let client = InnerHttpClient::default();
744 let response = client
745 .send_request(
746 reqwest::Method::GET,
747 format!("{url}/get"),
748 None,
749 None,
750 None,
751 None,
752 )
753 .await
754 .unwrap();
755
756 assert!(response.status.is_success());
757 assert_eq!(String::from_utf8_lossy(&response.body), "hello-world!");
758 }
759
760 #[tokio::test]
761 async fn test_response_body_within_cap_is_returned() {
762 let addr = start_test_server().await.unwrap();
763 let url = format!("http://{addr}");
764
765 let client = InnerHttpClient {
767 max_response_bytes: 4 * 1024 * 1024,
768 ..Default::default()
769 };
770
771 let response = client
772 .send_request(
773 reqwest::Method::GET,
774 format!("{url}/large"),
775 None,
776 None,
777 None,
778 None,
779 )
780 .await
781 .unwrap();
782
783 assert!(response.status.is_success());
784 assert_eq!(response.body.len(), 1024 * 1024);
785 }
786
787 #[tokio::test]
788 async fn test_response_body_exceeding_cap_is_rejected() {
789 let addr = start_test_server().await.unwrap();
790 let url = format!("http://{addr}");
791
792 let client = InnerHttpClient {
794 max_response_bytes: 16 * 1024,
795 ..Default::default()
796 };
797
798 let result = client
799 .send_request(
800 reqwest::Method::GET,
801 format!("{url}/large"),
802 None,
803 None,
804 None,
805 None,
806 )
807 .await;
808
809 let err = result.expect_err("oversized response body should be rejected");
810 assert!(
811 err.to_string().contains("exceeds maximum"),
812 "unexpected error: {err}",
813 );
814 }
815
816 #[tokio::test]
817 async fn test_post() {
818 let addr = start_test_server().await.unwrap();
819 let url = format!("http://{addr}");
820
821 let client = InnerHttpClient::default();
822 let response = client
823 .send_request(
824 reqwest::Method::POST,
825 format!("{url}/post"),
826 None,
827 None,
828 None,
829 None,
830 )
831 .await
832 .unwrap();
833
834 assert!(response.status.is_success());
835 }
836
837 #[tokio::test]
838 async fn test_post_with_body() {
839 let addr = start_test_server().await.unwrap();
840 let url = format!("http://{addr}");
841
842 let client = InnerHttpClient::default();
843
844 let mut body = HashMap::new();
845 body.insert(
846 "key1".to_string(),
847 serde_json::Value::String("value1".to_string()),
848 );
849 body.insert(
850 "key2".to_string(),
851 serde_json::Value::String("value2".to_string()),
852 );
853
854 let body_string = serde_json::to_string(&body).unwrap();
855 let body_bytes = body_string.into_bytes();
856
857 let response = client
858 .send_request(
859 reqwest::Method::POST,
860 format!("{url}/post"),
861 None,
862 None,
863 Some(body_bytes),
864 None,
865 )
866 .await
867 .unwrap();
868
869 assert!(response.status.is_success());
870 }
871
872 #[tokio::test]
873 async fn test_patch() {
874 let addr = start_test_server().await.unwrap();
875 let url = format!("http://{addr}");
876
877 let client = InnerHttpClient::default();
878 let response = client
879 .send_request(
880 reqwest::Method::PATCH,
881 format!("{url}/patch"),
882 None,
883 None,
884 None,
885 None,
886 )
887 .await
888 .unwrap();
889
890 assert!(response.status.is_success());
891 }
892
893 #[tokio::test]
894 async fn test_delete() {
895 let addr = start_test_server().await.unwrap();
896 let url = format!("http://{addr}");
897
898 let client = InnerHttpClient::default();
899 let response = client
900 .send_request(
901 reqwest::Method::DELETE,
902 format!("{url}/delete"),
903 None,
904 None,
905 None,
906 None,
907 )
908 .await
909 .unwrap();
910
911 assert!(response.status.is_success());
912 }
913
914 #[tokio::test]
915 async fn test_not_found() {
916 let addr = start_test_server().await.unwrap();
917 let url = format!("http://{addr}/notfound");
918 let client = InnerHttpClient::default();
919
920 let response = client
921 .send_request(reqwest::Method::GET, url, None, None, None, None)
922 .await
923 .unwrap();
924
925 assert!(response.status.is_client_error());
926 assert_eq!(response.status.as_u16(), 404);
927 }
928
929 #[tokio::test]
930 async fn test_timeout() {
931 let addr = start_test_server().await.unwrap();
932 let url = format!("http://{addr}/slow");
933 let client = InnerHttpClient::default();
934
935 let result = client
937 .send_request(reqwest::Method::GET, url, None, None, None, Some(1))
938 .await;
939
940 match result {
941 Err(HttpClientError::TimeoutError(msg)) => {
942 println!("Got expected timeout error: {msg}");
943 }
944 Err(e) => panic!("Expected a timeout error, was: {e:?}"),
945 Ok(resp) => panic!("Expected a timeout error, but was a successful response: {resp:?}"),
946 }
947 }
948
949 #[rstest]
950 fn test_http_client_without_proxy() {
951 let result = HttpClient::new(
953 HashMap::new(),
954 vec![],
955 vec![],
956 None,
957 None,
958 None, );
960
961 assert!(result.is_ok());
962 }
963
964 #[tokio::test]
965 async fn test_http_client_without_proxy_requests_directly() {
966 let addr = start_test_server().await.unwrap();
967 let client = HttpClient::new(HashMap::new(), vec![], vec![], None, Some(2), None).unwrap();
968 let response = client
969 .request(
970 Method::GET,
971 format!("http://{addr}/get"),
972 None,
973 None,
974 None,
975 None,
976 None,
977 )
978 .await
979 .expect("direct request");
980
981 assert_eq!(response.status.as_u16(), StatusCode::OK.as_u16());
982 assert_eq!(response.body.as_ref(), b"hello-world!");
983 }
984
985 #[tokio::test]
986 async fn test_http_client_uses_connect_and_proxy_authorization_for_https() {
987 const USERNAME: &str = "proxytest";
988 const PASSWORD: &str = "fixture42";
989 let (proxy_addr, request_rx) = spawn_rejecting_connect_proxy().await;
990 let client = HttpClient::new(
991 HashMap::new(),
992 vec![],
993 vec![],
994 None,
995 Some(2),
996 Some(format!("http://{USERNAME}:{PASSWORD}@{proxy_addr}")),
997 )
998 .unwrap();
999 let error = client
1000 .request(
1001 Method::GET,
1002 "https://fixture.example.test/path".to_string(),
1003 None,
1004 None,
1005 None,
1006 None,
1007 None,
1008 )
1009 .await
1010 .expect_err("proxy should reject CONNECT");
1011 let request = request_rx.await.expect("captured CONNECT request");
1012 let mut lines = request.split("\r\n");
1013 let request_line = lines.next().expect("CONNECT request line");
1014 let auth_value = lines
1015 .find_map(|line| {
1016 let (name, value) = line.split_once(':')?;
1017 name.eq_ignore_ascii_case("proxy-authorization")
1018 .then_some(value.trim())
1019 })
1020 .expect("Proxy-Authorization header");
1021 let expected_auth = format!("Basic {}", BASE64.encode(format!("{USERNAME}:{PASSWORD}")));
1022
1023 assert_eq!(request_line, "CONNECT fixture.example.test:443 HTTP/1.1");
1024 assert_eq!(auth_value, expected_auth);
1025 assert!(!error.to_string().contains(PASSWORD));
1026 assert!(!error.to_string().contains(&BASE64.encode(PASSWORD)));
1027 assert!(!error.to_string().contains(&expected_auth));
1028 }
1029
1030 #[tokio::test]
1031 async fn test_http_client_unreachable_proxy_error_redacts_credentials() {
1032 const USERNAME: &str = "proxy-user";
1033 const SECRET: &str = "unreachable-proxy-secret";
1034 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
1035 let proxy_addr = listener.local_addr().unwrap();
1036 drop(listener);
1037 let client = HttpClient::new(
1038 HashMap::new(),
1039 vec![],
1040 vec![],
1041 None,
1042 Some(1),
1043 Some(format!("http://{USERNAME}:{SECRET}@{proxy_addr}")),
1044 )
1045 .unwrap();
1046 let error = client
1047 .request(
1048 Method::GET,
1049 "https://fixture.example.test/".to_string(),
1050 None,
1051 None,
1052 None,
1053 None,
1054 None,
1055 )
1056 .await
1057 .expect_err("unreachable proxy should fail");
1058
1059 assert!(!error.to_string().contains(SECRET));
1060 assert!(!error.to_string().contains(&BASE64.encode(SECRET)));
1061 assert!(
1062 !error
1063 .to_string()
1064 .contains(&BASE64.encode(format!("{USERNAME}:{SECRET}")))
1065 );
1066 }
1067
1068 #[rstest]
1069 fn test_http_client_with_valid_proxy() {
1070 let result = HttpClient::new(
1072 HashMap::new(),
1073 vec![],
1074 vec![],
1075 None,
1076 None,
1077 Some("http://proxy.example.com:8080".to_string()),
1078 );
1079
1080 assert!(result.is_ok());
1081 }
1082
1083 #[rstest]
1084 fn test_http_client_with_socks5_proxy() {
1085 let result = HttpClient::new(
1087 HashMap::new(),
1088 vec![],
1089 vec![],
1090 None,
1091 None,
1092 Some("socks5://127.0.0.1:1080".to_string()),
1093 );
1094
1095 assert!(result.is_ok());
1096 }
1097
1098 #[rstest]
1099 fn test_http_client_with_malformed_proxy() {
1100 let result = HttpClient::new(
1104 HashMap::new(),
1105 vec![],
1106 vec![],
1107 None,
1108 None,
1109 Some("://invalid".to_string()),
1110 );
1111
1112 assert!(result.is_err());
1113 assert!(matches!(result, Err(HttpClientError::InvalidProxy(_))));
1114 }
1115
1116 #[rstest]
1117 fn test_http_client_invalid_proxy_error_redacts_credentials() {
1118 const SECRET: &str = "unique-proxy-secret";
1119 let result = HttpClient::new(
1120 HashMap::new(),
1121 vec![],
1122 vec![],
1123 None,
1124 None,
1125 Some(format!("http://proxytest:{SECRET}@[::1")),
1126 );
1127 let error = result.expect_err("malformed proxy URL should fail");
1128
1129 assert_eq!(
1130 error.to_string(),
1131 "Invalid proxy URL: proxy URL is malformed"
1132 );
1133 assert!(!error.to_string().contains(SECRET));
1134 }
1135
1136 #[rstest]
1137 fn test_http_client_with_empty_proxy_string() {
1138 let result = HttpClient::new(
1140 HashMap::new(),
1141 vec![],
1142 vec![],
1143 None,
1144 None,
1145 Some(String::new()),
1146 );
1147
1148 assert!(result.is_err());
1149 assert!(matches!(result, Err(HttpClientError::InvalidProxy(_))));
1150 }
1151
1152 #[tokio::test]
1153 async fn test_http_client_get() {
1154 let addr = start_test_server().await.unwrap();
1155 let url = format!("http://{addr}/get");
1156
1157 let client = HttpClient::new(HashMap::new(), vec![], vec![], None, None, None).unwrap();
1158 let response = client.get(url, None, None, None, None).await.unwrap();
1159
1160 assert!(response.status.is_success());
1161 assert_eq!(String::from_utf8_lossy(&response.body), "hello-world!");
1162 }
1163
1164 #[tokio::test]
1165 async fn test_http_client_post() {
1166 let addr = start_test_server().await.unwrap();
1167 let url = format!("http://{addr}/post");
1168
1169 let client = HttpClient::new(HashMap::new(), vec![], vec![], None, None, None).unwrap();
1170 let response = client
1171 .post(url, None, None, None, None, None)
1172 .await
1173 .unwrap();
1174
1175 assert!(response.status.is_success());
1176 }
1177
1178 #[tokio::test]
1179 async fn test_http_client_patch() {
1180 let addr = start_test_server().await.unwrap();
1181 let url = format!("http://{addr}/patch");
1182
1183 let client = HttpClient::new(HashMap::new(), vec![], vec![], None, None, None).unwrap();
1184 let response = client
1185 .patch(url, None, None, None, None, None)
1186 .await
1187 .unwrap();
1188
1189 assert!(response.status.is_success());
1190 }
1191
1192 #[tokio::test]
1193 async fn test_http_client_delete() {
1194 let addr = start_test_server().await.unwrap();
1195 let url = format!("http://{addr}/delete");
1196
1197 let client = HttpClient::new(HashMap::new(), vec![], vec![], None, None, None).unwrap();
1198 let response = client.delete(url, None, None, None, None).await.unwrap();
1199
1200 assert!(response.status.is_success());
1201 }
1202}