1use std::{borrow::Cow, collections::HashMap, str::FromStr, sync::Arc, time::Duration};
19
20use nautilus_core::collections::into_ustr_vec;
21use nautilus_cryptography::providers::install_cryptographic_provider;
22use reqwest::{
23 Method, Response, Url,
24 header::{HeaderMap, HeaderName, HeaderValue},
25};
26use ustr::Ustr;
27
28use super::{HttpClientError, HttpResponse, HttpStatus};
29use crate::ratelimiter::{RateLimiter, clock::MonotonicClock, quota::Quota};
30
31const DEFAULT_POOL_MAX_IDLE_PER_HOST: usize = 32;
33
34const DEFAULT_POOL_IDLE_TIMEOUT_SECS: u64 = 60;
36
37const DEFAULT_HTTP2_KEEP_ALIVE_SECS: u64 = 30;
39
40const DEFAULT_MAX_RESPONSE_BYTES: usize = 100 * 1024 * 1024;
46
47#[derive(Clone, Debug)]
52pub struct HttpClient {
53 pub(crate) client: InnerHttpClient,
54 pub(crate) rate_limiters: Arc<[Arc<RateLimiter<Ustr, MonotonicClock>>]>,
55}
56
57impl HttpClient {
58 pub fn new(
65 headers: HashMap<String, String>,
66 header_keys: Vec<String>,
67 keyed_quotas: Vec<(String, Quota)>,
68 default_quota: Option<Quota>,
69 timeout_secs: Option<u64>,
70 proxy_url: Option<String>,
71 ) -> Result<Self, HttpClientError> {
72 let keyed_quotas = keyed_quotas
73 .into_iter()
74 .map(|(key, quota)| (Ustr::from(&key), quota))
75 .collect();
76
77 let rate_limiter = Arc::new(RateLimiter::new_with_quota(default_quota, keyed_quotas));
78
79 Self::new_with_rate_limiter(headers, header_keys, timeout_secs, proxy_url, rate_limiter)
80 }
81
82 pub fn new_with_rate_limiter(
95 headers: HashMap<String, String>,
96 header_keys: Vec<String>,
97 timeout_secs: Option<u64>,
98 proxy_url: Option<String>,
99 rate_limiter: Arc<RateLimiter<Ustr, MonotonicClock>>,
100 ) -> Result<Self, HttpClientError> {
101 Self::new_with_rate_limiters(
102 headers,
103 header_keys,
104 timeout_secs,
105 proxy_url,
106 vec![rate_limiter],
107 )
108 }
109
110 pub fn new_with_rate_limiters(
121 headers: HashMap<String, String>,
122 header_keys: Vec<String>,
123 timeout_secs: Option<u64>,
124 proxy_url: Option<String>,
125 rate_limiters: Vec<Arc<RateLimiter<Ustr, MonotonicClock>>>,
126 ) -> Result<Self, HttpClientError> {
127 install_cryptographic_provider();
128
129 let mut header_map = HeaderMap::new();
131
132 for (key, value) in headers {
133 let header_name = HeaderName::from_str(&key)
134 .map_err(|e| HttpClientError::Error(format!("Invalid header name '{key}': {e}")))?;
135 let header_value = HeaderValue::from_str(&value).map_err(|e| {
136 HttpClientError::Error(format!("Invalid header value for '{key}': {e}"))
137 })?;
138 header_map.insert(header_name, header_value);
139 }
140
141 let mut client_builder = reqwest::Client::builder()
142 .default_headers(header_map)
143 .tcp_nodelay(true)
144 .pool_max_idle_per_host(DEFAULT_POOL_MAX_IDLE_PER_HOST)
145 .pool_idle_timeout(Duration::from_secs(DEFAULT_POOL_IDLE_TIMEOUT_SECS))
146 .http2_keep_alive_interval(Duration::from_secs(DEFAULT_HTTP2_KEEP_ALIVE_SECS))
147 .http2_keep_alive_while_idle(true)
148 .http2_adaptive_window(true);
149
150 if let Some(timeout_secs) = timeout_secs {
151 client_builder = client_builder.timeout(Duration::from_secs(timeout_secs));
152 }
153
154 if let Some(proxy_url) = proxy_url {
156 let proxy = reqwest::Proxy::all(&proxy_url)
157 .map_err(|_| HttpClientError::InvalidProxy("proxy URL is malformed".to_string()))?;
158 client_builder = client_builder.proxy(proxy);
159 }
160
161 let client = client_builder
162 .build()
163 .map_err(|e| HttpClientError::ClientBuildError(e.to_string()))?;
164
165 let response_headers = header_keys
168 .into_iter()
169 .map(|key| match HeaderName::from_str(&key) {
170 Ok(name) => Ok((key, name)),
171 Err(e) => Err(HttpClientError::Error(format!(
172 "Invalid header key '{key}': {e}"
173 ))),
174 })
175 .collect::<Result<Vec<_>, _>>()?;
176
177 let client = InnerHttpClient {
178 client,
179 response_headers: Arc::from(response_headers),
180 max_response_bytes: DEFAULT_MAX_RESPONSE_BYTES,
181 };
182
183 Ok(Self {
184 client,
185 rate_limiters: rate_limiters.into(),
186 })
187 }
188
189 #[expect(clippy::too_many_arguments)]
199 pub async fn request(
200 &self,
201 method: Method,
202 url: String,
203 params: Option<&HashMap<String, Vec<String>>>,
204 headers: Option<HashMap<String, String>>,
205 body: Option<Vec<u8>>,
206 timeout_secs: Option<u64>,
207 keys: Option<Vec<String>>,
208 ) -> Result<HttpResponse, HttpClientError> {
209 let keys = keys.map(into_ustr_vec);
210
211 self.request_with_ustr_keys(method, url, params, headers, body, timeout_secs, keys)
212 .await
213 }
214
215 #[expect(clippy::too_many_arguments)]
223 pub async fn request_with_url_redacted(
224 &self,
225 method: Method,
226 url: String,
227 params: Option<&HashMap<String, Vec<String>>>,
228 headers: Option<HashMap<String, String>>,
229 body: Option<Vec<u8>>,
230 timeout_secs: Option<u64>,
231 keys: Option<Vec<String>>,
232 ) -> Result<HttpResponse, HttpClientError> {
233 let keys = keys.map(into_ustr_vec);
234 self.await_rate_limits(keys.as_deref()).await;
235
236 self.client
237 .send_request_with_url_redacted(method, url, params, headers, body, timeout_secs)
238 .await
239 }
240
241 #[expect(clippy::too_many_arguments)]
251 pub async fn request_with_params<P: serde::Serialize>(
252 &self,
253 method: Method,
254 url: String,
255 params: Option<&P>,
256 headers: Option<HashMap<String, String>>,
257 body: Option<Vec<u8>>,
258 timeout_secs: Option<u64>,
259 keys: Option<Vec<String>>,
260 ) -> Result<HttpResponse, HttpClientError> {
261 let keys = keys.map(into_ustr_vec);
262 self.await_rate_limits(keys.as_deref()).await;
263
264 self.client
265 .send_request_with_query(method, url, params, headers, body, timeout_secs)
266 .await
267 }
268
269 #[expect(clippy::too_many_arguments)]
275 pub async fn request_with_ustr_keys(
276 &self,
277 method: Method,
278 url: String,
279 params: Option<&HashMap<String, Vec<String>>>,
280 headers: Option<HashMap<String, String>>,
281 body: Option<Vec<u8>>,
282 timeout_secs: Option<u64>,
283 keys: Option<Vec<Ustr>>,
284 ) -> Result<HttpResponse, HttpClientError> {
285 self.await_rate_limits(keys.as_deref()).await;
286
287 self.client
288 .send_request(method, url, params, headers, body, timeout_secs)
289 .await
290 }
291
292 pub(crate) async fn await_rate_limits(&self, keys: Option<&[Ustr]>) {
293 for rate_limiter in self.rate_limiters.iter() {
294 rate_limiter.await_keys_ready(keys).await;
295 }
296 }
297
298 pub async fn get(
304 &self,
305 url: String,
306 params: Option<&HashMap<String, Vec<String>>>,
307 headers: Option<HashMap<String, String>>,
308 timeout_secs: Option<u64>,
309 keys: Option<Vec<String>>,
310 ) -> Result<HttpResponse, HttpClientError> {
311 self.request(Method::GET, url, params, headers, None, timeout_secs, keys)
312 .await
313 }
314
315 pub async fn post(
321 &self,
322 url: String,
323 params: Option<&HashMap<String, Vec<String>>>,
324 headers: Option<HashMap<String, String>>,
325 body: Option<Vec<u8>>,
326 timeout_secs: Option<u64>,
327 keys: Option<Vec<String>>,
328 ) -> Result<HttpResponse, HttpClientError> {
329 self.request(Method::POST, url, params, headers, body, timeout_secs, keys)
330 .await
331 }
332
333 pub async fn patch(
339 &self,
340 url: String,
341 params: Option<&HashMap<String, Vec<String>>>,
342 headers: Option<HashMap<String, String>>,
343 body: Option<Vec<u8>>,
344 timeout_secs: Option<u64>,
345 keys: Option<Vec<String>>,
346 ) -> Result<HttpResponse, HttpClientError> {
347 self.request(
348 Method::PATCH,
349 url,
350 params,
351 headers,
352 body,
353 timeout_secs,
354 keys,
355 )
356 .await
357 }
358
359 pub async fn delete(
365 &self,
366 url: String,
367 params: Option<&HashMap<String, Vec<String>>>,
368 headers: Option<HashMap<String, String>>,
369 timeout_secs: Option<u64>,
370 keys: Option<Vec<String>>,
371 ) -> Result<HttpResponse, HttpClientError> {
372 self.request(
373 Method::DELETE,
374 url,
375 params,
376 headers,
377 None,
378 timeout_secs,
379 keys,
380 )
381 .await
382 }
383}
384
385#[derive(Clone, Debug)]
390pub struct InnerHttpClient {
391 pub(crate) client: reqwest::Client,
392 pub(crate) response_headers: Arc<[(String, HeaderName)]>,
393 pub(crate) max_response_bytes: usize,
394}
395
396impl InnerHttpClient {
397 pub async fn send_request(
403 &self,
404 method: Method,
405 url: String,
406 params: Option<&HashMap<String, Vec<String>>>,
407 headers: Option<HashMap<String, String>>,
408 body: Option<Vec<u8>>,
409 timeout_secs: Option<u64>,
410 ) -> Result<HttpResponse, HttpClientError> {
411 self.send_request_with_redaction(method, url, params, headers, body, timeout_secs, false)
412 .await
413 }
414
415 async fn send_request_with_url_redacted(
416 &self,
417 method: Method,
418 url: String,
419 params: Option<&HashMap<String, Vec<String>>>,
420 headers: Option<HashMap<String, String>>,
421 body: Option<Vec<u8>>,
422 timeout_secs: Option<u64>,
423 ) -> Result<HttpResponse, HttpClientError> {
424 self.send_request_with_redaction(method, url, params, headers, body, timeout_secs, true)
425 .await
426 }
427
428 #[expect(clippy::too_many_arguments)]
429 async fn send_request_with_redaction(
430 &self,
431 method: Method,
432 url: String,
433 params: Option<&HashMap<String, Vec<String>>>,
434 headers: Option<HashMap<String, String>>,
435 body: Option<Vec<u8>>,
436 timeout_secs: Option<u64>,
437 redact_url: bool,
438 ) -> Result<HttpResponse, HttpClientError> {
439 let full_url = encode_url_params(&url, params)?;
440 self.send_request_internal(
441 method,
442 full_url.as_ref(),
443 None::<&()>,
444 headers,
445 body,
446 timeout_secs,
447 redact_url,
448 )
449 .await
450 }
451
452 pub async fn send_request_with_query<Q: serde::Serialize>(
461 &self,
462 method: Method,
463 url: String,
464 query: Option<&Q>,
465 headers: Option<HashMap<String, String>>,
466 body: Option<Vec<u8>>,
467 timeout_secs: Option<u64>,
468 ) -> Result<HttpResponse, HttpClientError> {
469 self.send_request_internal(method, &url, query, headers, body, timeout_secs, false)
470 .await
471 }
472
473 #[expect(clippy::too_many_arguments)]
479 async fn send_request_internal<Q: serde::Serialize>(
480 &self,
481 method: Method,
482 url: &str,
483 query: Option<&Q>,
484 headers: Option<HashMap<String, String>>,
485 body: Option<Vec<u8>>,
486 timeout_secs: Option<u64>,
487 redact_url: bool,
488 ) -> Result<HttpResponse, HttpClientError> {
489 let reqwest_url =
490 Url::parse(url).map_err(|e| HttpClientError::from(format!("URL parse error: {e}")))?;
491
492 let mut request_builder = self.client.request(method, reqwest_url);
493 let extra_header_count = headers.as_ref().map_or(0, HashMap::len);
494 let body_len = body.as_ref().map_or(0, Vec::len);
495
496 if let Some(headers) = headers {
497 let mut header_map = HeaderMap::with_capacity(headers.len());
498 for (header_key, header_value) in &headers {
499 let key = HeaderName::from_bytes(header_key.as_bytes())
500 .map_err(|e| HttpClientError::from(format!("Invalid header name: {e}")))?;
501
502 if header_map
503 .insert(
504 key.clone(),
505 header_value.parse().map_err(|e| {
506 HttpClientError::from(format!("Invalid header value: {e}"))
507 })?,
508 )
509 .is_some()
510 {
511 log::trace!("Replaced duplicate request header '{key}'");
512 }
513 }
514 request_builder = request_builder.headers(header_map);
515 }
516
517 if let Some(q) = query {
518 request_builder = request_builder.query(q);
519 }
520
521 if let Some(timeout_secs) = timeout_secs {
522 request_builder = request_builder.timeout(Duration::new(timeout_secs, 0));
523 }
524
525 let request = match body {
526 Some(b) => request_builder
527 .body(b)
528 .build()
529 .map_err(|e| http_client_error(e, redact_url))?,
530 None => request_builder
531 .build()
532 .map_err(|e| http_client_error(e, redact_url))?,
533 };
534
535 let query_len = request.url().query().map_or(0, str::len);
536 log::trace!(
537 "Sending HTTP request: method={} extra_headers={extra_header_count} \
538 query_bytes={query_len} body_bytes={body_len}",
539 request.method(),
540 );
541
542 let response = self
543 .client
544 .execute(request)
545 .await
546 .map_err(|e| http_client_error(e, redact_url))?;
547
548 self.to_response_internal(response, redact_url).await
549 }
550
551 pub async fn to_response(&self, response: Response) -> Result<HttpResponse, HttpClientError> {
559 self.to_response_internal(response, false).await
560 }
561
562 async fn to_response_internal(
563 &self,
564 response: Response,
565 redact_url: bool,
566 ) -> Result<HttpResponse, HttpClientError> {
567 let status_code = response.status();
568 let resp_headers = response.headers();
569 let header_count = resp_headers.len();
570 let mut headers = HashMap::with_capacity(std::cmp::min(
571 self.response_headers.len(),
572 resp_headers.len(),
573 ));
574
575 for (key, name) in self.response_headers.iter() {
576 if let Some(val) = resp_headers.get(name)
577 && let Ok(v) = val.to_str()
578 {
579 headers.insert(key.clone(), v.to_owned());
580 }
581 }
582
583 let status = HttpStatus::new(status_code);
584 let body = self.read_body_capped(response, redact_url).await?;
585
586 log::trace!(
587 "Received HTTP response: status={status_code} headers={header_count} body_bytes={}",
588 body.len(),
589 );
590
591 Ok(HttpResponse {
592 status,
593 headers,
594 body,
595 })
596 }
597
598 async fn read_body_capped(
610 &self,
611 mut response: Response,
612 redact_url: bool,
613 ) -> Result<bytes::Bytes, HttpClientError> {
614 let max = self.max_response_bytes;
615
616 if let Some(len) = response.content_length()
618 && len > max as u64
619 {
620 return Err(HttpClientError::Error(format!(
621 "HTTP response body of {len} bytes exceeds maximum of {max} bytes",
622 )));
623 }
624
625 let mut buf = bytes::BytesMut::new();
626
627 while let Some(chunk) = response
628 .chunk()
629 .await
630 .map_err(|e| http_client_error(e, redact_url))?
631 {
632 if buf.len() + chunk.len() > max {
633 return Err(HttpClientError::Error(format!(
634 "HTTP response body exceeds maximum of {max} bytes",
635 )));
636 }
637 buf.extend_from_slice(&chunk);
638 }
639
640 Ok(buf.freeze())
641 }
642}
643
644fn http_client_error(error: reqwest::Error, redact_url: bool) -> HttpClientError {
645 if redact_url {
646 HttpClientError::from(error.without_url())
647 } else {
648 HttpClientError::from(error)
649 }
650}
651
652impl Default for InnerHttpClient {
653 fn default() -> Self {
657 install_cryptographic_provider();
658 let client = reqwest::Client::new();
659 Self {
660 client,
661 response_headers: Arc::default(),
662 max_response_bytes: DEFAULT_MAX_RESPONSE_BYTES,
663 }
664 }
665}
666
667fn encode_url_params<'a>(
674 url: &'a str,
675 params: Option<&HashMap<String, Vec<String>>>,
676) -> Result<Cow<'a, str>, HttpClientError> {
677 let Some(params) = params else {
678 return Ok(Cow::Borrowed(url));
679 };
680
681 let pairs: Vec<(&str, &str)> = params
682 .iter()
683 .flat_map(|(key, values)| {
684 values
685 .iter()
686 .map(move |value| (key.as_str(), value.as_str()))
687 })
688 .collect();
689
690 if pairs.is_empty() {
691 return Ok(Cow::Borrowed(url));
692 }
693
694 let query_string = serde_urlencoded::to_string(pairs)
695 .map_err(|e| HttpClientError::Error(format!("Failed to encode params: {e}")))?;
696
697 let (base, fragment) = match url.split_once('#') {
700 Some((base, fragment)) => (base, Some(fragment)),
701 None => (url, None),
702 };
703 let separator = if base.contains('?') { '&' } else { '?' };
704
705 Ok(Cow::Owned(match fragment {
706 Some(fragment) => format!("{base}{separator}{query_string}#{fragment}"),
707 None => format!("{base}{separator}{query_string}"),
708 }))
709}
710
711#[cfg(test)]
712mod encode_url_params_tests {
713 use std::{borrow::Cow, collections::HashMap};
714
715 use rstest::rstest;
716
717 use super::encode_url_params;
718
719 fn params(pairs: &[(&str, &str)]) -> HashMap<String, Vec<String>> {
720 let mut map: HashMap<String, Vec<String>> = HashMap::new();
721
722 for (key, value) in pairs {
723 map.entry((*key).to_string())
724 .or_default()
725 .push((*value).to_string());
726 }
727
728 map
729 }
730
731 #[rstest]
732 #[case("https://x/y", "https://x/y?a=b")]
733 #[case("https://x/y?old=1", "https://x/y?old=1&a=b")]
734 #[case("https://x/y#frag", "https://x/y?a=b#frag")]
735 #[case("https://x/y?old=1#frag", "https://x/y?old=1&a=b#frag")]
736 #[case(
737 "https://x/y#section?display=full",
738 "https://x/y?a=b#section?display=full"
739 )]
740 #[case("https://x/y#", "https://x/y?a=b#")]
741 fn test_query_is_inserted_before_the_fragment(#[case] url: &str, #[case] expected: &str) {
742 let params = params(&[("a", "b")]);
743
744 assert_eq!(encode_url_params(url, Some(¶ms)).unwrap(), expected);
745 }
746
747 #[rstest]
748 fn test_url_is_borrowed_when_no_params_are_supplied() {
749 assert!(matches!(
750 encode_url_params("https://x/y#frag", None).unwrap(),
751 Cow::Borrowed("https://x/y#frag")
752 ));
753 }
754
755 #[rstest]
756 fn test_url_is_borrowed_when_params_are_empty() {
757 let params = HashMap::new();
758
759 assert!(matches!(
760 encode_url_params("https://x/y#frag", Some(¶ms)).unwrap(),
761 Cow::Borrowed("https://x/y#frag")
762 ));
763 }
764}
765
766#[cfg(test)]
767#[cfg(target_os = "linux")] mod tests {
769 use std::{net::SocketAddr, num::NonZeroU32, sync::Mutex};
770
771 use axum::{
772 Router,
773 body::to_bytes,
774 extract::Request,
775 response::IntoResponse,
776 routing::{any, delete, get, patch, post},
777 serve,
778 };
779 use base64::{Engine as _, engine::general_purpose::STANDARD as BASE64};
780 use http::status::StatusCode;
781 use log::{Level, LevelFilter, Log, Metadata, Record};
782 use rstest::rstest;
783 use tokio::{
784 io::{AsyncReadExt, AsyncWriteExt},
785 sync::oneshot,
786 };
787
788 use super::*;
789
790 async fn capture_request(request: Request) -> impl IntoResponse {
791 let (parts, body) = request.into_parts();
792 let body = to_bytes(body, usize::MAX).await.unwrap();
793 let default_header = parts.headers.get("x-default").unwrap().to_str().unwrap();
794 let request_header = parts.headers.get("x-request").unwrap().to_str().unwrap();
795 let query = parts.uri.query().unwrap_or_default();
796 let body = String::from_utf8(body.to_vec()).unwrap();
797 let capture = format!(
798 "{}\n{}\n{query}\n{default_header}\n{request_header}\n{body}",
799 parts.method,
800 parts.uri.path(),
801 );
802
803 ([("x-response-id", "response-42")], capture)
804 }
805
806 #[derive(Default)]
807 struct CapturingTraceLogger {
808 messages: Mutex<Vec<(Level, String)>>,
809 }
810
811 impl CapturingTraceLogger {
812 fn clear(&self) {
813 self.messages.lock().unwrap().clear();
814 }
815
816 fn messages(&self) -> Vec<(Level, String)> {
817 self.messages.lock().unwrap().clone()
818 }
819 }
820
821 impl Log for CapturingTraceLogger {
822 fn enabled(&self, metadata: &Metadata<'_>) -> bool {
823 metadata.level() <= Level::Trace
824 }
825
826 fn log(&self, record: &Record<'_>) {
827 if self.enabled(record.metadata()) {
828 self.messages
829 .lock()
830 .unwrap()
831 .push((record.level(), record.args().to_string()));
832 }
833 }
834
835 fn flush(&self) {}
836 }
837
838 static CAPTURING_TRACE_LOGGER: CapturingTraceLogger = CapturingTraceLogger {
839 messages: Mutex::new(Vec::new()),
840 };
841
842 fn create_router() -> Router {
843 Router::new()
844 .route("/get", get(|| async { "hello-world!" }))
845 .route("/post", post(|| async { StatusCode::OK }))
846 .route("/patch", patch(|| async { StatusCode::OK }))
847 .route("/delete", delete(|| async { StatusCode::OK }))
848 .route("/capture", any(capture_request))
849 .route("/notfound", get(|| async { StatusCode::NOT_FOUND }))
850 .route(
851 "/slow",
852 get(|| async {
853 tokio::time::sleep(Duration::from_secs(2)).await;
854 "Eventually responded"
855 }),
856 )
857 .route(
858 "/large",
859 get(|| async { "x".repeat(1024 * 1024) }),
861 )
862 }
863
864 async fn start_test_server() -> Result<SocketAddr, Box<dyn std::error::Error + Send + Sync>> {
865 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
866 let addr = listener.local_addr().unwrap();
867
868 tokio::spawn(async move {
869 serve(listener, create_router()).await.unwrap();
870 });
871
872 Ok(addr)
873 }
874
875 async fn spawn_rejecting_connect_proxy() -> (SocketAddr, oneshot::Receiver<String>) {
876 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
877 let addr = listener.local_addr().unwrap();
878 let (request_tx, request_rx) = oneshot::channel();
879
880 tokio::spawn(async move {
881 let (mut stream, _) = listener.accept().await.unwrap();
882 let mut request = Vec::new();
883 let mut chunk = [0u8; 1024];
884 loop {
885 let read = stream.read(&mut chunk).await.unwrap();
886 if read == 0 {
887 break;
888 }
889 request.extend_from_slice(&chunk[..read]);
890 if request.windows(4).any(|window| window == b"\r\n\r\n") {
891 break;
892 }
893 }
894 request_tx
895 .send(String::from_utf8(request).unwrap())
896 .unwrap();
897 stream
898 .write_all(
899 b"HTTP/1.1 407 Proxy Authentication Required\r\nContent-Length: 0\r\n\r\n",
900 )
901 .await
902 .unwrap();
903 });
904
905 (addr, request_rx)
906 }
907
908 #[tokio::test]
909 async fn test_http_client_awaits_multiple_rate_limiters() {
910 let quota = Quota::per_minute(NonZeroU32::MIN);
911 let request_key = Ustr::from("scope:request");
912 let order_key = Ustr::from("scope:order");
913 let request_limiter = Arc::new(RateLimiter::new_with_quota(
914 None,
915 vec![(request_key, quota)],
916 ));
917 let order_limiter = Arc::new(RateLimiter::new_with_quota(None, vec![(order_key, quota)]));
918 let client = HttpClient::new_with_rate_limiters(
919 HashMap::new(),
920 Vec::new(),
921 None,
922 None,
923 vec![Arc::clone(&request_limiter), Arc::clone(&order_limiter)],
924 )
925 .unwrap();
926
927 client
928 .await_rate_limits(Some(&[request_key, order_key]))
929 .await;
930
931 assert!(request_limiter.check_key(&request_key).is_err());
932 assert!(order_limiter.check_key(&order_key).is_err());
933 }
934
935 #[tokio::test]
936 async fn test_get() {
937 let addr = start_test_server().await.unwrap();
938 let url = format!("http://{addr}");
939
940 let client = InnerHttpClient::default();
941 let response = client
942 .send_request(
943 reqwest::Method::GET,
944 format!("{url}/get"),
945 None,
946 None,
947 None,
948 None,
949 )
950 .await
951 .unwrap();
952
953 assert_eq!(response.status.as_u16(), StatusCode::OK.as_u16());
954 assert_eq!(String::from_utf8_lossy(&response.body), "hello-world!");
955 }
956
957 #[tokio::test]
958 async fn test_request_preserves_wire_semantics_and_extracts_response_headers() {
959 let addr = start_test_server().await.unwrap();
960 let mut default_headers = HashMap::new();
961 default_headers.insert("x-default".to_string(), "default-a".to_string());
962 let client = HttpClient::new(
963 default_headers,
964 vec!["x-response-id".to_string()],
965 vec![],
966 None,
967 None,
968 None,
969 )
970 .unwrap();
971 let mut params = HashMap::new();
972 params.insert(
973 "tag".to_string(),
974 vec!["A B".to_string(), "C/D".to_string()],
975 );
976 let mut request_headers = HashMap::new();
977 request_headers.insert("x-request".to_string(), "request-b".to_string());
978
979 let response = client
980 .request(
981 Method::PUT,
982 format!("http://{addr}/capture?existing=seed"),
983 Some(¶ms),
984 Some(request_headers),
985 Some(b"payload-c".to_vec()),
986 None,
987 None,
988 )
989 .await
990 .unwrap();
991
992 assert_eq!(response.status.as_u16(), StatusCode::OK.as_u16());
993 assert_eq!(
994 response.headers,
995 HashMap::from([("x-response-id".to_string(), "response-42".to_string())])
996 );
997 assert_eq!(
998 response.body.as_ref(),
999 b"PUT\n/capture\nexisting=seed&tag=A+B&tag=C%2FD\ndefault-a\nrequest-b\npayload-c"
1000 );
1001 }
1002
1003 #[tokio::test]
1004 async fn test_request_with_params_serializes_query_fields() {
1005 #[derive(serde::Serialize)]
1006 struct Query<'a> {
1007 symbol: &'a str,
1008 limit: u32,
1009 }
1010
1011 let addr = start_test_server().await.unwrap();
1012 let mut default_headers = HashMap::new();
1013 default_headers.insert("x-default".to_string(), "default-d".to_string());
1014 let client = HttpClient::new(
1015 default_headers,
1016 vec!["x-response-id".to_string()],
1017 vec![],
1018 None,
1019 None,
1020 None,
1021 )
1022 .unwrap();
1023 let mut request_headers = HashMap::new();
1024 request_headers.insert("x-request".to_string(), "request-e".to_string());
1025 let params = Query {
1026 symbol: "BTC/USDT",
1027 limit: 37,
1028 };
1029
1030 let response = client
1031 .request_with_params(
1032 Method::GET,
1033 format!("http://{addr}/capture"),
1034 Some(¶ms),
1035 Some(request_headers),
1036 None,
1037 None,
1038 None,
1039 )
1040 .await
1041 .unwrap();
1042
1043 assert_eq!(response.status.as_u16(), StatusCode::OK.as_u16());
1044 assert_eq!(
1045 response.headers,
1046 HashMap::from([("x-response-id".to_string(), "response-42".to_string())])
1047 );
1048 assert_eq!(
1049 response.body.as_ref(),
1050 b"GET\n/capture\nsymbol=BTC%2FUSDT&limit=37\ndefault-d\nrequest-e\n"
1051 );
1052 }
1053
1054 #[tokio::test]
1055 async fn test_response_body_within_cap_is_returned() {
1056 let addr = start_test_server().await.unwrap();
1057 let url = format!("http://{addr}");
1058
1059 let client = InnerHttpClient {
1061 max_response_bytes: 4 * 1024 * 1024,
1062 ..Default::default()
1063 };
1064
1065 let response = client
1066 .send_request(
1067 reqwest::Method::GET,
1068 format!("{url}/large"),
1069 None,
1070 None,
1071 None,
1072 None,
1073 )
1074 .await
1075 .unwrap();
1076
1077 assert_eq!(response.status.as_u16(), StatusCode::OK.as_u16());
1078 assert_eq!(response.body.len(), 1024 * 1024);
1079 }
1080
1081 #[tokio::test]
1082 async fn test_response_body_exceeding_cap_is_rejected() {
1083 let addr = start_test_server().await.unwrap();
1084 let url = format!("http://{addr}");
1085
1086 let client = InnerHttpClient {
1088 max_response_bytes: 16 * 1024,
1089 ..Default::default()
1090 };
1091
1092 let result = client
1093 .send_request(
1094 reqwest::Method::GET,
1095 format!("{url}/large"),
1096 None,
1097 None,
1098 None,
1099 None,
1100 )
1101 .await;
1102
1103 let err = result.expect_err("oversized response body should be rejected");
1104 assert!(
1105 err.to_string().contains("exceeds maximum"),
1106 "unexpected error: {err}",
1107 );
1108 }
1109
1110 #[tokio::test]
1111 async fn test_post() {
1112 let addr = start_test_server().await.unwrap();
1113 let url = format!("http://{addr}");
1114
1115 let client = InnerHttpClient::default();
1116 let response = client
1117 .send_request(
1118 reqwest::Method::POST,
1119 format!("{url}/post"),
1120 None,
1121 None,
1122 None,
1123 None,
1124 )
1125 .await
1126 .unwrap();
1127
1128 assert_eq!(response.status.as_u16(), StatusCode::OK.as_u16());
1129 }
1130
1131 #[tokio::test]
1132 async fn test_post_with_body() {
1133 let addr = start_test_server().await.unwrap();
1134 let url = format!("http://{addr}");
1135
1136 let client = InnerHttpClient::default();
1137
1138 let mut body = HashMap::new();
1139 body.insert(
1140 "key1".to_string(),
1141 serde_json::Value::String("value1".to_string()),
1142 );
1143 body.insert(
1144 "key2".to_string(),
1145 serde_json::Value::String("value2".to_string()),
1146 );
1147
1148 let body_string = serde_json::to_string(&body).unwrap();
1149 let body_bytes = body_string.into_bytes();
1150
1151 let response = client
1152 .send_request(
1153 reqwest::Method::POST,
1154 format!("{url}/post"),
1155 None,
1156 None,
1157 Some(body_bytes),
1158 None,
1159 )
1160 .await
1161 .unwrap();
1162
1163 assert_eq!(response.status.as_u16(), StatusCode::OK.as_u16());
1164 }
1165
1166 #[tokio::test]
1167 async fn test_patch() {
1168 let addr = start_test_server().await.unwrap();
1169 let url = format!("http://{addr}");
1170
1171 let client = InnerHttpClient::default();
1172 let response = client
1173 .send_request(
1174 reqwest::Method::PATCH,
1175 format!("{url}/patch"),
1176 None,
1177 None,
1178 None,
1179 None,
1180 )
1181 .await
1182 .unwrap();
1183
1184 assert_eq!(response.status.as_u16(), StatusCode::OK.as_u16());
1185 }
1186
1187 #[tokio::test]
1188 async fn test_delete() {
1189 let addr = start_test_server().await.unwrap();
1190 let url = format!("http://{addr}");
1191
1192 let client = InnerHttpClient::default();
1193 let response = client
1194 .send_request(
1195 reqwest::Method::DELETE,
1196 format!("{url}/delete"),
1197 None,
1198 None,
1199 None,
1200 None,
1201 )
1202 .await
1203 .unwrap();
1204
1205 assert_eq!(response.status.as_u16(), StatusCode::OK.as_u16());
1206 }
1207
1208 #[tokio::test]
1209 async fn test_not_found() {
1210 let addr = start_test_server().await.unwrap();
1211 let url = format!("http://{addr}/notfound");
1212 let client = InnerHttpClient::default();
1213
1214 let response = client
1215 .send_request(reqwest::Method::GET, url, None, None, None, None)
1216 .await
1217 .unwrap();
1218
1219 assert!(response.status.is_client_error());
1220 assert_eq!(response.status.as_u16(), 404);
1221 }
1222
1223 #[tokio::test]
1224 async fn test_timeout() {
1225 let addr = start_test_server().await.unwrap();
1226 let url = format!("http://{addr}/slow");
1227 let client = InnerHttpClient::default();
1228
1229 let result = client
1231 .send_request(reqwest::Method::GET, url, None, None, None, Some(1))
1232 .await;
1233
1234 assert!(
1235 matches!(&result, Err(HttpClientError::TimeoutError(_))),
1236 "Expected a timeout error, was: {result:?}"
1237 );
1238 }
1239
1240 #[rstest]
1241 fn test_http_client_without_proxy() {
1242 let result = HttpClient::new(
1244 HashMap::new(),
1245 vec![],
1246 vec![],
1247 None,
1248 None,
1249 None, );
1251
1252 assert!(result.is_ok());
1253 }
1254
1255 #[tokio::test]
1256 async fn test_http_client_without_proxy_requests_directly() {
1257 let addr = start_test_server().await.unwrap();
1258 let client = HttpClient::new(HashMap::new(), vec![], vec![], None, Some(2), None).unwrap();
1259 let response = client
1260 .request(
1261 Method::GET,
1262 format!("http://{addr}/get"),
1263 None,
1264 None,
1265 None,
1266 None,
1267 None,
1268 )
1269 .await
1270 .expect("direct request");
1271
1272 assert_eq!(response.status.as_u16(), StatusCode::OK.as_u16());
1273 assert_eq!(response.body.as_ref(), b"hello-world!");
1274 }
1275
1276 #[tokio::test]
1277 async fn test_http_client_redacted_url_request_preserves_response() {
1278 let addr = start_test_server().await.unwrap();
1279 let client = HttpClient::new(HashMap::new(), vec![], vec![], None, Some(2), None).unwrap();
1280 let response = client
1281 .request_with_url_redacted(
1282 Method::GET,
1283 format!("http://{addr}/get"),
1284 None,
1285 None,
1286 None,
1287 None,
1288 None,
1289 )
1290 .await
1291 .expect("direct request with URL redaction");
1292
1293 assert_eq!(response.status.as_u16(), StatusCode::OK.as_u16());
1294 assert_eq!(response.body.as_ref(), b"hello-world!");
1295 }
1296
1297 #[tokio::test]
1298 async fn test_http_client_redacted_url_request_removes_endpoint_from_error() {
1299 const USERINFO_SECRET: &str = "transport-userinfo-secret";
1300 const PATH_SECRET: &str = "transport-path-secret";
1301 const QUERY_SECRET: &str = "transport-query-secret";
1302 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
1303 let addr = listener.local_addr().unwrap();
1304 drop(listener);
1305 let url = format!(
1306 "http://rpc-user:{USERINFO_SECRET}@{addr}/{PATH_SECRET}?api_key={QUERY_SECRET}"
1307 );
1308 let client = HttpClient::new(HashMap::new(), vec![], vec![], None, Some(1), None).unwrap();
1309
1310 let error = client
1311 .request_with_url_redacted(Method::GET, url.clone(), None, None, None, None, None)
1312 .await
1313 .expect_err("an unreachable endpoint should fail");
1314
1315 for rendered in [error.to_string(), format!("{error:?}")] {
1316 assert!(!rendered.contains(USERINFO_SECRET));
1317 assert!(!rendered.contains(PATH_SECRET));
1318 assert!(!rendered.contains(QUERY_SECRET));
1319 assert!(!rendered.contains(&url));
1320 }
1321 }
1322
1323 #[tokio::test]
1324 async fn test_http_client_redacted_url_request_removes_endpoint_from_trace_logs() {
1325 const USERINFO_SECRET: &str = "trace-userinfo-secret";
1326 const PATH_SECRET: &str = "trace-path-secret";
1327 const QUERY_SECRET: &str = "trace-query-secret";
1328 let _ = log::set_logger(&CAPTURING_TRACE_LOGGER);
1329 log::set_max_level(LevelFilter::Trace);
1330 CAPTURING_TRACE_LOGGER.clear();
1331 let addr = start_test_server().await.unwrap();
1332 let url = format!(
1333 "http://rpc-user:{USERINFO_SECRET}@{addr}/{PATH_SECRET}?api_key={QUERY_SECRET}"
1334 );
1335 let client = HttpClient::new(HashMap::new(), vec![], vec![], None, Some(2), None).unwrap();
1336
1337 let response = client
1338 .request_with_url_redacted(Method::GET, url.clone(), None, None, None, None, None)
1339 .await
1340 .expect("credentialized endpoint should return an HTTP response");
1341 let messages = CAPTURING_TRACE_LOGGER.messages();
1342
1343 assert_eq!(response.status.as_u16(), StatusCode::NOT_FOUND.as_u16());
1344 assert!(messages.iter().any(|(level, message)| {
1345 *level == Level::Trace && message.starts_with("Sending HTTP request: method=GET")
1346 }));
1347 assert!(messages.iter().any(|(level, message)| {
1348 *level == Level::Trace
1349 && message.starts_with("Received HTTP response: status=404 Not Found")
1350 }));
1351
1352 for (_, message) in messages {
1353 assert!(!message.contains(USERINFO_SECRET));
1354 assert!(!message.contains(PATH_SECRET));
1355 assert!(!message.contains(QUERY_SECRET));
1356 assert!(!message.contains(&url));
1357 }
1358 }
1359
1360 #[tokio::test]
1361 async fn test_http_client_uses_connect_and_proxy_authorization_for_https() {
1362 const USERNAME: &str = "proxytest";
1363 const PASSWORD: &str = "fixture42";
1364 let (proxy_addr, request_rx) = spawn_rejecting_connect_proxy().await;
1365 let client = HttpClient::new(
1366 HashMap::new(),
1367 vec![],
1368 vec![],
1369 None,
1370 Some(2),
1371 Some(format!("http://{USERNAME}:{PASSWORD}@{proxy_addr}")),
1372 )
1373 .unwrap();
1374 let error = client
1375 .request(
1376 Method::GET,
1377 "https://fixture.example.test/path".to_string(),
1378 None,
1379 None,
1380 None,
1381 None,
1382 None,
1383 )
1384 .await
1385 .expect_err("proxy should reject CONNECT");
1386 let request = request_rx.await.expect("captured CONNECT request");
1387 let mut lines = request.split("\r\n");
1388 let request_line = lines.next().expect("CONNECT request line");
1389 let auth_value = lines
1390 .find_map(|line| {
1391 let (name, value) = line.split_once(':')?;
1392 name.eq_ignore_ascii_case("proxy-authorization")
1393 .then_some(value.trim())
1394 })
1395 .expect("Proxy-Authorization header");
1396 let expected_auth = format!("Basic {}", BASE64.encode(format!("{USERNAME}:{PASSWORD}")));
1397
1398 assert_eq!(request_line, "CONNECT fixture.example.test:443 HTTP/1.1");
1399 assert_eq!(auth_value, expected_auth);
1400 assert!(!error.to_string().contains(PASSWORD));
1401 assert!(!error.to_string().contains(&BASE64.encode(PASSWORD)));
1402 assert!(!error.to_string().contains(&expected_auth));
1403 }
1404
1405 #[tokio::test]
1406 async fn test_http_client_unreachable_proxy_error_redacts_credentials() {
1407 const USERNAME: &str = "proxy-user";
1408 const SECRET: &str = "unreachable-proxy-secret";
1409 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
1410 let proxy_addr = listener.local_addr().unwrap();
1411 drop(listener);
1412 let client = HttpClient::new(
1413 HashMap::new(),
1414 vec![],
1415 vec![],
1416 None,
1417 Some(1),
1418 Some(format!("http://{USERNAME}:{SECRET}@{proxy_addr}")),
1419 )
1420 .unwrap();
1421 let error = client
1422 .request(
1423 Method::GET,
1424 "https://fixture.example.test/".to_string(),
1425 None,
1426 None,
1427 None,
1428 None,
1429 None,
1430 )
1431 .await
1432 .expect_err("unreachable proxy should fail");
1433
1434 assert!(!error.to_string().contains(SECRET));
1435 assert!(!error.to_string().contains(&BASE64.encode(SECRET)));
1436 assert!(
1437 !error
1438 .to_string()
1439 .contains(&BASE64.encode(format!("{USERNAME}:{SECRET}")))
1440 );
1441 }
1442
1443 #[rstest]
1444 fn test_http_client_with_valid_proxy() {
1445 let result = HttpClient::new(
1447 HashMap::new(),
1448 vec![],
1449 vec![],
1450 None,
1451 None,
1452 Some("http://proxy.example.com:8080".to_string()),
1453 );
1454
1455 assert!(result.is_ok());
1456 }
1457
1458 #[rstest]
1459 fn test_http_client_with_socks5_proxy() {
1460 let result = HttpClient::new(
1462 HashMap::new(),
1463 vec![],
1464 vec![],
1465 None,
1466 None,
1467 Some("socks5://127.0.0.1:1080".to_string()),
1468 );
1469
1470 assert!(result.is_ok());
1471 }
1472
1473 #[rstest]
1474 fn test_http_client_with_malformed_proxy() {
1475 let result = HttpClient::new(
1479 HashMap::new(),
1480 vec![],
1481 vec![],
1482 None,
1483 None,
1484 Some("://invalid".to_string()),
1485 );
1486
1487 assert!(result.is_err());
1488 assert!(matches!(result, Err(HttpClientError::InvalidProxy(_))));
1489 }
1490
1491 #[rstest]
1492 fn test_http_client_invalid_proxy_error_redacts_credentials() {
1493 const SECRET: &str = "unique-proxy-secret";
1494 let result = HttpClient::new(
1495 HashMap::new(),
1496 vec![],
1497 vec![],
1498 None,
1499 None,
1500 Some(format!("http://proxytest:{SECRET}@[::1")),
1501 );
1502 let error = result.expect_err("malformed proxy URL should fail");
1503
1504 assert_eq!(
1505 error.to_string(),
1506 "Invalid proxy URL: proxy URL is malformed"
1507 );
1508 assert!(!error.to_string().contains(SECRET));
1509 }
1510
1511 #[rstest]
1512 fn test_http_client_with_empty_proxy_string() {
1513 let result = HttpClient::new(
1515 HashMap::new(),
1516 vec![],
1517 vec![],
1518 None,
1519 None,
1520 Some(String::new()),
1521 );
1522
1523 assert!(result.is_err());
1524 assert!(matches!(result, Err(HttpClientError::InvalidProxy(_))));
1525 }
1526
1527 #[tokio::test]
1528 async fn test_http_client_get() {
1529 let addr = start_test_server().await.unwrap();
1530 let url = format!("http://{addr}/get");
1531
1532 let client = HttpClient::new(HashMap::new(), vec![], vec![], None, None, None).unwrap();
1533 let response = client.get(url, None, None, None, None).await.unwrap();
1534
1535 assert_eq!(response.status.as_u16(), StatusCode::OK.as_u16());
1536 assert_eq!(String::from_utf8_lossy(&response.body), "hello-world!");
1537 }
1538
1539 #[tokio::test]
1540 async fn test_http_client_post() {
1541 let addr = start_test_server().await.unwrap();
1542 let url = format!("http://{addr}/post");
1543
1544 let client = HttpClient::new(HashMap::new(), vec![], vec![], None, None, None).unwrap();
1545 let response = client
1546 .post(url, None, None, None, None, None)
1547 .await
1548 .unwrap();
1549
1550 assert_eq!(response.status.as_u16(), StatusCode::OK.as_u16());
1551 }
1552
1553 #[tokio::test]
1554 async fn test_http_client_patch() {
1555 let addr = start_test_server().await.unwrap();
1556 let url = format!("http://{addr}/patch");
1557
1558 let client = HttpClient::new(HashMap::new(), vec![], vec![], None, None, None).unwrap();
1559 let response = client
1560 .patch(url, None, None, None, None, None)
1561 .await
1562 .unwrap();
1563
1564 assert_eq!(response.status.as_u16(), StatusCode::OK.as_u16());
1565 }
1566
1567 #[tokio::test]
1568 async fn test_http_client_delete() {
1569 let addr = start_test_server().await.unwrap();
1570 let url = format!("http://{addr}/delete");
1571
1572 let client = HttpClient::new(HashMap::new(), vec![], vec![], None, None, None).unwrap();
1573 let response = client.delete(url, None, None, None, None).await.unwrap();
1574
1575 assert_eq!(response.status.as_u16(), StatusCode::OK.as_u16());
1576 }
1577}