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