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