1use std::{borrow::Cow, collections::HashMap, str::FromStr, sync::Arc, time::Duration};
19
20use base64::{Engine as _, engine::general_purpose::STANDARD as BASE64};
21use bytes::Bytes;
22use http::{
23 Method,
24 header::{HeaderMap, HeaderName, HeaderValue},
25};
26use http_body_util::Full;
27use nautilus_core::{collections::into_ustr_vec, string::secret::SecretString};
28use nautilus_cryptography::providers::install_cryptographic_provider;
29use url::Url;
30use ustr::Ustr;
31
32use super::{
33 HttpClientError, HttpResponse, HttpResponseStream, HttpStatus,
34 stream::{read_chunk, response_error},
35};
36use crate::ratelimiter::{RateLimiter, clock::MonotonicClock, quota::Quota};
37
38#[cfg(not(all(feature = "simulation", madsim)))]
40const DEFAULT_POOL_MAX_IDLE_PER_HOST: usize = 32;
41
42#[cfg(not(all(feature = "simulation", madsim)))]
44const DEFAULT_POOL_IDLE_TIMEOUT_SECS: u64 = 60;
45
46#[cfg(not(all(feature = "simulation", madsim)))]
48const DEFAULT_HTTP2_KEEP_ALIVE_SECS: u64 = 30;
49
50const DEFAULT_MAX_RESPONSE_BYTES: usize = 100 * 1024 * 1024;
56
57#[cfg(all(feature = "simulation", madsim))]
58pub(super) const REQUEST_TIMEOUT_MESSAGE: &str = "simulated request deadline elapsed";
59#[cfg(not(all(feature = "simulation", madsim)))]
60pub(super) const REQUEST_TIMEOUT_MESSAGE: &str = "request deadline elapsed";
61
62#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
64pub enum HttpRedirectPolicy {
65 #[default]
67 Follow,
68 Reject,
70}
71
72#[derive(Clone, Debug)]
79pub struct HttpClient {
80 pub(crate) client: InnerHttpClient,
81 pub(crate) rate_limiters: Arc<[Arc<RateLimiter<Ustr, MonotonicClock>>]>,
82}
83
84#[bon::bon]
85impl HttpClient {
86 #[allow(
101 clippy::needless_pass_by_value,
102 reason = "owned proxy URLs are part of the public builder API"
103 )]
104 #[builder(finish_fn = build)]
105 pub fn builder(
106 #[builder(default)] headers: HashMap<String, String>,
107 #[builder(default)] header_keys: Vec<String>,
108 #[builder(default)] keyed_quotas: Vec<(String, Quota)>,
109 default_quota: Option<Quota>,
110 timeout_secs: Option<u64>,
111 proxy_url: Option<String>,
112 rate_limiters: Option<Vec<Arc<RateLimiter<Ustr, MonotonicClock>>>>,
113 #[builder(default)] redirect_policy: HttpRedirectPolicy,
114 #[builder(default = true)] use_system_proxy: bool,
115 ) -> Result<Self, HttpClientError> {
116 let rate_limiters = if let Some(rate_limiters) = rate_limiters {
117 if default_quota.is_some() || !keyed_quotas.is_empty() {
118 return Err(HttpClientError::Error(
119 "Cannot combine shared rate limiters with quota configuration".to_string(),
120 ));
121 }
122 rate_limiters
123 } else {
124 let keyed_quotas = keyed_quotas
125 .into_iter()
126 .map(|(key, quota)| (Ustr::from(&key), quota))
127 .collect();
128 vec![Arc::new(RateLimiter::new_with_quota(
129 default_quota,
130 keyed_quotas,
131 ))]
132 };
133
134 Self::build(
135 headers,
136 header_keys,
137 timeout_secs,
138 proxy_url.as_deref(),
139 rate_limiters,
140 redirect_policy,
141 use_system_proxy,
142 )
143 }
144
145 fn build(
146 headers: HashMap<String, String>,
147 header_keys: Vec<String>,
148 timeout_secs: Option<u64>,
149 proxy_url: Option<&str>,
150 rate_limiters: Vec<Arc<RateLimiter<Ustr, MonotonicClock>>>,
151 redirect_policy: HttpRedirectPolicy,
152 use_system_proxy: bool,
153 ) -> Result<Self, HttpClientError> {
154 install_cryptographic_provider();
155
156 let mut header_map = HeaderMap::new();
157
158 for (key, value) in headers {
159 let header_name = HeaderName::from_str(&key)
160 .map_err(|e| HttpClientError::Error(format!("Invalid header name '{key}': {e}")))?;
161 let header_value = HeaderValue::from_str(&value).map_err(|e| {
162 HttpClientError::Error(format!("Invalid header value for '{key}': {e}"))
163 })?;
164 header_map.insert(header_name, header_value);
165 }
166
167 #[cfg(all(feature = "simulation", madsim))]
168 let simulation = super::simulation::Client::new(redirect_policy, proxy_url)?;
169
170 #[cfg(not(all(feature = "simulation", madsim)))]
171 let client = super::transport::Client::new(
172 proxy_url,
173 use_system_proxy,
174 super::transport::Settings {
175 pool_max_idle_per_host: DEFAULT_POOL_MAX_IDLE_PER_HOST,
176 pool_idle_timeout: Duration::from_secs(DEFAULT_POOL_IDLE_TIMEOUT_SECS),
177 keep_alive_interval: Some(Duration::from_secs(DEFAULT_HTTP2_KEEP_ALIVE_SECS)),
178 adaptive_window: true,
179 },
180 )?;
181 #[cfg(all(feature = "simulation", madsim))]
182 let _ = use_system_proxy;
183
184 let response_headers = header_keys
187 .into_iter()
188 .map(|key| match HeaderName::from_str(&key) {
189 Ok(name) => Ok((key, name)),
190 Err(e) => Err(HttpClientError::Error(format!(
191 "Invalid header key '{key}': {e}"
192 ))),
193 })
194 .collect::<Result<Vec<_>, _>>()?;
195
196 let client = InnerHttpClient {
197 #[cfg(not(all(feature = "simulation", madsim)))]
198 client,
199 headers: header_map,
200 timeout: timeout_secs.map(Duration::from_secs),
201 #[cfg(not(all(feature = "simulation", madsim)))]
202 redirect_policy,
203 #[cfg(all(feature = "simulation", madsim))]
204 simulation,
205 response_headers: Arc::from(response_headers),
206 max_response_bytes: DEFAULT_MAX_RESPONSE_BYTES,
207 };
208
209 Ok(Self {
210 client,
211 rate_limiters: rate_limiters.into(),
212 })
213 }
214
215 #[expect(clippy::too_many_arguments)]
225 pub async fn request(
226 &self,
227 method: Method,
228 url: String,
229 params: Option<&HashMap<String, Vec<String>>>,
230 headers: Option<HashMap<String, String>>,
231 body: Option<Vec<u8>>,
232 timeout_secs: Option<u64>,
233 keys: Option<Vec<String>>,
234 ) -> Result<HttpResponse, HttpClientError> {
235 let keys = keys.map(into_ustr_vec);
236
237 self.request_with_ustr_keys(method, url, params, headers, body, timeout_secs, keys)
238 .await
239 }
240
241 #[expect(clippy::too_many_arguments)]
251 pub async fn request_with_secret_body(
252 &self,
253 method: Method,
254 url: String,
255 params: Option<&HashMap<String, Vec<String>>>,
256 headers: Option<HashMap<String, String>>,
257 body: SecretString,
258 timeout_secs: Option<u64>,
259 keys: Option<Vec<String>>,
260 ) -> Result<HttpResponse, HttpClientError> {
261 let keys = keys.map(into_ustr_vec);
262 self.await_rate_limits(keys.as_deref()).await;
263
264 self.client
265 .send_request_with_secret_body(method, url, params, headers, body, timeout_secs)
266 .await
267 }
268
269 #[expect(clippy::too_many_arguments)]
277 pub async fn request_with_url_redacted(
278 &self,
279 method: Method,
280 url: String,
281 params: Option<&HashMap<String, Vec<String>>>,
282 headers: Option<HashMap<String, String>>,
283 body: Option<Vec<u8>>,
284 timeout_secs: Option<u64>,
285 keys: Option<Vec<String>>,
286 ) -> Result<HttpResponse, HttpClientError> {
287 let keys = keys.map(into_ustr_vec);
288 self.await_rate_limits(keys.as_deref()).await;
289
290 self.client
291 .send_request_with_url_redacted(method, url, params, headers, body, timeout_secs)
292 .await
293 }
294
295 #[expect(clippy::too_many_arguments)]
304 pub async fn request_with_params<P: serde::Serialize>(
305 &self,
306 method: Method,
307 url: String,
308 params: Option<&P>,
309 headers: Option<HashMap<String, String>>,
310 body: Option<Vec<u8>>,
311 timeout_secs: Option<u64>,
312 keys: Option<Vec<String>>,
313 ) -> Result<HttpResponse, HttpClientError> {
314 let keys = keys.map(into_ustr_vec);
315 self.await_rate_limits(keys.as_deref()).await;
316
317 self.client
318 .send_request_with_query(method, url, params, headers, body, timeout_secs)
319 .await
320 }
321
322 #[expect(clippy::too_many_arguments)]
331 pub async fn request_with_params_url_redacted<P: serde::Serialize>(
332 &self,
333 method: Method,
334 url: String,
335 params: Option<&P>,
336 headers: Option<HashMap<String, String>>,
337 body: Option<Vec<u8>>,
338 timeout_secs: Option<u64>,
339 keys: Option<Vec<String>>,
340 ) -> Result<HttpResponse, HttpClientError> {
341 let keys = keys.map(into_ustr_vec);
342 self.await_rate_limits(keys.as_deref()).await;
343
344 self.client
345 .send_request_with_query_url_redacted(method, url, params, headers, body, timeout_secs)
346 .await
347 }
348
349 pub async fn get_stream(&self, url: String) -> Result<HttpResponseStream, HttpClientError> {
361 self.await_rate_limits(None).await;
362 self.client
363 .send_stream_internal::<[(String, String); 0]>(
364 Method::GET,
365 &url,
366 None,
367 None,
368 None,
369 None,
370 false,
371 )
372 .await
373 }
374
375 #[expect(clippy::too_many_arguments)]
381 pub async fn request_with_ustr_keys(
382 &self,
383 method: Method,
384 url: String,
385 params: Option<&HashMap<String, Vec<String>>>,
386 headers: Option<HashMap<String, String>>,
387 body: Option<Vec<u8>>,
388 timeout_secs: Option<u64>,
389 keys: Option<Vec<Ustr>>,
390 ) -> Result<HttpResponse, HttpClientError> {
391 self.await_rate_limits(keys.as_deref()).await;
392
393 self.client
394 .send_request(method, url, params, headers, body, timeout_secs)
395 .await
396 }
397
398 pub(crate) async fn await_rate_limits(&self, keys: Option<&[Ustr]>) {
399 RateLimiter::await_limiters_ready(&self.rate_limiters, keys).await;
400 }
401
402 pub async fn get(
408 &self,
409 url: String,
410 params: Option<&HashMap<String, Vec<String>>>,
411 headers: Option<HashMap<String, String>>,
412 timeout_secs: Option<u64>,
413 keys: Option<Vec<String>>,
414 ) -> Result<HttpResponse, HttpClientError> {
415 self.request(Method::GET, url, params, headers, None, timeout_secs, keys)
416 .await
417 }
418
419 pub async fn post(
425 &self,
426 url: String,
427 params: Option<&HashMap<String, Vec<String>>>,
428 headers: Option<HashMap<String, String>>,
429 body: Option<Vec<u8>>,
430 timeout_secs: Option<u64>,
431 keys: Option<Vec<String>>,
432 ) -> Result<HttpResponse, HttpClientError> {
433 self.request(Method::POST, url, params, headers, body, timeout_secs, keys)
434 .await
435 }
436
437 pub async fn patch(
443 &self,
444 url: String,
445 params: Option<&HashMap<String, Vec<String>>>,
446 headers: Option<HashMap<String, String>>,
447 body: Option<Vec<u8>>,
448 timeout_secs: Option<u64>,
449 keys: Option<Vec<String>>,
450 ) -> Result<HttpResponse, HttpClientError> {
451 self.request(
452 Method::PATCH,
453 url,
454 params,
455 headers,
456 body,
457 timeout_secs,
458 keys,
459 )
460 .await
461 }
462
463 pub async fn delete(
469 &self,
470 url: String,
471 params: Option<&HashMap<String, Vec<String>>>,
472 headers: Option<HashMap<String, String>>,
473 timeout_secs: Option<u64>,
474 keys: Option<Vec<String>>,
475 ) -> Result<HttpResponse, HttpClientError> {
476 self.request(
477 Method::DELETE,
478 url,
479 params,
480 headers,
481 None,
482 timeout_secs,
483 keys,
484 )
485 .await
486 }
487}
488
489#[derive(Clone, Debug)]
494pub struct InnerHttpClient {
495 #[cfg(all(feature = "simulation", madsim))]
496 simulation: super::simulation::Client,
497 #[cfg(not(all(feature = "simulation", madsim)))]
498 client: super::transport::Client,
499 headers: HeaderMap,
500 timeout: Option<Duration>,
501 #[cfg(not(all(feature = "simulation", madsim)))]
502 redirect_policy: HttpRedirectPolicy,
503 pub(crate) response_headers: Arc<[(String, HeaderName)]>,
504 pub(crate) max_response_bytes: usize,
505}
506
507impl InnerHttpClient {
508 pub async fn send_request(
514 &self,
515 method: Method,
516 url: String,
517 params: Option<&HashMap<String, Vec<String>>>,
518 headers: Option<HashMap<String, String>>,
519 body: Option<Vec<u8>>,
520 timeout_secs: Option<u64>,
521 ) -> Result<HttpResponse, HttpClientError> {
522 self.send_request_with_redaction(
523 method,
524 url,
525 params,
526 headers,
527 body.map(RequestBody::Plain),
528 timeout_secs,
529 false,
530 )
531 .await
532 }
533
534 async fn send_request_with_secret_body(
535 &self,
536 method: Method,
537 url: String,
538 params: Option<&HashMap<String, Vec<String>>>,
539 headers: Option<HashMap<String, String>>,
540 body: SecretString,
541 timeout_secs: Option<u64>,
542 ) -> Result<HttpResponse, HttpClientError> {
543 self.send_request_with_redaction(
544 method,
545 url,
546 params,
547 headers,
548 Some(RequestBody::Secret(body)),
549 timeout_secs,
550 false,
551 )
552 .await
553 }
554
555 async fn send_request_with_url_redacted(
556 &self,
557 method: Method,
558 url: String,
559 params: Option<&HashMap<String, Vec<String>>>,
560 headers: Option<HashMap<String, String>>,
561 body: Option<Vec<u8>>,
562 timeout_secs: Option<u64>,
563 ) -> Result<HttpResponse, HttpClientError> {
564 self.send_request_with_redaction(
565 method,
566 url,
567 params,
568 headers,
569 body.map(RequestBody::Plain),
570 timeout_secs,
571 true,
572 )
573 .await
574 }
575
576 #[expect(clippy::too_many_arguments)]
577 async fn send_request_with_redaction(
578 &self,
579 method: Method,
580 url: String,
581 params: Option<&HashMap<String, Vec<String>>>,
582 headers: Option<HashMap<String, String>>,
583 body: Option<RequestBody>,
584 timeout_secs: Option<u64>,
585 redact_url: bool,
586 ) -> Result<HttpResponse, HttpClientError> {
587 let full_url = encode_url_params(&url, params)?;
588 self.send_request_internal(
589 method,
590 full_url.as_ref(),
591 None::<&()>,
592 headers,
593 body,
594 timeout_secs,
595 redact_url,
596 )
597 .await
598 }
599
600 pub async fn send_request_with_query<Q: serde::Serialize>(
609 &self,
610 method: Method,
611 url: String,
612 query: Option<&Q>,
613 headers: Option<HashMap<String, String>>,
614 body: Option<Vec<u8>>,
615 timeout_secs: Option<u64>,
616 ) -> Result<HttpResponse, HttpClientError> {
617 self.send_request_internal(
618 method,
619 &url,
620 query,
621 headers,
622 body.map(RequestBody::Plain),
623 timeout_secs,
624 false,
625 )
626 .await
627 }
628
629 async fn send_request_with_query_url_redacted<Q: serde::Serialize>(
630 &self,
631 method: Method,
632 url: String,
633 query: Option<&Q>,
634 headers: Option<HashMap<String, String>>,
635 body: Option<Vec<u8>>,
636 timeout_secs: Option<u64>,
637 ) -> Result<HttpResponse, HttpClientError> {
638 self.send_request_internal(
639 method,
640 &url,
641 query,
642 headers,
643 body.map(RequestBody::Plain),
644 timeout_secs,
645 true,
646 )
647 .await
648 }
649
650 #[expect(clippy::too_many_arguments)]
656 async fn send_request_internal<Q: serde::Serialize>(
657 &self,
658 method: Method,
659 url: &str,
660 query: Option<&Q>,
661 headers: Option<HashMap<String, String>>,
662 body: Option<RequestBody>,
663 timeout_secs: Option<u64>,
664 redact_url: bool,
665 ) -> Result<HttpResponse, HttpClientError> {
666 let stream = self
667 .send_stream_internal(method, url, query, headers, body, timeout_secs, redact_url)
668 .await?;
669 let result = self
670 .consume_response(stream.response, stream.deadline)
671 .await;
672 result.map_err(|e| response_error(e, stream.url.as_ref()))
673 }
674
675 #[expect(clippy::too_many_arguments)]
676 async fn send_stream_internal<Q: serde::Serialize>(
677 &self,
678 method: Method,
679 url: &str,
680 query: Option<&Q>,
681 headers: Option<HashMap<String, String>>,
682 body: Option<RequestBody>,
683 timeout_secs: Option<u64>,
684 redact_url: bool,
685 ) -> Result<HttpResponseStream, HttpClientError> {
686 let mut url =
687 Url::parse(url).map_err(|e| HttpClientError::from(format!("URL parse error: {e}")))?;
688 if !matches!(url.scheme(), "http" | "https") || url.host_str().is_none() {
689 return Err(HttpClientError::Error(
690 "unsupported HTTP URL scheme or hostname".into(),
691 ));
692 }
693
694 let mut header_map = self.headers.clone();
695
696 if let Ok(username) = percent_encoding::percent_decode_str(url.username()).decode_utf8() {
697 let password = url.password().and_then(|password| {
698 percent_encoding::percent_decode_str(password)
699 .decode_utf8()
700 .ok()
701 });
702
703 if !username.is_empty() || password.is_some() {
704 let mut value = HeaderValue::from_str(&format!(
705 "Basic {}",
706 BASE64.encode(format!(
707 "{username}:{}",
708 password.as_deref().unwrap_or_default()
709 ))
710 ))
711 .map_err(|e| HttpClientError::Error(e.to_string()))?;
712 value.set_sensitive(true);
713 header_map.insert(http::header::AUTHORIZATION, value);
714 let _ = url.set_username("");
715 let _ = url.set_password(None);
716 }
717 }
718
719 let extra_header_count = headers.as_ref().map_or(0, HashMap::len);
720
721 if let Some(headers) = headers {
722 for (key, value) in headers {
723 let key = HeaderName::from_bytes(key.as_bytes())
724 .map_err(|e| HttpClientError::from(format!("Invalid header name: {e}")))?;
725 let value = HeaderValue::from_str(&value)
726 .map_err(|e| HttpClientError::from(format!("Invalid header value: {e}")))?;
727 if header_map.insert(key.clone(), value).is_some() {
728 log::trace!("Replaced duplicate request header '{key}'");
729 }
730 }
731 }
732
733 if let Some(query) = query {
734 {
735 let mut pairs = url.query_pairs_mut();
736 let serializer = serde_urlencoded::Serializer::new(&mut pairs);
737 query
738 .serialize(serializer)
739 .map_err(|e| HttpClientError::Error(e.to_string()))?;
740 }
741
742 if url.query() == Some("") {
743 url.set_query(None);
744 }
745 }
746
747 if !header_map.contains_key(http::header::ACCEPT) {
748 header_map.insert(http::header::ACCEPT, HeaderValue::from_static("*/*"));
749 }
750
751 let body = body.map(RequestBody::into_bytes).unwrap_or_default();
752 let body_len = body.len();
753 let query_len = url.query().map_or(0, str::len);
754 let mut request = http::Request::new(Full::new(body));
755 *request.method_mut() = method;
756 *request.uri_mut() = url[..url::Position::AfterQuery]
757 .parse()
758 .map_err(|_| HttpClientError::Error("invalid HTTP request target".into()))?;
759 *request.headers_mut() = header_map;
760 log::trace!(
761 "Sending HTTP request: method={} extra_headers={extra_header_count} \
762 query_bytes={query_len} body_bytes={body_len}",
763 request.method(),
764 );
765
766 let duration = timeout_secs.map(Duration::from_secs).or(self.timeout);
767 let deadline = duration.map(|duration| crate::dst::time::Instant::now() + duration);
768 let operation = async {
769 #[cfg(all(feature = "simulation", madsim))]
770 let (response, connection) = self.simulation.send(request, &url).await?;
771 #[cfg(not(all(feature = "simulation", madsim)))]
772 let response = self.client.send(request, self.redirect_policy).await?;
773 Ok(HttpResponseStream {
774 response,
775 deadline,
776 url: (!redact_url).then(|| url.clone()),
777 #[cfg(all(feature = "simulation", madsim))]
778 _connection: connection,
779 })
780 };
781
782 let result = match deadline {
783 Some(deadline) => tokio::select! {
784 biased;
785 () = crate::dst::time::sleep_until(deadline) => Err(HttpClientError::TimeoutError(REQUEST_TIMEOUT_MESSAGE.into())),
786 result = operation => result,
787 },
788 None => operation.await,
789 };
790 result.map_err(|e| response_error(e, (!redact_url).then_some(&url)))
791 }
792
793 async fn consume_response<B>(
794 &self,
795 response: http::Response<B>,
796 deadline: Option<crate::dst::time::Instant>,
797 ) -> Result<HttpResponse, HttpClientError>
798 where
799 B: http_body::Body<Data = Bytes> + Unpin,
800 B::Error: std::error::Error + 'static,
801 {
802 let (parts, mut body) = response.into_parts();
803 let mut headers =
804 HashMap::with_capacity(self.response_headers.len().min(parts.headers.len()));
805 for (key, name) in self.response_headers.iter() {
806 if let Some(value) = parts
807 .headers
808 .get(name)
809 .and_then(|value| value.to_str().ok())
810 {
811 headers.insert(key.clone(), value.to_owned());
812 }
813 }
814
815 let max = self.max_response_bytes;
816 if let Some(len) = body.size_hint().exact()
817 && len > max as u64
818 {
819 return Err(HttpClientError::Error(format!(
820 "HTTP response body of {len} bytes exceeds maximum of {max} bytes",
821 )));
822 }
823
824 let mut buf = bytes::BytesMut::new();
825 while let Some(chunk) = read_chunk(&mut body, deadline).await? {
826 if chunk.len() > max - buf.len() {
827 return Err(HttpClientError::Error(format!(
828 "HTTP response body exceeds maximum of {max} bytes",
829 )));
830 }
831 buf.extend_from_slice(&chunk);
832 }
833
834 log::trace!(
835 "Received HTTP response: status={} headers={} body_bytes={}",
836 parts.status,
837 parts.headers.len(),
838 buf.len()
839 );
840 Ok(HttpResponse {
841 status: HttpStatus::new(parts.status),
842 headers,
843 body: buf.freeze(),
844 })
845 }
846}
847
848enum RequestBody {
849 Plain(Vec<u8>),
850 Secret(SecretString),
851}
852
853impl RequestBody {
854 fn into_bytes(self) -> Bytes {
855 match self {
856 Self::Plain(body) => body.into(),
857 Self::Secret(body) => Bytes::from_owner(SecretBody(body)),
858 }
859 }
860}
861
862struct SecretBody(SecretString);
863
864impl AsRef<[u8]> for SecretBody {
865 fn as_ref(&self) -> &[u8] {
866 self.0.expose_secret().as_bytes()
867 }
868}
869
870impl Default for InnerHttpClient {
871 fn default() -> Self {
880 install_cryptographic_provider();
881 #[cfg(not(all(feature = "simulation", madsim)))]
882 let client =
883 super::transport::Client::new(None, true, super::transport::Settings::default())
884 .expect("failed to build default HTTP client");
885 Self {
886 #[cfg(not(all(feature = "simulation", madsim)))]
887 client,
888 headers: HeaderMap::new(),
889 timeout: None,
890 #[cfg(not(all(feature = "simulation", madsim)))]
891 redirect_policy: HttpRedirectPolicy::default(),
892 #[cfg(all(feature = "simulation", madsim))]
893 simulation: super::simulation::Client::default(),
894 response_headers: Arc::default(),
895 max_response_bytes: DEFAULT_MAX_RESPONSE_BYTES,
896 }
897 }
898}
899
900fn encode_url_params<'a>(
907 url: &'a str,
908 params: Option<&HashMap<String, Vec<String>>>,
909) -> Result<Cow<'a, str>, HttpClientError> {
910 let Some(params) = params else {
911 return Ok(Cow::Borrowed(url));
912 };
913
914 let pairs: Vec<(&str, &str)> = params
915 .iter()
916 .flat_map(|(key, values)| {
917 values
918 .iter()
919 .map(move |value| (key.as_str(), value.as_str()))
920 })
921 .collect();
922
923 if pairs.is_empty() {
924 return Ok(Cow::Borrowed(url));
925 }
926
927 let query_string = serde_urlencoded::to_string(pairs)
928 .map_err(|e| HttpClientError::Error(format!("Failed to encode params: {e}")))?;
929
930 let (base, fragment) = match url.split_once('#') {
933 Some((base, fragment)) => (base, Some(fragment)),
934 None => (url, None),
935 };
936 let separator = if base.contains('?') { '&' } else { '?' };
937
938 Ok(Cow::Owned(match fragment {
939 Some(fragment) => format!("{base}{separator}{query_string}#{fragment}"),
940 None => format!("{base}{separator}{query_string}"),
941 }))
942}
943
944#[cfg(test)]
945mod encode_url_params_tests {
946 use std::{borrow::Cow, collections::HashMap};
947
948 use rstest::rstest;
949
950 use super::encode_url_params;
951
952 fn params(pairs: &[(&str, &str)]) -> HashMap<String, Vec<String>> {
953 let mut map: HashMap<String, Vec<String>> = HashMap::new();
954
955 for (key, value) in pairs {
956 map.entry((*key).to_string())
957 .or_default()
958 .push((*value).to_string());
959 }
960
961 map
962 }
963
964 #[rstest]
965 #[case("https://x/y", "https://x/y?a=b")]
966 #[case("https://x/y?old=1", "https://x/y?old=1&a=b")]
967 #[case("https://x/y#frag", "https://x/y?a=b#frag")]
968 #[case("https://x/y?old=1#frag", "https://x/y?old=1&a=b#frag")]
969 #[case(
970 "https://x/y#section?display=full",
971 "https://x/y?a=b#section?display=full"
972 )]
973 #[case("https://x/y#", "https://x/y?a=b#")]
974 fn test_query_is_inserted_before_the_fragment(#[case] url: &str, #[case] expected: &str) {
975 let params = params(&[("a", "b")]);
976
977 assert_eq!(encode_url_params(url, Some(¶ms)).unwrap(), expected);
978 }
979
980 #[rstest]
981 fn test_url_is_borrowed_when_no_params_are_supplied() {
982 assert!(matches!(
983 encode_url_params("https://x/y#frag", None).unwrap(),
984 Cow::Borrowed("https://x/y#frag")
985 ));
986 }
987
988 #[rstest]
989 fn test_url_is_borrowed_when_params_are_empty() {
990 let params = HashMap::new();
991
992 assert!(matches!(
993 encode_url_params("https://x/y#frag", Some(¶ms)).unwrap(),
994 Cow::Borrowed("https://x/y#frag")
995 ));
996 }
997}
998
999#[cfg(test)]
1000#[cfg(target_os = "linux")] #[cfg(not(all(feature = "simulation", madsim)))]
1002mod tests {
1003 use std::net::SocketAddr;
1004
1005 use axum::{
1006 Router,
1007 body::to_bytes,
1008 extract::Request,
1009 response::IntoResponse,
1010 routing::{any, delete, get, patch, post},
1011 serve,
1012 };
1013 use http::status::StatusCode;
1014 use log::Level;
1015 use rstest::rstest;
1016 use tokio::{
1017 io::{AsyncReadExt, AsyncWriteExt},
1018 sync::oneshot,
1019 };
1020
1021 use super::*;
1022 use crate::logging::tests::capture_logs;
1023
1024 async fn capture_request(request: Request) -> impl IntoResponse {
1025 let (parts, body) = request.into_parts();
1026 let body = to_bytes(body, usize::MAX).await.unwrap();
1027 let default_header = parts.headers.get("x-default").unwrap().to_str().unwrap();
1028 let request_header = parts.headers.get("x-request").unwrap().to_str().unwrap();
1029 let query = parts.uri.query().unwrap_or_default();
1030 let body = String::from_utf8(body.to_vec()).unwrap();
1031 let capture = format!(
1032 "{}\n{}\n{query}\n{default_header}\n{request_header}\n{body}",
1033 parts.method,
1034 parts.uri.path(),
1035 );
1036
1037 ([("x-response-id", "response-42")], capture)
1038 }
1039
1040 fn create_router() -> Router {
1041 Router::new()
1042 .route("/get", get(|| async { "hello-world!" }))
1043 .route("/post", post(|body: Bytes| async move { body }))
1044 .route("/patch", patch(|body: Bytes| async move { body }))
1045 .route("/delete", delete(|| async { StatusCode::OK }))
1046 .route("/capture", any(capture_request))
1047 .route("/notfound", get(|| async { StatusCode::NOT_FOUND }))
1048 .route(
1049 "/redirect",
1050 get(|| async { (StatusCode::TEMPORARY_REDIRECT, [("location", "/get")]) }),
1051 )
1052 .route(
1053 "/slow",
1054 get(|| async {
1055 tokio::time::sleep(Duration::from_secs(2)).await;
1056 "Eventually responded"
1057 }),
1058 )
1059 .route(
1060 "/large",
1061 get(|| async { "x".repeat(1024 * 1024) }),
1063 )
1064 }
1065
1066 async fn start_test_server() -> Result<SocketAddr, Box<dyn std::error::Error + Send + Sync>> {
1067 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
1068 let addr = listener.local_addr().unwrap();
1069
1070 tokio::spawn(async move {
1071 serve(listener, create_router()).await.unwrap();
1072 });
1073
1074 Ok(addr)
1075 }
1076
1077 async fn spawn_connection_dropper() -> (SocketAddr, tokio::task::JoinHandle<()>) {
1078 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
1079 let addr = listener.local_addr().unwrap();
1080
1081 let task = tokio::spawn(async move {
1082 loop {
1083 let (stream, _) = listener.accept().await.unwrap();
1084 drop(stream);
1085 }
1086 });
1087
1088 (addr, task)
1089 }
1090
1091 async fn spawn_chunked_response_server() -> (SocketAddr, tokio::task::JoinHandle<()>) {
1092 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
1093 let addr = listener.local_addr().unwrap();
1094 let task = tokio::spawn(async move {
1095 let (mut stream, _) = listener.accept().await.unwrap();
1096 let mut request = Vec::new();
1097 let mut chunk = [0u8; 1024];
1098
1099 loop {
1100 let read = stream.read(&mut chunk).await.unwrap();
1101 if read == 0 {
1102 break;
1103 }
1104 request.extend_from_slice(&chunk[..read]);
1105 if request.windows(4).any(|window| window == b"\r\n\r\n") {
1106 break;
1107 }
1108 }
1109
1110 stream
1111 .write_all(
1112 b"HTTP/1.1 200 OK\r\nTransfer-Encoding: chunked\r\nConnection: close\r\n\r\n\
1113 5\r\nfirst\r\n6\r\nsecond\r\n0\r\n\r\n",
1114 )
1115 .await
1116 .unwrap();
1117 });
1118
1119 (addr, task)
1120 }
1121
1122 async fn spawn_rejecting_connect_proxy() -> (SocketAddr, oneshot::Receiver<String>) {
1123 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
1124 let addr = listener.local_addr().unwrap();
1125 let (request_tx, request_rx) = oneshot::channel();
1126
1127 tokio::spawn(async move {
1128 let (mut stream, _) = listener.accept().await.unwrap();
1129 let mut request = Vec::new();
1130 let mut chunk = [0u8; 1024];
1131 loop {
1132 let read = stream.read(&mut chunk).await.unwrap();
1133 if read == 0 {
1134 break;
1135 }
1136 request.extend_from_slice(&chunk[..read]);
1137 if request.windows(4).any(|window| window == b"\r\n\r\n") {
1138 break;
1139 }
1140 }
1141 request_tx
1142 .send(String::from_utf8(request).unwrap())
1143 .unwrap();
1144 stream
1145 .write_all(
1146 b"HTTP/1.1 407 Proxy Authentication Required\r\nContent-Length: 0\r\n\r\n",
1147 )
1148 .await
1149 .unwrap();
1150 });
1151
1152 (addr, request_rx)
1153 }
1154
1155 #[tokio::test(start_paused = true)]
1156 async fn test_body_ready_at_deadline_is_rejected() {
1157 let client = InnerHttpClient::default();
1158 let response = http::Response::new(Full::new(Bytes::from_static(b"ready")));
1159 let result = client
1160 .consume_response(response, Some(crate::dst::time::Instant::now()))
1161 .await;
1162 assert!(
1163 matches!(result, Err(HttpClientError::TimeoutError(message)) if message == REQUEST_TIMEOUT_MESSAGE)
1164 );
1165 }
1166
1167 #[tokio::test]
1168 async fn test_get() {
1169 let addr = start_test_server().await.unwrap();
1170 let url = format!("http://{addr}");
1171
1172 let client = InnerHttpClient::default();
1173 let response = client
1174 .send_request(Method::GET, format!("{url}/get"), None, None, None, None)
1175 .await
1176 .unwrap();
1177
1178 assert_eq!(response.status.as_u16(), StatusCode::OK.as_u16());
1179 assert_eq!(response.headers, HashMap::new());
1180 assert_eq!(response.body.as_ref(), b"hello-world!");
1181 }
1182
1183 #[tokio::test]
1184 async fn test_request_preserves_wire_semantics_and_extracts_response_headers() {
1185 let addr = start_test_server().await.unwrap();
1186 let mut default_headers = HashMap::new();
1187 default_headers.insert("x-default".to_string(), "default-a".to_string());
1188 let client = HttpClient::builder()
1189 .headers(default_headers)
1190 .header_keys(vec!["x-response-id".to_string()])
1191 .build()
1192 .unwrap();
1193 let mut params = HashMap::new();
1194 params.insert(
1195 "tag".to_string(),
1196 vec!["A B".to_string(), "C/D".to_string()],
1197 );
1198 let mut request_headers = HashMap::new();
1199 request_headers.insert("x-request".to_string(), "request-b".to_string());
1200
1201 let response = client
1202 .request(
1203 Method::PUT,
1204 format!("http://{addr}/capture?existing=seed"),
1205 Some(¶ms),
1206 Some(request_headers),
1207 Some(b"payload-c".to_vec()),
1208 None,
1209 None,
1210 )
1211 .await
1212 .unwrap();
1213
1214 assert_eq!(response.status.as_u16(), StatusCode::OK.as_u16());
1215 assert_eq!(
1216 response.headers,
1217 HashMap::from([("x-response-id".to_string(), "response-42".to_string())])
1218 );
1219 assert_eq!(
1220 response.body.as_ref(),
1221 b"PUT\n/capture\nexisting=seed&tag=A+B&tag=C%2FD\ndefault-a\nrequest-b\npayload-c"
1222 );
1223 }
1224
1225 #[tokio::test]
1226 async fn test_request_with_secret_body_preserves_wire_body() {
1227 let addr = start_test_server().await.unwrap();
1228 let client = HttpClient::builder()
1229 .headers(HashMap::from([(
1230 "x-default".to_string(),
1231 "default-secret".to_string(),
1232 )]))
1233 .build()
1234 .unwrap();
1235 let headers = HashMap::from([("x-request".to_string(), "request-secret".to_string())]);
1236
1237 let response = client
1238 .request_with_secret_body(
1239 Method::POST,
1240 format!("http://{addr}/capture"),
1241 None,
1242 Some(headers),
1243 SecretString::from("credential-body"),
1244 None,
1245 None,
1246 )
1247 .await
1248 .unwrap();
1249
1250 assert_eq!(response.status.as_u16(), StatusCode::OK.as_u16());
1251 assert_eq!(response.headers, HashMap::new());
1252 assert_eq!(
1253 response.body.as_ref(),
1254 b"POST\n/capture\n\ndefault-secret\nrequest-secret\ncredential-body"
1255 );
1256 }
1257
1258 #[tokio::test]
1259 async fn test_request_with_params_serializes_query_fields() {
1260 #[derive(serde::Serialize)]
1261 struct Query<'a> {
1262 symbol: &'a str,
1263 limit: u32,
1264 }
1265
1266 let addr = start_test_server().await.unwrap();
1267 let mut default_headers = HashMap::new();
1268 default_headers.insert("x-default".to_string(), "default-d".to_string());
1269 let client = HttpClient::builder()
1270 .headers(default_headers)
1271 .header_keys(vec!["x-response-id".to_string()])
1272 .build()
1273 .unwrap();
1274 let mut request_headers = HashMap::new();
1275 request_headers.insert("x-request".to_string(), "request-e".to_string());
1276 let params = Query {
1277 symbol: "BTC/USDT",
1278 limit: 37,
1279 };
1280
1281 let response = client
1282 .request_with_params(
1283 Method::GET,
1284 format!("http://{addr}/capture"),
1285 Some(¶ms),
1286 Some(request_headers),
1287 None,
1288 None,
1289 None,
1290 )
1291 .await
1292 .unwrap();
1293
1294 assert_eq!(response.status.as_u16(), StatusCode::OK.as_u16());
1295 assert_eq!(
1296 response.headers,
1297 HashMap::from([("x-response-id".to_string(), "response-42".to_string())])
1298 );
1299 assert_eq!(
1300 response.body.as_ref(),
1301 b"GET\n/capture\nsymbol=BTC%2FUSDT&limit=37\ndefault-d\nrequest-e\n"
1302 );
1303 }
1304
1305 #[tokio::test]
1306 async fn test_request_with_params_url_redacted_preserves_query_fields() {
1307 #[derive(serde::Serialize)]
1308 struct Query<'a> {
1309 auth: &'a str,
1310 market_id: i16,
1311 }
1312
1313 let addr = start_test_server().await.unwrap();
1314 let client = HttpClient::builder()
1315 .headers(HashMap::from([(
1316 "x-default".to_string(),
1317 "default-f".to_string(),
1318 )]))
1319 .build()
1320 .unwrap();
1321 let headers = HashMap::from([("x-request".to_string(), "request-g".to_string())]);
1322 let params = Query {
1323 auth: "token/42",
1324 market_id: 7,
1325 };
1326
1327 let response = client
1328 .request_with_params_url_redacted(
1329 Method::GET,
1330 format!("http://{addr}/capture"),
1331 Some(¶ms),
1332 Some(headers),
1333 None,
1334 None,
1335 None,
1336 )
1337 .await
1338 .unwrap();
1339
1340 assert_eq!(response.status.as_u16(), StatusCode::OK.as_u16());
1341 assert_eq!(
1342 response.body.as_ref(),
1343 b"GET\n/capture\nauth=token%2F42&market_id=7\ndefault-f\nrequest-g\n"
1344 );
1345 }
1346
1347 #[rstest]
1348 #[case::empty_at_zero_cap(b"", 0)]
1349 #[case::at_cap(b"body-37", 7)]
1350 #[case::below_cap(b"body-37", 8)]
1351 #[tokio::test]
1352 async fn test_declared_response_body_at_or_below_cap_is_returned(
1353 #[case] bytes: &'static [u8],
1354 #[case] max_response_bytes: usize,
1355 ) {
1356 let client = InnerHttpClient {
1357 max_response_bytes,
1358 ..Default::default()
1359 };
1360 let response = http::Response::new(Full::new(Bytes::from_static(bytes)));
1361
1362 let response = client.consume_response(response, None).await.unwrap();
1363
1364 assert_eq!(response.status.as_u16(), 200);
1365 assert_eq!(response.headers, HashMap::new());
1366 assert_eq!(response.body.as_ref(), bytes);
1367 }
1368
1369 #[tokio::test]
1370 async fn test_response_body_within_cap_is_returned() {
1371 let addr = start_test_server().await.unwrap();
1372 let url = format!("http://{addr}");
1373
1374 let client = InnerHttpClient {
1376 max_response_bytes: 4 * 1024 * 1024,
1377 ..Default::default()
1378 };
1379
1380 let response = client
1381 .send_request(Method::GET, format!("{url}/large"), None, None, None, None)
1382 .await
1383 .unwrap();
1384
1385 assert_eq!(response.status.as_u16(), StatusCode::OK.as_u16());
1386 assert_eq!(response.headers, HashMap::new());
1387 assert_eq!(response.body.as_ref(), vec![b'x'; 1024 * 1024]);
1388 }
1389
1390 #[tokio::test]
1391 async fn test_response_body_exceeding_cap_is_rejected() {
1392 let addr = start_test_server().await.unwrap();
1393 let url = format!("http://{addr}");
1394
1395 let client = InnerHttpClient {
1397 max_response_bytes: 16 * 1024,
1398 ..Default::default()
1399 };
1400
1401 let result = client
1402 .send_request(Method::GET, format!("{url}/large"), None, None, None, None)
1403 .await;
1404
1405 let err = result.expect_err("oversized response body should be rejected");
1406 let HttpClientError::Error(message) = err else {
1407 panic!("expected HTTP error, was {err:?}");
1408 };
1409 assert_eq!(
1410 message,
1411 "HTTP response body of 1048576 bytes exceeds maximum of 16384 bytes"
1412 );
1413 }
1414
1415 #[rstest]
1416 #[case::at_cap(11)]
1417 #[case::below_cap(12)]
1418 #[tokio::test]
1419 async fn test_chunked_response_body_at_or_below_cap_is_returned(
1420 #[case] max_response_bytes: usize,
1421 ) {
1422 let (addr, server_task) = spawn_chunked_response_server().await;
1423 let client = InnerHttpClient {
1424 max_response_bytes,
1425 ..Default::default()
1426 };
1427
1428 let response = client
1429 .send_request(
1430 Method::GET,
1431 format!("http://{addr}"),
1432 None,
1433 None,
1434 None,
1435 None,
1436 )
1437 .await
1438 .unwrap();
1439 server_task.await.unwrap();
1440
1441 assert_eq!(response.status.as_u16(), 200);
1442 assert_eq!(response.headers, HashMap::new());
1443 assert_eq!(response.body.as_ref(), b"firstsecond");
1444 }
1445
1446 #[tokio::test]
1447 async fn test_chunked_response_body_exceeding_cap_is_rejected() {
1448 let (addr, server_task) = spawn_chunked_response_server().await;
1449 let max_response_bytes = 8;
1450 let client = InnerHttpClient {
1451 max_response_bytes,
1452 ..Default::default()
1453 };
1454
1455 let error = client
1456 .send_request(
1457 Method::GET,
1458 format!("http://{addr}"),
1459 None,
1460 None,
1461 None,
1462 None,
1463 )
1464 .await
1465 .expect_err("chunked response body should be rejected");
1466 server_task.await.unwrap();
1467
1468 let HttpClientError::Error(message) = error else {
1469 panic!("expected HTTP error, was {error:?}");
1470 };
1471 assert_eq!(
1472 message,
1473 format!("HTTP response body exceeds maximum of {max_response_bytes} bytes")
1474 );
1475 }
1476
1477 #[tokio::test]
1478 async fn test_post() {
1479 let addr = start_test_server().await.unwrap();
1480 let url = format!("http://{addr}");
1481
1482 let client = InnerHttpClient::default();
1483 let response = client
1484 .send_request(Method::POST, format!("{url}/post"), None, None, None, None)
1485 .await
1486 .unwrap();
1487
1488 assert_eq!(response.status.as_u16(), StatusCode::OK.as_u16());
1489 assert_eq!(response.headers, HashMap::new());
1490 assert_eq!(response.body.as_ref(), b"");
1491 }
1492
1493 #[tokio::test]
1494 async fn test_post_with_body() {
1495 let addr = start_test_server().await.unwrap();
1496 let url = format!("http://{addr}");
1497
1498 let client = InnerHttpClient::default();
1499
1500 let mut body = HashMap::new();
1501 body.insert(
1502 "key1".to_string(),
1503 serde_json::Value::String("value1".to_string()),
1504 );
1505 body.insert(
1506 "key2".to_string(),
1507 serde_json::Value::String("value2".to_string()),
1508 );
1509
1510 let body_string = serde_json::to_string(&body).unwrap();
1511 let body_bytes = body_string.into_bytes();
1512
1513 let response = client
1514 .send_request(
1515 Method::POST,
1516 format!("{url}/post"),
1517 None,
1518 None,
1519 Some(body_bytes.clone()),
1520 None,
1521 )
1522 .await
1523 .unwrap();
1524
1525 assert_eq!(response.status.as_u16(), StatusCode::OK.as_u16());
1526 assert_eq!(response.headers, HashMap::new());
1527 assert_eq!(response.body.as_ref(), body_bytes);
1528 }
1529
1530 #[tokio::test]
1531 async fn test_patch() {
1532 let addr = start_test_server().await.unwrap();
1533 let url = format!("http://{addr}");
1534
1535 let client = InnerHttpClient::default();
1536 let response = client
1537 .send_request(
1538 Method::PATCH,
1539 format!("{url}/patch"),
1540 None,
1541 None,
1542 None,
1543 None,
1544 )
1545 .await
1546 .unwrap();
1547
1548 assert_eq!(response.status.as_u16(), StatusCode::OK.as_u16());
1549 assert_eq!(response.headers, HashMap::new());
1550 assert_eq!(response.body.as_ref(), b"");
1551 }
1552
1553 #[tokio::test]
1554 async fn test_delete() {
1555 let addr = start_test_server().await.unwrap();
1556 let url = format!("http://{addr}");
1557
1558 let client = InnerHttpClient::default();
1559 let response = client
1560 .send_request(
1561 Method::DELETE,
1562 format!("{url}/delete"),
1563 None,
1564 None,
1565 None,
1566 None,
1567 )
1568 .await
1569 .unwrap();
1570
1571 assert_eq!(response.status.as_u16(), StatusCode::OK.as_u16());
1572 assert_eq!(response.headers, HashMap::new());
1573 assert_eq!(response.body.as_ref(), b"");
1574 }
1575
1576 #[tokio::test]
1577 async fn test_not_found() {
1578 let addr = start_test_server().await.unwrap();
1579 let url = format!("http://{addr}/notfound");
1580 let client = InnerHttpClient::default();
1581
1582 let response = client
1583 .send_request(Method::GET, url, None, None, None, None)
1584 .await
1585 .unwrap();
1586
1587 assert!(response.status.is_client_error());
1588 assert_eq!(response.status.as_u16(), 404);
1589 assert_eq!(response.headers, HashMap::new());
1590 assert_eq!(response.body.as_ref(), b"");
1591 }
1592
1593 #[tokio::test]
1594 async fn test_timeout() {
1595 let addr = start_test_server().await.unwrap();
1596 let url = format!("http://{addr}/slow");
1597 let client = InnerHttpClient::default();
1598
1599 let result = client
1601 .send_request(Method::GET, url, None, None, None, Some(1))
1602 .await;
1603
1604 assert!(
1605 matches!(&result, Err(HttpClientError::TimeoutError(_))),
1606 "Expected a timeout error, was: {result:?}"
1607 );
1608 }
1609
1610 #[rstest]
1611 fn test_http_client_without_proxy() {
1612 let result = HttpClient::builder().build();
1614
1615 assert!(result.is_ok());
1616 }
1617
1618 #[rstest]
1619 fn test_http_client_builder_preserves_empty_rate_limiters() {
1620 let client = HttpClient::builder()
1621 .rate_limiters(Vec::new())
1622 .build()
1623 .unwrap();
1624
1625 assert!(client.rate_limiters.is_empty());
1626 }
1627
1628 #[rstest]
1629 fn test_http_client_builder_rejects_shared_rate_limiters_with_quotas() {
1630 let quota = Quota::with_period(Duration::from_secs(1)).unwrap();
1631 let rate_limiter = Arc::new(RateLimiter::new_with_quota(None, Vec::new()));
1632 let result = HttpClient::builder()
1633 .default_quota(quota)
1634 .rate_limiters(vec![rate_limiter])
1635 .build();
1636
1637 assert_eq!(
1638 result.unwrap_err().to_string(),
1639 "HTTP error occurred: Cannot combine shared rate limiters with quota configuration"
1640 );
1641 }
1642
1643 #[tokio::test]
1644 async fn test_http_client_without_proxy_requests_directly() {
1645 let addr = start_test_server().await.unwrap();
1646 let client = HttpClient::builder().timeout_secs(2).build().unwrap();
1647 let response = client
1648 .request(
1649 Method::GET,
1650 format!("http://{addr}/get"),
1651 None,
1652 None,
1653 None,
1654 None,
1655 None,
1656 )
1657 .await
1658 .expect("direct request");
1659
1660 assert_eq!(response.status.as_u16(), StatusCode::OK.as_u16());
1661 assert_eq!(response.body.as_ref(), b"hello-world!");
1662 }
1663
1664 #[tokio::test]
1665 async fn test_http_client_redirect_policy() {
1666 let addr = start_test_server().await.unwrap();
1667 let follow = HttpClient::builder().timeout_secs(2).build().unwrap();
1668 let reject = HttpClient::builder()
1669 .timeout_secs(2)
1670 .redirect_policy(HttpRedirectPolicy::Reject)
1671 .build()
1672 .unwrap();
1673
1674 let followed = follow
1675 .request(
1676 Method::GET,
1677 format!("http://{addr}/redirect"),
1678 None,
1679 None,
1680 None,
1681 None,
1682 None,
1683 )
1684 .await
1685 .unwrap();
1686 let rejected = reject
1687 .request(
1688 Method::GET,
1689 format!("http://{addr}/redirect"),
1690 None,
1691 None,
1692 None,
1693 None,
1694 None,
1695 )
1696 .await
1697 .unwrap();
1698
1699 assert_eq!(followed.status.as_u16(), StatusCode::OK.as_u16());
1700 assert_eq!(followed.body.as_ref(), b"hello-world!");
1701 assert_eq!(
1702 rejected.status.as_u16(),
1703 StatusCode::TEMPORARY_REDIRECT.as_u16()
1704 );
1705 assert!(rejected.body.is_empty());
1706 }
1707
1708 #[tokio::test]
1709 async fn test_http_client_redacted_url_request_preserves_response() {
1710 let addr = start_test_server().await.unwrap();
1711 let client = HttpClient::builder().timeout_secs(2).build().unwrap();
1712 let response = client
1713 .request_with_url_redacted(
1714 Method::GET,
1715 format!("http://{addr}/get"),
1716 None,
1717 None,
1718 None,
1719 None,
1720 None,
1721 )
1722 .await
1723 .expect("direct request with URL redaction");
1724
1725 assert_eq!(response.status.as_u16(), StatusCode::OK.as_u16());
1726 assert_eq!(response.body.as_ref(), b"hello-world!");
1727 }
1728
1729 #[tokio::test]
1730 async fn test_http_client_redacted_url_request_removes_endpoint_from_error() {
1731 const USERINFO_SECRET: &str = "transport-userinfo-secret";
1732 const PATH_SECRET: &str = "transport-path-secret";
1733 const QUERY_SECRET: &str = "transport-query-secret";
1734 let (addr, drop_task) = spawn_connection_dropper().await;
1735 let url = format!(
1736 "http://rpc-user:{USERINFO_SECRET}@{addr}/{PATH_SECRET}?api_key={QUERY_SECRET}"
1737 );
1738 let client = HttpClient::builder().timeout_secs(1).build().unwrap();
1739
1740 let error = client
1741 .request_with_url_redacted(Method::GET, url.clone(), None, None, None, None, None)
1742 .await
1743 .expect_err("an unreachable endpoint should fail");
1744 drop_task.abort();
1745 let task_error = drop_task
1746 .await
1747 .expect_err("connection dropper should be cancelled");
1748
1749 assert!(task_error.is_cancelled());
1750 for rendered in [error.to_string(), format!("{error:?}")] {
1751 assert!(!rendered.contains(USERINFO_SECRET));
1752 assert!(!rendered.contains(PATH_SECRET));
1753 assert!(!rendered.contains(QUERY_SECRET));
1754 assert!(!rendered.contains(&url));
1755 }
1756 }
1757
1758 #[tokio::test]
1759 async fn test_request_with_params_url_redacted_removes_query_from_error() {
1760 const QUERY_SECRET: &str = "transport-query-secret";
1761 #[derive(serde::Serialize)]
1762 struct Query<'a> {
1763 auth: &'a str,
1764 }
1765
1766 let (addr, drop_task) = spawn_connection_dropper().await;
1767 let url = format!("http://{addr}/trades");
1768 let params = Query { auth: QUERY_SECRET };
1769 let client = HttpClient::builder().timeout_secs(1).build().unwrap();
1770
1771 let error = client
1772 .request_with_params_url_redacted(
1773 Method::GET,
1774 url,
1775 Some(¶ms),
1776 None,
1777 None,
1778 None,
1779 None,
1780 )
1781 .await
1782 .expect_err("a dropped connection should fail");
1783 drop_task.abort();
1784 let task_error = drop_task
1785 .await
1786 .expect_err("connection dropper should be cancelled");
1787
1788 assert!(task_error.is_cancelled());
1789 for rendered in [error.to_string(), format!("{error:?}")] {
1790 assert!(!rendered.contains("auth="));
1791 assert!(!rendered.contains(QUERY_SECRET));
1792 }
1793 }
1794
1795 #[tokio::test]
1796 async fn test_http_client_redacted_url_request_removes_endpoint_from_trace_logs() {
1797 const USERINFO_SECRET: &str = "trace-userinfo-secret";
1798 const PATH_SECRET: &str = "trace-path-secret";
1799 const QUERY_SECRET: &str = "trace-query-secret";
1800 let capture = capture_logs().await;
1801 let addr = start_test_server().await.unwrap();
1802 let url = format!(
1803 "http://rpc-user:{USERINFO_SECRET}@{addr}/{PATH_SECRET}?api_key={QUERY_SECRET}"
1804 );
1805 let client = HttpClient::builder().timeout_secs(2).build().unwrap();
1806
1807 let response = client
1808 .request_with_url_redacted(Method::GET, url.clone(), None, None, None, None, None)
1809 .await
1810 .expect("credentialized endpoint should return an HTTP response");
1811 let messages = capture.messages();
1812
1813 assert_eq!(response.status.as_u16(), StatusCode::NOT_FOUND.as_u16());
1814 assert!(messages.iter().any(|(level, message)| {
1815 *level == Level::Trace && message.starts_with("Sending HTTP request: method=GET")
1816 }));
1817 assert!(messages.iter().any(|(level, message)| {
1818 *level == Level::Trace
1819 && message.starts_with("Received HTTP response: status=404 Not Found")
1820 }));
1821
1822 for (_, message) in messages {
1823 assert!(!message.contains(USERINFO_SECRET));
1824 assert!(!message.contains(PATH_SECRET));
1825 assert!(!message.contains(QUERY_SECRET));
1826 assert!(!message.contains(&url));
1827 }
1828 }
1829
1830 #[tokio::test]
1831 async fn test_http_client_uses_connect_and_proxy_authorization_for_https() {
1832 const USERNAME: &str = "proxytest";
1833 const PASSWORD: &str = "fixture42";
1834 let (proxy_addr, request_rx) = spawn_rejecting_connect_proxy().await;
1835 let client = HttpClient::builder()
1836 .timeout_secs(2)
1837 .proxy_url(format!("http://{USERNAME}:{PASSWORD}@{proxy_addr}"))
1838 .build()
1839 .unwrap();
1840 let error = client
1841 .request(
1842 Method::GET,
1843 "https://fixture.example.test/path".to_string(),
1844 None,
1845 None,
1846 None,
1847 None,
1848 None,
1849 )
1850 .await
1851 .expect_err("proxy should reject CONNECT");
1852 let request = request_rx.await.expect("captured CONNECT request");
1853 let mut lines = request.split("\r\n");
1854 let request_line = lines.next().expect("CONNECT request line");
1855 let auth_value = lines
1856 .find_map(|line| {
1857 let (name, value) = line.split_once(':')?;
1858 name.eq_ignore_ascii_case("proxy-authorization")
1859 .then_some(value.trim())
1860 })
1861 .expect("Proxy-Authorization header");
1862 let expected_auth = format!("Basic {}", BASE64.encode(format!("{USERNAME}:{PASSWORD}")));
1863
1864 assert_eq!(request_line, "CONNECT fixture.example.test:443 HTTP/1.1");
1865 assert_eq!(auth_value, expected_auth);
1866 assert!(!error.to_string().contains(PASSWORD));
1867 assert!(!error.to_string().contains(&BASE64.encode(PASSWORD)));
1868 assert!(!error.to_string().contains(&expected_auth));
1869 }
1870
1871 #[tokio::test]
1872 async fn test_http_client_unreachable_proxy_error_redacts_credentials() {
1873 const USERNAME: &str = "proxy-user";
1874 const SECRET: &str = "unreachable-proxy-secret";
1875 let (proxy_addr, drop_task) = spawn_connection_dropper().await;
1876 let client = HttpClient::builder()
1877 .timeout_secs(1)
1878 .proxy_url(format!("http://{USERNAME}:{SECRET}@{proxy_addr}"))
1879 .build()
1880 .unwrap();
1881 let error = client
1882 .request(
1883 Method::GET,
1884 "https://fixture.example.test/".to_string(),
1885 None,
1886 None,
1887 None,
1888 None,
1889 None,
1890 )
1891 .await
1892 .expect_err("unreachable proxy should fail");
1893 drop_task.abort();
1894 let task_error = drop_task
1895 .await
1896 .expect_err("connection dropper should be cancelled");
1897
1898 assert!(task_error.is_cancelled());
1899 assert!(!error.to_string().contains(SECRET));
1900 assert!(!error.to_string().contains(&BASE64.encode(SECRET)));
1901 assert!(
1902 !error
1903 .to_string()
1904 .contains(&BASE64.encode(format!("{USERNAME}:{SECRET}")))
1905 );
1906 }
1907
1908 #[rstest]
1909 fn test_http_client_with_valid_proxy() {
1910 let result = HttpClient::builder()
1912 .proxy_url("http://proxy.example.com:8080".to_string())
1913 .build();
1914
1915 assert!(result.is_ok());
1916 }
1917
1918 #[rstest]
1919 fn test_http_client_with_socks5_proxy() {
1920 let result = HttpClient::builder()
1922 .proxy_url("socks5://127.0.0.1:1080".to_string())
1923 .build();
1924
1925 assert!(result.is_ok());
1926 }
1927
1928 #[rstest]
1929 fn test_http_client_with_malformed_proxy() {
1930 let result = HttpClient::builder()
1934 .proxy_url("://invalid".to_string())
1935 .build();
1936
1937 assert!(result.is_err());
1938 assert!(matches!(result, Err(HttpClientError::InvalidProxy(_))));
1939 }
1940
1941 #[rstest]
1942 fn test_http_client_invalid_proxy_error_redacts_credentials() {
1943 const SECRET: &str = "unique-proxy-secret";
1944 let result = HttpClient::builder()
1945 .proxy_url(format!("http://proxytest:{SECRET}@[::1"))
1946 .build();
1947 let error = result.expect_err("malformed proxy URL should fail");
1948
1949 assert_eq!(
1950 error.to_string(),
1951 "Invalid proxy URL: proxy URL is malformed"
1952 );
1953 assert!(!error.to_string().contains(SECRET));
1954 }
1955
1956 #[rstest]
1957 fn test_http_client_with_empty_proxy_string() {
1958 let result = HttpClient::builder().proxy_url(String::new()).build();
1960
1961 assert!(result.is_err());
1962 assert!(matches!(result, Err(HttpClientError::InvalidProxy(_))));
1963 }
1964
1965 #[tokio::test]
1966 async fn test_http_client_get() {
1967 let addr = start_test_server().await.unwrap();
1968 let url = format!("http://{addr}/get");
1969
1970 let client = HttpClient::builder().build().unwrap();
1971 let response = client.get(url, None, None, None, None).await.unwrap();
1972
1973 assert_eq!(response.status.as_u16(), StatusCode::OK.as_u16());
1974 assert_eq!(response.headers, HashMap::new());
1975 assert_eq!(response.body.as_ref(), b"hello-world!");
1976 }
1977
1978 #[tokio::test]
1979 async fn test_http_client_post() {
1980 let addr = start_test_server().await.unwrap();
1981 let url = format!("http://{addr}/post");
1982
1983 let client = HttpClient::builder().build().unwrap();
1984 let response = client
1985 .post(url, None, None, Some(b"post-body-73".to_vec()), None, None)
1986 .await
1987 .unwrap();
1988
1989 assert_eq!(response.status.as_u16(), StatusCode::OK.as_u16());
1990 assert_eq!(response.headers, HashMap::new());
1991 assert_eq!(response.body.as_ref(), b"post-body-73");
1992 }
1993
1994 #[tokio::test]
1995 async fn test_http_client_patch() {
1996 let addr = start_test_server().await.unwrap();
1997 let url = format!("http://{addr}/patch");
1998
1999 let client = HttpClient::builder().build().unwrap();
2000 let response = client
2001 .patch(url, None, None, Some(b"patch-body-91".to_vec()), None, None)
2002 .await
2003 .unwrap();
2004
2005 assert_eq!(response.status.as_u16(), StatusCode::OK.as_u16());
2006 assert_eq!(response.headers, HashMap::new());
2007 assert_eq!(response.body.as_ref(), b"patch-body-91");
2008 }
2009
2010 #[tokio::test]
2011 async fn test_http_client_delete() {
2012 let addr = start_test_server().await.unwrap();
2013 let url = format!("http://{addr}/delete");
2014
2015 let client = HttpClient::builder().build().unwrap();
2016 let response = client.delete(url, None, None, None, None).await.unwrap();
2017
2018 assert_eq!(response.status.as_u16(), StatusCode::OK.as_u16());
2019 assert_eq!(response.headers, HashMap::new());
2020 assert_eq!(response.body.as_ref(), b"");
2021 }
2022}
2023
2024#[cfg(test)]
2025mod rate_limit_tests {
2026 use std::{num::NonZeroU32, sync::Arc, time::Duration};
2027
2028 #[cfg(all(feature = "simulation", madsim))]
2029 use madsim::task as test_task;
2030 #[cfg(not(all(feature = "simulation", madsim)))]
2031 use tokio::task as test_task;
2032 use ustr::Ustr;
2033
2034 use super::HttpClient;
2035 use crate::ratelimiter::{RateLimiter, quota::Quota};
2036
2037 #[tokio::test]
2038 async fn test_http_client_awaits_multiple_rate_limiters() {
2039 let quota = Quota::per_minute(NonZeroU32::MIN);
2040 let request_key = Ustr::from("scope:request");
2041 let order_key = Ustr::from("scope:order");
2042 let request_limiter = Arc::new(RateLimiter::new_with_quota(
2043 None,
2044 vec![(request_key, quota)],
2045 ));
2046 let order_limiter = Arc::new(RateLimiter::new_with_quota(None, vec![(order_key, quota)]));
2047 let client = HttpClient::builder()
2048 .rate_limiters(vec![
2049 Arc::clone(&request_limiter),
2050 Arc::clone(&order_limiter),
2051 ])
2052 .build()
2053 .unwrap();
2054
2055 client
2056 .await_rate_limits(Some(&[request_key, order_key]))
2057 .await;
2058
2059 assert!(request_limiter.check_key(&request_key).is_err());
2060 assert!(order_limiter.check_key(&order_key).is_err());
2061 }
2062
2063 #[cfg_attr(
2064 not(all(feature = "simulation", madsim)),
2065 tokio::test(start_paused = true)
2066 )]
2067 #[cfg_attr(all(feature = "simulation", madsim), madsim::test)]
2068 async fn test_http_client_reserves_multiple_rate_limits_together() {
2069 let global_key = Ustr::from("scope:global");
2070 let order_key = Ustr::from("scope:order");
2071 let global_limiter = Arc::new(RateLimiter::new_with_quota(
2072 None,
2073 vec![(
2074 global_key,
2075 Quota::with_period(Duration::from_secs(1)).unwrap(),
2076 )],
2077 ));
2078 let order_limiter = Arc::new(RateLimiter::new_with_quota(
2079 None,
2080 vec![(
2081 order_key,
2082 Quota::with_period(Duration::from_secs(10)).unwrap(),
2083 )],
2084 ));
2085 order_limiter.check_key(&order_key).unwrap();
2086
2087 let client = HttpClient::builder()
2088 .rate_limiters(vec![
2089 Arc::clone(&global_limiter),
2090 Arc::clone(&order_limiter),
2091 ])
2092 .build()
2093 .unwrap();
2094
2095 let request = test_task::spawn(async move {
2096 client
2097 .await_rate_limits(Some(&[global_key, order_key]))
2098 .await;
2099 });
2100 test_task::yield_now().await;
2101
2102 global_limiter.check_key(&global_key).unwrap();
2103 assert!(!request.is_finished());
2104
2105 advance_test_clock(Duration::from_millis(9_999)).await;
2106 global_limiter.until_key_ready(&global_key).await;
2107 global_limiter.until_key_ready(&global_key).await;
2108 advance_test_clock(Duration::from_millis(1)).await;
2109 test_task::yield_now().await;
2110 assert!(!request.is_finished());
2111
2112 advance_test_clock(Duration::from_millis(998)).await;
2113 test_task::yield_now().await;
2114 assert!(!request.is_finished());
2115
2116 advance_test_clock(Duration::from_millis(1)).await;
2117 request.await.unwrap();
2118
2119 assert!(global_limiter.check_key(&global_key).is_err());
2120 assert!(order_limiter.check_key(&order_key).is_err());
2121 }
2122
2123 #[cfg(all(feature = "simulation", madsim))]
2124 async fn advance_test_clock(duration: Duration) {
2125 madsim::time::advance(duration);
2126 test_task::yield_now().await;
2127 }
2128
2129 #[cfg(not(all(feature = "simulation", madsim)))]
2130 async fn advance_test_clock(duration: Duration) {
2131 tokio::time::advance(duration).await;
2132 }
2133}