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