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