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