Skip to main content

polyoxide_core/
client.rs

1use std::sync::Arc;
2use std::time::Duration;
3
4use reqwest::Method;
5use tokio::sync::{OwnedSemaphorePermit, Semaphore};
6use url::Url;
7
8use reqwest::header::RETRY_AFTER;
9
10use crate::error::ApiError;
11use crate::hooks::{
12    DefaultRetryPolicy, DynRetryPolicy, DynThrottle, NoThrottle, RequestParts, RetryPolicy,
13    Throttle,
14};
15use crate::rate_limit::{RateLimiter, RetryConfig};
16
17/// Extract the `Retry-After` header value as a string, if present and valid UTF-8.
18pub fn retry_after_header(response: &reqwest::Response) -> Option<String> {
19    response
20        .headers()
21        .get(RETRY_AFTER)?
22        .to_str()
23        .ok()
24        .map(String::from)
25}
26
27/// Default request timeout in milliseconds
28pub const DEFAULT_TIMEOUT_MS: u64 = 30_000;
29/// Default connection pool size per host
30pub const DEFAULT_POOL_SIZE: usize = 10;
31
32/// Shared HTTP client with base URL, throttle, retry policy and retry config.
33///
34/// This is the common structure used by all API clients to hold the
35/// configured reqwest client, base URL, and the hooks its send loop,
36/// [`send`](Self::send), runs on every request.
37#[derive(Clone)]
38pub struct HttpClient {
39    /// The underlying reqwest HTTP client
40    pub client: reqwest::Client,
41    /// Base URL for API requests
42    pub base_url: Url,
43    pub(crate) throttle: Arc<DynThrottle<'static>>,
44    pub(crate) policy: Arc<DynRetryPolicy<'static>>,
45    pub(crate) retry_config: RetryConfig,
46    concurrency_limiter: Option<Arc<Semaphore>>,
47}
48
49/// Written by hand: the throttle and the policy are trait objects, which
50/// print nothing useful.
51impl std::fmt::Debug for HttpClient {
52    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
53        f.debug_struct("HttpClient")
54            .field("client", &self.client)
55            .field("base_url", &self.base_url)
56            .field("retry_config", &self.retry_config)
57            .field("concurrency_limiter", &self.concurrency_limiter)
58            .finish_non_exhaustive()
59    }
60}
61
62impl HttpClient {
63    /// Clone this client, pointed at a different base URL.
64    ///
65    /// The underlying reqwest client (and so its connection pool), throttle
66    /// (and so its hold), retry policy, retry config, and concurrency limiter
67    /// are all shared with the original. Use this to reach a sibling API host
68    /// that should share the same transport configuration and request budget —
69    /// a venue whose APIs live on their own subdomains is consumed by one
70    /// client, and a 429 on one holds them all.
71    ///
72    /// Sharing the concurrency limiter is deliberate: the limit exists to keep
73    /// Cloudflare from seeing a burst from this process, and that is a
74    /// per-process concern rather than a per-host one.
75    ///
76    /// # Path prefixes are ignored
77    ///
78    /// Request paths are absolute (`/user-pnl`), and resolving an absolute
79    /// path against a base replaces the base's path entirely. So a prefix in
80    /// `base_url` is **silently dropped**, with or without a trailing slash:
81    ///
82    /// ```text
83    /// http://host          + /user-pnl -> http://host/user-pnl
84    /// http://host/proxy    + /user-pnl -> http://host/user-pnl   (prefix gone)
85    /// http://host/proxy/   + /user-pnl -> http://host/user-pnl   (prefix gone)
86    /// ```
87    ///
88    /// Point this at a scheme, host, and port — not at a sub-path. Fronting
89    /// the API with a path-prefixed reverse proxy is not supported.
90    pub fn with_base_url(&self, base_url: &str) -> Result<Self, ApiError> {
91        Ok(Self {
92            base_url: Url::parse(base_url)?,
93            ..self.clone()
94        })
95    }
96
97    /// Acquire a concurrency permit, if a limiter is configured.
98    ///
99    /// The returned permit **must** be held until the HTTP response has been
100    /// received. Dropping the permit releases the concurrency slot.
101    /// Returns `None` when no concurrency limit is set.
102    ///
103    /// [`send`](Self::send) takes one for each attempt. Transitional for any
104    /// other caller: no crate calls it since Stories 3.4 to 3.6 moved the
105    /// hand-written loops onto [`send`](Self::send), but tests observe the
106    /// permit through it. It is removed from the public API once they can
107    /// observe it another way, and `docs/s1-removals.md` names [`send`](Self::send).
108    pub async fn acquire_concurrency(&self) -> Option<OwnedSemaphorePermit> {
109        let sem = self.concurrency_limiter.as_ref()?;
110        Some(
111            sem.clone()
112                .acquire_owned()
113                .await
114                .expect("concurrency semaphore is never closed"),
115        )
116    }
117
118    /// GET a URL and return the raw response body as bytes.
119    ///
120    /// Use this for endpoints that return non-JSON payloads (e.g. `application/zip`
121    /// downloads). It runs on [`send`](Self::send), so it is throttled, gated,
122    /// retried and held as every other request is.
123    ///
124    /// A non-2xx response is [`ApiError::Response`].
125    ///
126    /// # Errors
127    ///
128    /// Returns [`ApiError`] on URL-join failure, network errors, or non-2xx
129    /// responses.
130    pub async fn get_bytes(
131        &self,
132        path: &str,
133        query: &[(String, String)],
134    ) -> Result<Vec<u8>, ApiError> {
135        let mut parts = RequestParts::new(Method::GET, path);
136        parts.query = query.to_vec();
137        let response = self.send(parts, &[], None).await?;
138
139        // Only a policy that is `Done` with a failed response gets here.
140        if !response.status().is_success() {
141            return Err(ApiError::from_response(response).await);
142        }
143
144        let bytes = response.bytes().await?;
145        Ok(bytes.to_vec())
146    }
147}
148
149/// Builder for configuring HTTP clients.
150///
151/// Provides a consistent way to configure HTTP clients across all API crates
152/// with sensible defaults.
153///
154/// # Example
155///
156/// ```
157/// use polyoxide_core::HttpClientBuilder;
158///
159/// let client = HttpClientBuilder::new("https://api.example.com")
160///     .timeout_ms(60_000)
161///     .pool_size(20)
162///     .build()
163///     .unwrap();
164/// ```
165pub struct HttpClientBuilder {
166    base_url: String,
167    timeout_ms: u64,
168    pool_size: usize,
169    throttle: Option<Arc<DynThrottle<'static>>>,
170    policy: Option<Arc<DynRetryPolicy<'static>>>,
171    retry_config: RetryConfig,
172    max_concurrent: Option<usize>,
173    gzip: Option<bool>,
174}
175
176impl HttpClientBuilder {
177    /// Create a new HTTP client builder with the given base URL.
178    pub fn new(base_url: impl Into<String>) -> Self {
179        Self {
180            base_url: base_url.into(),
181            timeout_ms: DEFAULT_TIMEOUT_MS,
182            pool_size: DEFAULT_POOL_SIZE,
183            throttle: None,
184            policy: None,
185            retry_config: RetryConfig::default(),
186            max_concurrent: None,
187            gzip: None,
188        }
189    }
190
191    /// Set request timeout in milliseconds.
192    ///
193    /// Default: 30,000ms (30 seconds)
194    pub fn timeout_ms(mut self, timeout: u64) -> Self {
195        self.timeout_ms = timeout;
196        self
197    }
198
199    /// Set connection pool size per host.
200    ///
201    /// Default: 10 connections
202    pub fn pool_size(mut self, size: usize) -> Self {
203        self.pool_size = size;
204        self
205    }
206
207    /// Set a rate limiter for this client: [`with_throttle`](Self::with_throttle)
208    /// with a [`RateLimiter`].
209    pub fn with_rate_limiter(self, limiter: RateLimiter) -> Self {
210        self.with_throttle(limiter)
211    }
212
213    /// Set the throttle every request on this client goes through, shared
214    /// with every [`with_base_url`](HttpClient::with_base_url) sibling.
215    ///
216    /// Default: [`NoThrottle`], which charges and holds
217    /// nothing.
218    pub fn with_throttle(mut self, throttle: impl Throttle + 'static) -> Self {
219        self.throttle = Some(DynThrottle::new_arc(throttle));
220        self
221    }
222
223    /// Set the policy that decides what follows each response.
224    ///
225    /// Default: [`DefaultRetryPolicy`], which
226    /// retries a 429 and nothing else.
227    pub fn with_retry_policy(mut self, policy: impl RetryPolicy + 'static) -> Self {
228        self.policy = Some(DynRetryPolicy::new_arc(policy));
229        self
230    }
231
232    /// Set the retry schedule: how many retries, and the backoff between them.
233    pub fn with_retry_config(mut self, config: RetryConfig) -> Self {
234        self.retry_config = config;
235        self
236    }
237
238    /// Set the maximum number of concurrent in-flight HTTP requests.
239    ///
240    /// Prevents Cloudflare 1015 rate-limit errors caused by request bursts
241    /// when many callers share the same client concurrently.
242    pub fn with_max_concurrent(mut self, max: usize) -> Self {
243        self.max_concurrent = Some(max);
244        self
245    }
246
247    /// Ask for gzip-compressed responses and decode them, or pin that off.
248    ///
249    /// Unset by default, which leaves reqwest's own default. This crate enables
250    /// reqwest's `gzip` feature, so an unset client asks for gzip and decodes
251    /// the body transparently, as every client did for a consumer whose
252    /// workspace enabled the feature before this setting existed. 0.37.0 set
253    /// it off unless asked, which took compression away from those consumers.
254    pub fn gzip(mut self, enabled: bool) -> Self {
255        self.gzip = Some(enabled);
256        self
257    }
258
259    /// Build the HTTP client.
260    pub fn build(self) -> Result<HttpClient, ApiError> {
261        let mut builder = reqwest::Client::builder();
262        if let Some(enabled) = self.gzip {
263            builder = builder.gzip(enabled);
264        }
265        let client = builder
266            .timeout(Duration::from_millis(self.timeout_ms))
267            .connect_timeout(Duration::from_secs(10))
268            .redirect(reqwest::redirect::Policy::none())
269            .pool_max_idle_per_host(self.pool_size)
270            .build()?;
271
272        let base_url = Url::parse(&self.base_url)?;
273
274        Ok(HttpClient {
275            client,
276            base_url,
277            throttle: self
278                .throttle
279                .unwrap_or_else(|| DynThrottle::new_arc(NoThrottle)),
280            policy: self
281                .policy
282                .unwrap_or_else(|| DynRetryPolicy::new_arc(DefaultRetryPolicy)),
283            retry_config: self.retry_config,
284            concurrency_limiter: self.max_concurrent.map(|n| Arc::new(Semaphore::new(n))),
285        })
286    }
287}
288
289impl Default for HttpClientBuilder {
290    fn default() -> Self {
291        Self::new(String::new())
292    }
293}
294
295#[cfg(test)]
296mod tests {
297    use super::*;
298    use reqwest::StatusCode;
299
300    #[test]
301    fn test_should_retry_uses_retry_after_header() {
302        let config = RetryConfig::default();
303        let d = config.retry_delay(0, Some("2"));
304        assert_eq!(d, Duration::from_millis(2000));
305    }
306
307    #[test]
308    fn test_should_retry_retry_after_fractional_seconds() {
309        let config = RetryConfig::default();
310        // 1.5s, not the 0.5s this once used: a server-supplied delay is only
311        // honoured when it exceeds the client's own backoff, and attempt 0's
312        // jitter range is [375, 625]ms — straddling it made the assertion
313        // depend on the roll. The point here is that fractions parse.
314        let d = config.retry_delay(0, Some("1.5"));
315        assert_eq!(d, Duration::from_millis(1500));
316    }
317
318    #[test]
319    fn retry_after_below_our_own_backoff_does_not_shorten_the_wait() {
320        let config = RetryConfig::default();
321        // Cloudflare answers a tripped rate limit with 429 + `error code: 1015`
322        // and a Retry-After that floors to zero. Taking it verbatim collapsed
323        // the sleep to nothing: the observed failure was three "retry after 0ms"
324        // attempts inside 65ms, which deepens a 1015 ban rather than waiting it
325        // out. A server asking us to wait *longer* is honoured; one asking us to
326        // wait less than our own policy is not.
327        for header in ["0", "0.0", "-1", "-30", "0.0001"] {
328            let d = config.retry_delay(0, Some(header));
329            assert!(
330                d >= Duration::from_millis(375),
331                "Retry-After: {header:?} produced a {d:?} sleep; the floor is the \
332                 client's own attempt-0 backoff, >=375ms after jitter"
333            );
334        }
335    }
336
337    #[test]
338    fn retry_after_zero_still_backs_off_exponentially_across_attempts() {
339        let config = RetryConfig::default();
340        // Flooring at a flat minimum would still let a 1015 ban be hammered at a
341        // fixed cadence. The floor has to be the *attempt's* backoff, so a
342        // degenerate header still yields 500ms, 1s, 2s.
343        for (attempt, min_ms) in [(0u32, 375u64), (1, 750), (2, 1_500)] {
344            let d = config.retry_delay(attempt, Some("0"));
345            assert!(
346                d >= Duration::from_millis(min_ms),
347                "attempt {attempt} with Retry-After: 0 slept {d:?}, expected >={min_ms}ms"
348            );
349        }
350    }
351
352    #[test]
353    fn test_should_retry_retry_after_clamped_to_max_backoff() {
354        let config = RetryConfig::default();
355        // Default max_backoff_ms = 10_000; header says 60s
356        let d = config.retry_delay(0, Some("60"));
357        assert_eq!(d, Duration::from_millis(10_000));
358    }
359
360    #[test]
361    fn test_should_retry_retry_after_invalid_falls_back() {
362        let config = RetryConfig::default();
363        // Non-numeric Retry-After (HTTP-date format) falls back to computed backoff
364        let d = config.retry_delay(0, Some("Wed, 21 Oct 2025 07:28:00 GMT"));
365        // Should be in the jitter range for attempt 0: [375, 625]ms
366        let ms = d.as_millis() as u64;
367        assert!(
368            (375..=625).contains(&ms),
369            "expected fallback backoff in [375, 625], got {ms}"
370        );
371    }
372
373    // ── Builder wiring ───────────────────────────────────────────
374
375    /// Notes when it signs: just after the throttle let the attempt go.
376    #[derive(Default)]
377    struct SignedAt(std::sync::Mutex<Option<std::time::Instant>>);
378
379    impl crate::hooks::Authenticator for SignedAt {
380        async fn sign(&self, _parts: &mut RequestParts, _attempt: u32) -> Result<(), ApiError> {
381            *self.0.lock().unwrap() = Some(std::time::Instant::now());
382            Ok(())
383        }
384    }
385
386    /// How long `client` takes to let a `POST /order` through to its signing,
387    /// against a mock server.
388    async fn time_to_sign(builder: HttpClientBuilder, server: &mockito::ServerGuard) -> Duration {
389        let client = builder.build().unwrap();
390        let client = client.with_base_url(&server.url()).unwrap();
391        let signed = SignedAt::default();
392        let start = std::time::Instant::now();
393        client
394            .send(
395                RequestParts::new(Method::POST, "/order"),
396                &[],
397                Some(crate::hooks::DynAuthenticator::from_ref(&signed)),
398            )
399            .await
400            .unwrap();
401        let at = signed.0.lock().unwrap().expect("the request was signed");
402        at - start
403    }
404
405    #[tokio::test]
406    async fn test_builder_without_rate_limiter() {
407        let mut server = mockito::Server::new_async().await;
408        let _order = server.mock("POST", "/order").create_async().await;
409        let builder = HttpClientBuilder::new("https://example.com");
410        let elapsed = time_to_sign(builder, &server).await;
411        assert!(elapsed < Duration::from_millis(10));
412    }
413
414    // ── Concurrency limiter ─────────────────────────────────────
415
416    #[tokio::test]
417    async fn test_acquire_concurrency_none_when_not_configured() {
418        let client = HttpClientBuilder::new("https://example.com")
419            .build()
420            .unwrap();
421        assert!(client.acquire_concurrency().await.is_none());
422    }
423
424    #[tokio::test]
425    async fn test_acquire_concurrency_returns_permit() {
426        let client = HttpClientBuilder::new("https://example.com")
427            .with_max_concurrent(2)
428            .build()
429            .unwrap();
430        let permit = client.acquire_concurrency().await;
431        assert!(permit.is_some());
432    }
433
434    #[tokio::test]
435    async fn test_concurrency_shared_across_clones() {
436        let client = HttpClientBuilder::new("https://example.com")
437            .with_max_concurrent(1)
438            .build()
439            .unwrap();
440        let clone = client.clone();
441
442        // Hold the only permit from the original
443        let _permit = client.acquire_concurrency().await.unwrap();
444
445        // Clone should block because concurrency=1 and permit is held
446        let result =
447            tokio::time::timeout(Duration::from_millis(50), clone.acquire_concurrency()).await;
448        assert!(result.is_err(), "clone should block when permit is held");
449    }
450
451    #[tokio::test]
452    async fn test_concurrency_limits_parallel_tasks() {
453        let client = HttpClientBuilder::new("https://example.com")
454            .with_max_concurrent(2)
455            .build()
456            .unwrap();
457
458        let start = std::time::Instant::now();
459        let mut handles = Vec::new();
460        for _ in 0..4 {
461            let c = client.clone();
462            handles.push(tokio::spawn(async move {
463                let _permit = c.acquire_concurrency().await;
464                tokio::time::sleep(Duration::from_millis(50)).await;
465            }));
466        }
467        for h in handles {
468            h.await.unwrap();
469        }
470        // 4 tasks, concurrency 2, 50ms each => ~100ms minimum
471        assert!(
472            start.elapsed() >= Duration::from_millis(90),
473            "expected ~100ms, got {:?}",
474            start.elapsed()
475        );
476    }
477
478    #[tokio::test]
479    async fn test_builder_with_max_concurrent() {
480        let client = HttpClientBuilder::new("https://example.com")
481            .with_max_concurrent(5)
482            .build()
483            .unwrap();
484        // Should be able to acquire 5 permits
485        let mut permits = Vec::new();
486        for _ in 0..5 {
487            permits.push(client.acquire_concurrency().await);
488        }
489        assert!(permits.iter().all(|p| p.is_some()));
490
491        // 6th should block
492        let result =
493            tokio::time::timeout(Duration::from_millis(50), client.acquire_concurrency()).await;
494        assert!(result.is_err());
495    }
496
497    // ── get_bytes() ──────────────────────────────────────────────
498
499    #[tokio::test]
500    async fn test_get_bytes_returns_body_verbatim() {
501        let mut server = mockito::Server::new_async().await;
502        // Intentionally non-UTF-8 bytes to prove we're not assuming text.
503        let body: Vec<u8> = vec![0x50, 0x4B, 0x03, 0x04, 0x00, 0xFF, 0xFE, 0x42];
504        let mock = server
505            .mock("GET", "/v1/accounting/snapshot")
506            .match_query(mockito::Matcher::UrlEncoded("user".into(), "0xabc".into()))
507            .with_status(200)
508            .with_header("content-type", "application/zip")
509            .with_body(body.clone())
510            .create_async()
511            .await;
512
513        let client = HttpClientBuilder::new(server.url()).build().unwrap();
514        let out = client
515            .get_bytes(
516                "/v1/accounting/snapshot",
517                &[("user".to_string(), "0xabc".to_string())],
518            )
519            .await
520            .unwrap();
521        assert_eq!(out, body);
522        mock.assert_async().await;
523    }
524
525    #[tokio::test]
526    async fn test_get_bytes_maps_non_2xx_to_api_error() {
527        let mut server = mockito::Server::new_async().await;
528        let mock = server
529            .mock("GET", "/does-not-exist")
530            .with_status(404)
531            .with_header("content-type", "application/json")
532            .with_body(r#"{"error": "not found"}"#)
533            .create_async()
534            .await;
535
536        let client = HttpClientBuilder::new(server.url()).build().unwrap();
537        let err = client.get_bytes("/does-not-exist", &[]).await.unwrap_err();
538        match err {
539            ApiError::Response(response) => {
540                assert_eq!(response.status, StatusCode::NOT_FOUND);
541                assert_eq!(response.message, "not found");
542            }
543            other => panic!("expected ApiError::Response, got {other:?}"),
544        }
545        mock.assert_async().await;
546    }
547
548    #[tokio::test]
549    async fn test_get_bytes_no_query_params() {
550        let mut server = mockito::Server::new_async().await;
551        let mock = server
552            .mock("GET", "/raw")
553            .with_status(200)
554            .with_body(&b"hello"[..])
555            .create_async()
556            .await;
557
558        let client = HttpClientBuilder::new(server.url()).build().unwrap();
559        let out = client.get_bytes("/raw", &[]).await.unwrap();
560        assert_eq!(out, b"hello");
561        mock.assert_async().await;
562    }
563
564    // ── gzip ─────────────────────────────────────────────────────
565
566    #[tokio::test]
567    async fn gzip_follows_reqwest_unless_set() {
568        // Unset, the client leaves reqwest's default, which asks for gzip with
569        // the feature on. 0.37.0 forced it off, so a consumer whose workspace
570        // had the feature on lost compression on every polyoxide client.
571        let mut server = mockito::Server::new_async().await;
572        let mock = server
573            .mock("GET", "/plain")
574            .match_header("accept-encoding", mockito::Matcher::Regex("gzip".into()))
575            .with_body("ok")
576            .create_async()
577            .await;
578
579        let client = HttpClientBuilder::new(server.url()).build().unwrap();
580        assert_eq!(client.get_bytes("/plain", &[]).await.unwrap(), b"ok");
581        mock.assert_async().await;
582    }
583
584    #[tokio::test]
585    async fn gzip_false_asks_for_no_compression() {
586        let mut server = mockito::Server::new_async().await;
587        let mock = server
588            .mock("GET", "/plain")
589            .match_header("accept-encoding", mockito::Matcher::Missing)
590            .with_body("ok")
591            .create_async()
592            .await;
593
594        let client = HttpClientBuilder::new(server.url())
595            .gzip(false)
596            .build()
597            .unwrap();
598        assert_eq!(client.get_bytes("/plain", &[]).await.unwrap(), b"ok");
599        mock.assert_async().await;
600    }
601
602    #[tokio::test]
603    async fn gzip_asks_for_and_decodes_a_compressed_body() {
604        // `{"serverTime":1}` compressed by python3's `gzip.compress(.., mtime=0)`.
605        const GZIPPED: &[u8] = &[
606            0x1f, 0x8b, 0x08, 0x00, 0x00, 0x00, 0x00, 0x00, 0x02, 0xff, 0xab, 0x56, 0x2a, 0x4e,
607            0x2d, 0x2a, 0x4b, 0x2d, 0x0a, 0xc9, 0xcc, 0x4d, 0x55, 0xb2, 0x32, 0xac, 0x05, 0x00,
608            0xe2, 0x1d, 0x3e, 0x1a, 0x10, 0x00, 0x00, 0x00,
609        ];
610        let mut server = mockito::Server::new_async().await;
611        let mock = server
612            .mock("GET", "/time")
613            .match_header("accept-encoding", mockito::Matcher::Regex("gzip".into()))
614            .with_header("content-encoding", "gzip")
615            .with_body(GZIPPED)
616            .create_async()
617            .await;
618
619        let client = HttpClientBuilder::new(server.url())
620            .gzip(true)
621            .build()
622            .unwrap();
623        let body = client.get_bytes("/time", &[]).await.unwrap();
624        assert_eq!(body, br#"{"serverTime":1}"#);
625        mock.assert_async().await;
626    }
627
628    #[test]
629    fn with_base_url_retargets_and_shares_transport() {
630        let client = HttpClientBuilder::new("https://api.example.com")
631            .with_max_concurrent(4)
632            .build()
633            .unwrap();
634        let sibling = client.with_base_url("https://sibling.example.com").unwrap();
635
636        assert_eq!(sibling.base_url.host_str(), Some("sibling.example.com"));
637        // The original is untouched — this returns a clone, not a mutation.
638        assert_eq!(client.base_url.host_str(), Some("api.example.com"));
639        // Both must draw on the same concurrency budget, since the limit exists
640        // to stop this process bursting rather than to pace any one host.
641        assert!(sibling.concurrency_limiter.is_some());
642    }
643
644    #[test]
645    fn with_base_url_rejects_a_malformed_url() {
646        let client = HttpClientBuilder::new("https://api.example.com")
647            .build()
648            .unwrap();
649        assert!(client.with_base_url("not-a-url").is_err());
650    }
651
652    #[test]
653    fn base_url_path_prefixes_are_dropped() {
654        // Documented footgun, pinned so it cannot change silently: request
655        // paths are absolute, so they replace the base path entirely. If this
656        // test ever fails, the doc comment on with_base_url needs updating too.
657        let client = HttpClientBuilder::new("http://localhost:8080/proxy")
658            .build()
659            .unwrap();
660        assert_eq!(
661            client.base_url.join("/user-pnl").unwrap().as_str(),
662            "http://localhost:8080/user-pnl",
663            "a path prefix in the base URL is not preserved"
664        );
665
666        let with_slash = client
667            .with_base_url("http://localhost:8080/proxy/")
668            .unwrap();
669        assert_eq!(
670            with_slash.base_url.join("/user-pnl").unwrap().as_str(),
671            "http://localhost:8080/user-pnl",
672            "a trailing slash does not preserve the prefix either"
673        );
674    }
675}