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