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    /// several Polymarket APIs live on their own subdomains but are consumed by
70    /// one 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    // ── Polymarket's retry decision and the retry delay ──────────
301    //
302    // These tests drove `HttpClient::should_retry` until the hand-written
303    // loops that called it moved onto the send loop. They keep their names
304    // and their answers, read from the policy the loop asks and from
305    // `RetryConfig::retry_delay`, the loop's own floor.
306
307    /// Whether Polymarket's policy retries `status` on `attempt` under
308    /// `config`. `decide` itself fails a request with no retry left, so this
309    /// is the send loop's answer.
310    fn retries(config: &RetryConfig, status: StatusCode, attempt: u32) -> bool {
311        use crate::hooks::{Outcome, ResponseMeta};
312
313        let headers = reqwest::header::HeaderMap::new();
314        let response = ResponseMeta {
315            status,
316            headers: &headers,
317        };
318        matches!(
319            crate::polymarket::PolymarketRetryPolicy
320                .decide(&response, &config.attempt_info(attempt), config)
321                .outcome,
322            Outcome::Retry(_)
323        )
324    }
325
326    #[test]
327    fn test_should_retry_429_under_max() {
328        let config = RetryConfig::default();
329        // Default max_retries=3, so attempts 0 and 2 should retry
330        assert!(retries(&config, StatusCode::TOO_MANY_REQUESTS, 0));
331        assert!(retries(&config, StatusCode::TOO_MANY_REQUESTS, 2));
332    }
333
334    #[test]
335    fn test_should_retry_429_at_max() {
336        let config = RetryConfig::default();
337        // attempt == max_retries → no retry
338        assert!(!retries(&config, StatusCode::TOO_MANY_REQUESTS, 3));
339    }
340
341    #[test]
342    fn test_should_retry_425_under_max() {
343        let config = RetryConfig::default();
344        // 425 Too Early — Polymarket's matching engine restarting.
345        assert!(retries(&config, StatusCode::TOO_EARLY, 0));
346        assert!(retries(&config, StatusCode::TOO_EARLY, 2));
347    }
348
349    #[test]
350    fn test_should_retry_425_at_max() {
351        let config = RetryConfig::default();
352        assert!(!retries(&config, StatusCode::TOO_EARLY, 3));
353    }
354
355    #[test]
356    fn test_should_retry_ignores_other_statuses() {
357        let config = RetryConfig::default();
358        for status in [
359            StatusCode::OK,
360            StatusCode::INTERNAL_SERVER_ERROR,
361            StatusCode::BAD_GATEWAY,
362            StatusCode::BAD_REQUEST,
363            StatusCode::FORBIDDEN,
364            StatusCode::NOT_FOUND,
365            // 503 is deliberately excluded even though post-only mode sends it
366            // with a Retry-After: the documented wait is ~79s, far too long to
367            // block inside a request, and it rejects orders wholesale.
368            StatusCode::SERVICE_UNAVAILABLE,
369        ] {
370            assert!(!retries(&config, status, 0), "{status} was retried");
371        }
372    }
373
374    #[test]
375    fn test_should_retry_5xx_not_retried_despite_being_is_retriable() {
376        // The class describes the error; this loop resends writes, so it stays narrower.
377        let config = RetryConfig::default();
378        assert!(!retries(&config, StatusCode::INTERNAL_SERVER_ERROR, 0));
379        let err = ApiError::from(crate::error::ErrorResponse::new(
380            StatusCode::INTERNAL_SERVER_ERROR,
381            Default::default(),
382            "",
383        ));
384        assert!(polyoxide_venue::Classify::is_retriable(&err));
385    }
386
387    #[test]
388    fn test_should_retry_custom_config() {
389        let config = RetryConfig {
390            max_retries: 1,
391            ..RetryConfig::default()
392        };
393        assert!(retries(&config, StatusCode::TOO_MANY_REQUESTS, 0));
394        assert!(!retries(&config, StatusCode::TOO_MANY_REQUESTS, 1));
395    }
396
397    #[test]
398    fn test_should_retry_uses_retry_after_header() {
399        let config = RetryConfig::default();
400        let d = config.retry_delay(0, Some("2"));
401        assert_eq!(d, Duration::from_millis(2000));
402    }
403
404    #[test]
405    fn test_should_retry_retry_after_fractional_seconds() {
406        let config = RetryConfig::default();
407        // 1.5s, not the 0.5s this once used: a server-supplied delay is only
408        // honoured when it exceeds the client's own backoff, and attempt 0's
409        // jitter range is [375, 625]ms — straddling it made the assertion
410        // depend on the roll. The point here is that fractions parse.
411        let d = config.retry_delay(0, Some("1.5"));
412        assert_eq!(d, Duration::from_millis(1500));
413    }
414
415    #[test]
416    fn retry_after_below_our_own_backoff_does_not_shorten_the_wait() {
417        let config = RetryConfig::default();
418        // Cloudflare answers a tripped rate limit with 429 + `error code: 1015`
419        // and a Retry-After that floors to zero. Taking it verbatim collapsed
420        // the sleep to nothing: the observed failure was three "retry after 0ms"
421        // attempts inside 65ms, which deepens a 1015 ban rather than waiting it
422        // out. A server asking us to wait *longer* is honoured; one asking us to
423        // wait less than our own policy is not.
424        for header in ["0", "0.0", "-1", "-30", "0.0001"] {
425            let d = config.retry_delay(0, Some(header));
426            assert!(
427                d >= Duration::from_millis(375),
428                "Retry-After: {header:?} produced a {d:?} sleep; the floor is the \
429                 client's own attempt-0 backoff, >=375ms after jitter"
430            );
431        }
432    }
433
434    #[test]
435    fn retry_after_zero_still_backs_off_exponentially_across_attempts() {
436        let config = RetryConfig::default();
437        // Flooring at a flat minimum would still let a 1015 ban be hammered at a
438        // fixed cadence. The floor has to be the *attempt's* backoff, so a
439        // degenerate header still yields 500ms, 1s, 2s.
440        for (attempt, min_ms) in [(0u32, 375u64), (1, 750), (2, 1_500)] {
441            let d = config.retry_delay(attempt, Some("0"));
442            assert!(
443                d >= Duration::from_millis(min_ms),
444                "attempt {attempt} with Retry-After: 0 slept {d:?}, expected >={min_ms}ms"
445            );
446        }
447    }
448
449    #[test]
450    fn test_should_retry_retry_after_clamped_to_max_backoff() {
451        let config = RetryConfig::default();
452        // Default max_backoff_ms = 10_000; header says 60s
453        let d = config.retry_delay(0, Some("60"));
454        assert_eq!(d, Duration::from_millis(10_000));
455    }
456
457    #[test]
458    fn test_should_retry_retry_after_invalid_falls_back() {
459        let config = RetryConfig::default();
460        // Non-numeric Retry-After (HTTP-date format) falls back to computed backoff
461        let d = config.retry_delay(0, Some("Wed, 21 Oct 2025 07:28:00 GMT"));
462        // Should be in the jitter range for attempt 0: [375, 625]ms
463        let ms = d.as_millis() as u64;
464        assert!(
465            (375..=625).contains(&ms),
466            "expected fallback backoff in [375, 625], got {ms}"
467        );
468    }
469
470    // ── Builder wiring ───────────────────────────────────────────
471
472    /// Notes when it signs: just after the throttle let the attempt go.
473    #[derive(Default)]
474    struct SignedAt(std::sync::Mutex<Option<std::time::Instant>>);
475
476    impl crate::hooks::Authenticator for SignedAt {
477        async fn sign(&self, _parts: &mut RequestParts, _attempt: u32) -> Result<(), ApiError> {
478            *self.0.lock().unwrap() = Some(std::time::Instant::now());
479            Ok(())
480        }
481    }
482
483    /// How long `client` takes to let a `POST /order` through to its signing,
484    /// against a mock server.
485    async fn time_to_sign(builder: HttpClientBuilder, server: &mockito::ServerGuard) -> Duration {
486        let client = builder.build().unwrap();
487        let client = client.with_base_url(&server.url()).unwrap();
488        let signed = SignedAt::default();
489        let start = std::time::Instant::now();
490        client
491            .send(
492                RequestParts::new(Method::POST, "/order"),
493                &[],
494                Some(crate::hooks::DynAuthenticator::from_ref(&signed)),
495            )
496            .await
497            .unwrap();
498        let at = signed.0.lock().unwrap().expect("the request was signed");
499        at - start
500    }
501
502    #[tokio::test]
503    async fn test_builder_with_rate_limiter() {
504        let mut server = mockito::Server::new_async().await;
505        let _order = server.mock("POST", "/order").create_async().await;
506        let builder = HttpClientBuilder::new("https://example.com")
507            .with_rate_limiter(crate::polymarket::clob_limits());
508        let elapsed = time_to_sign(builder, &server).await;
509        assert!(elapsed < Duration::from_millis(50));
510    }
511
512    #[tokio::test]
513    async fn test_builder_without_rate_limiter() {
514        let mut server = mockito::Server::new_async().await;
515        let _order = server.mock("POST", "/order").create_async().await;
516        let builder = HttpClientBuilder::new("https://example.com");
517        let elapsed = time_to_sign(builder, &server).await;
518        assert!(elapsed < Duration::from_millis(10));
519    }
520
521    // ── Concurrency limiter ─────────────────────────────────────
522
523    #[tokio::test]
524    async fn test_acquire_concurrency_none_when_not_configured() {
525        let client = HttpClientBuilder::new("https://example.com")
526            .build()
527            .unwrap();
528        assert!(client.acquire_concurrency().await.is_none());
529    }
530
531    #[tokio::test]
532    async fn test_acquire_concurrency_returns_permit() {
533        let client = HttpClientBuilder::new("https://example.com")
534            .with_max_concurrent(2)
535            .build()
536            .unwrap();
537        let permit = client.acquire_concurrency().await;
538        assert!(permit.is_some());
539    }
540
541    #[tokio::test]
542    async fn test_concurrency_shared_across_clones() {
543        let client = HttpClientBuilder::new("https://example.com")
544            .with_max_concurrent(1)
545            .build()
546            .unwrap();
547        let clone = client.clone();
548
549        // Hold the only permit from the original
550        let _permit = client.acquire_concurrency().await.unwrap();
551
552        // Clone should block because concurrency=1 and permit is held
553        let result =
554            tokio::time::timeout(Duration::from_millis(50), clone.acquire_concurrency()).await;
555        assert!(result.is_err(), "clone should block when permit is held");
556    }
557
558    #[tokio::test]
559    async fn test_concurrency_limits_parallel_tasks() {
560        let client = HttpClientBuilder::new("https://example.com")
561            .with_max_concurrent(2)
562            .build()
563            .unwrap();
564
565        let start = std::time::Instant::now();
566        let mut handles = Vec::new();
567        for _ in 0..4 {
568            let c = client.clone();
569            handles.push(tokio::spawn(async move {
570                let _permit = c.acquire_concurrency().await;
571                tokio::time::sleep(Duration::from_millis(50)).await;
572            }));
573        }
574        for h in handles {
575            h.await.unwrap();
576        }
577        // 4 tasks, concurrency 2, 50ms each => ~100ms minimum
578        assert!(
579            start.elapsed() >= Duration::from_millis(90),
580            "expected ~100ms, got {:?}",
581            start.elapsed()
582        );
583    }
584
585    #[tokio::test]
586    async fn test_builder_with_max_concurrent() {
587        let client = HttpClientBuilder::new("https://example.com")
588            .with_max_concurrent(5)
589            .build()
590            .unwrap();
591        // Should be able to acquire 5 permits
592        let mut permits = Vec::new();
593        for _ in 0..5 {
594            permits.push(client.acquire_concurrency().await);
595        }
596        assert!(permits.iter().all(|p| p.is_some()));
597
598        // 6th should block
599        let result =
600            tokio::time::timeout(Duration::from_millis(50), client.acquire_concurrency()).await;
601        assert!(result.is_err());
602    }
603
604    // ── get_bytes() ──────────────────────────────────────────────
605
606    #[tokio::test]
607    async fn test_get_bytes_returns_body_verbatim() {
608        let mut server = mockito::Server::new_async().await;
609        // Intentionally non-UTF-8 bytes to prove we're not assuming text.
610        let body: Vec<u8> = vec![0x50, 0x4B, 0x03, 0x04, 0x00, 0xFF, 0xFE, 0x42];
611        let mock = server
612            .mock("GET", "/v1/accounting/snapshot")
613            .match_query(mockito::Matcher::UrlEncoded("user".into(), "0xabc".into()))
614            .with_status(200)
615            .with_header("content-type", "application/zip")
616            .with_body(body.clone())
617            .create_async()
618            .await;
619
620        let client = HttpClientBuilder::new(server.url()).build().unwrap();
621        let out = client
622            .get_bytes(
623                "/v1/accounting/snapshot",
624                &[("user".to_string(), "0xabc".to_string())],
625            )
626            .await
627            .unwrap();
628        assert_eq!(out, body);
629        mock.assert_async().await;
630    }
631
632    #[tokio::test]
633    async fn test_get_bytes_maps_non_2xx_to_api_error() {
634        let mut server = mockito::Server::new_async().await;
635        let mock = server
636            .mock("GET", "/does-not-exist")
637            .with_status(404)
638            .with_header("content-type", "application/json")
639            .with_body(r#"{"error": "not found"}"#)
640            .create_async()
641            .await;
642
643        let client = HttpClientBuilder::new(server.url()).build().unwrap();
644        let err = client.get_bytes("/does-not-exist", &[]).await.unwrap_err();
645        match err {
646            ApiError::Response(response) => {
647                assert_eq!(response.status, StatusCode::NOT_FOUND);
648                assert_eq!(response.message, "not found");
649            }
650            other => panic!("expected ApiError::Response, got {other:?}"),
651        }
652        mock.assert_async().await;
653    }
654
655    #[tokio::test]
656    async fn test_get_bytes_no_query_params() {
657        let mut server = mockito::Server::new_async().await;
658        let mock = server
659            .mock("GET", "/raw")
660            .with_status(200)
661            .with_body(&b"hello"[..])
662            .create_async()
663            .await;
664
665        let client = HttpClientBuilder::new(server.url()).build().unwrap();
666        let out = client.get_bytes("/raw", &[]).await.unwrap();
667        assert_eq!(out, b"hello");
668        mock.assert_async().await;
669    }
670
671    // ── gzip ─────────────────────────────────────────────────────
672
673    #[tokio::test]
674    async fn gzip_follows_reqwest_unless_set() {
675        // Unset, the client leaves reqwest's default, which asks for gzip with
676        // the feature on. 0.37.0 forced it off, so a consumer whose workspace
677        // had the feature on lost compression on every polyoxide client.
678        let mut server = mockito::Server::new_async().await;
679        let mock = server
680            .mock("GET", "/plain")
681            .match_header("accept-encoding", mockito::Matcher::Regex("gzip".into()))
682            .with_body("ok")
683            .create_async()
684            .await;
685
686        let client = HttpClientBuilder::new(server.url()).build().unwrap();
687        assert_eq!(client.get_bytes("/plain", &[]).await.unwrap(), b"ok");
688        mock.assert_async().await;
689    }
690
691    #[tokio::test]
692    async fn gzip_false_asks_for_no_compression() {
693        let mut server = mockito::Server::new_async().await;
694        let mock = server
695            .mock("GET", "/plain")
696            .match_header("accept-encoding", mockito::Matcher::Missing)
697            .with_body("ok")
698            .create_async()
699            .await;
700
701        let client = HttpClientBuilder::new(server.url())
702            .gzip(false)
703            .build()
704            .unwrap();
705        assert_eq!(client.get_bytes("/plain", &[]).await.unwrap(), b"ok");
706        mock.assert_async().await;
707    }
708
709    #[tokio::test]
710    async fn gzip_asks_for_and_decodes_a_compressed_body() {
711        // `{"serverTime":1}` compressed by python3's `gzip.compress(.., mtime=0)`.
712        const GZIPPED: &[u8] = &[
713            0x1f, 0x8b, 0x08, 0x00, 0x00, 0x00, 0x00, 0x00, 0x02, 0xff, 0xab, 0x56, 0x2a, 0x4e,
714            0x2d, 0x2a, 0x4b, 0x2d, 0x0a, 0xc9, 0xcc, 0x4d, 0x55, 0xb2, 0x32, 0xac, 0x05, 0x00,
715            0xe2, 0x1d, 0x3e, 0x1a, 0x10, 0x00, 0x00, 0x00,
716        ];
717        let mut server = mockito::Server::new_async().await;
718        let mock = server
719            .mock("GET", "/time")
720            .match_header("accept-encoding", mockito::Matcher::Regex("gzip".into()))
721            .with_header("content-encoding", "gzip")
722            .with_body(GZIPPED)
723            .create_async()
724            .await;
725
726        let client = HttpClientBuilder::new(server.url())
727            .gzip(true)
728            .build()
729            .unwrap();
730        let body = client.get_bytes("/time", &[]).await.unwrap();
731        assert_eq!(body, br#"{"serverTime":1}"#);
732        mock.assert_async().await;
733    }
734
735    #[test]
736    fn with_base_url_retargets_and_shares_transport() {
737        let client = HttpClientBuilder::new("https://data-api.polymarket.com")
738            .with_max_concurrent(4)
739            .build()
740            .unwrap();
741        let sibling = client
742            .with_base_url("https://user-pnl-api.polymarket.com")
743            .unwrap();
744
745        assert_eq!(
746            sibling.base_url.host_str(),
747            Some("user-pnl-api.polymarket.com")
748        );
749        // The original is untouched — this returns a clone, not a mutation.
750        assert_eq!(client.base_url.host_str(), Some("data-api.polymarket.com"));
751        // Both must draw on the same concurrency budget, since the limit exists
752        // to stop this process bursting rather than to pace any one host.
753        assert!(sibling.concurrency_limiter.is_some());
754    }
755
756    #[test]
757    fn with_base_url_rejects_a_malformed_url() {
758        let client = HttpClientBuilder::new("https://data-api.polymarket.com")
759            .build()
760            .unwrap();
761        assert!(client.with_base_url("not-a-url").is_err());
762    }
763
764    #[test]
765    fn base_url_path_prefixes_are_dropped() {
766        // Documented footgun, pinned so it cannot change silently: request
767        // paths are absolute, so they replace the base path entirely. If this
768        // test ever fails, the doc comment on with_base_url needs updating too.
769        let client = HttpClientBuilder::new("http://localhost:8080/proxy")
770            .build()
771            .unwrap();
772        assert_eq!(
773            client.base_url.join("/user-pnl").unwrap().as_str(),
774            "http://localhost:8080/user-pnl",
775            "a path prefix in the base URL is not preserved"
776        );
777
778        let with_slash = client
779            .with_base_url("http://localhost:8080/proxy/")
780            .unwrap();
781        assert_eq!(
782            with_slash.base_url.join("/user-pnl").unwrap().as_str(),
783            "http://localhost:8080/user-pnl",
784            "a trailing slash does not preserve the prefix either"
785        );
786    }
787}