Skip to main content

nautilus_network/http/
client.rs

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