Skip to main content

polyoxide_core/
client.rs

1use std::sync::Arc;
2use std::time::Duration;
3
4use reqwest::StatusCode;
5use tokio::sync::{OwnedSemaphorePermit, Semaphore};
6use url::Url;
7
8use reqwest::header::RETRY_AFTER;
9
10use crate::error::ApiError;
11use crate::rate_limit::{RateLimiter, RetryConfig};
12
13/// Extract the `Retry-After` header value as a string, if present and valid UTF-8.
14pub fn retry_after_header(response: &reqwest::Response) -> Option<String> {
15    response
16        .headers()
17        .get(RETRY_AFTER)?
18        .to_str()
19        .ok()
20        .map(String::from)
21}
22
23/// Default request timeout in milliseconds
24pub const DEFAULT_TIMEOUT_MS: u64 = 30_000;
25/// Default connection pool size per host
26pub const DEFAULT_POOL_SIZE: usize = 10;
27
28/// Shared HTTP client with base URL, optional rate limiter, and retry config.
29///
30/// This is the common structure used by all API clients to hold
31/// the configured reqwest client, base URL, and rate-limiting state.
32#[derive(Debug, Clone)]
33pub struct HttpClient {
34    /// The underlying reqwest HTTP client
35    pub client: reqwest::Client,
36    /// Base URL for API requests
37    pub base_url: Url,
38    rate_limiter: Option<RateLimiter>,
39    retry_config: RetryConfig,
40    concurrency_limiter: Option<Arc<Semaphore>>,
41}
42
43impl HttpClient {
44    /// Clone this client, pointed at a different base URL.
45    ///
46    /// The underlying reqwest client (and so its connection pool), rate
47    /// limiter, retry config, and concurrency limiter are all shared with the
48    /// original. Use this to reach a sibling API host that should share the
49    /// same transport configuration and request budget — several Polymarket
50    /// APIs live on their own subdomains but are consumed by one client.
51    ///
52    /// Sharing the concurrency limiter is deliberate: the limit exists to keep
53    /// Cloudflare from seeing a burst from this process, and that is a
54    /// per-process concern rather than a per-host one.
55    ///
56    /// # Path prefixes are ignored
57    ///
58    /// Request paths are absolute (`/user-pnl`), and resolving an absolute
59    /// path against a base replaces the base's path entirely. So a prefix in
60    /// `base_url` is **silently dropped**, with or without a trailing slash:
61    ///
62    /// ```text
63    /// http://host          + /user-pnl -> http://host/user-pnl
64    /// http://host/proxy    + /user-pnl -> http://host/user-pnl   (prefix gone)
65    /// http://host/proxy/   + /user-pnl -> http://host/user-pnl   (prefix gone)
66    /// ```
67    ///
68    /// Point this at a scheme, host, and port — not at a sub-path. Fronting
69    /// the API with a path-prefixed reverse proxy is not supported.
70    pub fn with_base_url(&self, base_url: &str) -> Result<Self, ApiError> {
71        Ok(Self {
72            base_url: Url::parse(base_url)?,
73            ..self.clone()
74        })
75    }
76
77    /// Await rate limiter for the given endpoint path + method.
78    pub async fn acquire_rate_limit(&self, path: &str, method: Option<&reqwest::Method>) {
79        if let Some(rl) = &self.rate_limiter {
80            rl.acquire(path, method).await;
81        }
82    }
83
84    /// Acquire a concurrency permit, if a limiter is configured.
85    ///
86    /// The returned permit **must** be held until the HTTP response has been
87    /// received. Dropping the permit releases the concurrency slot.
88    /// Returns `None` when no concurrency limit is set.
89    pub async fn acquire_concurrency(&self) -> Option<OwnedSemaphorePermit> {
90        let sem = self.concurrency_limiter.as_ref()?;
91        Some(
92            sem.clone()
93                .acquire_owned()
94                .await
95                .expect("concurrency semaphore is never closed"),
96        )
97    }
98
99    /// Check if a response should be retried; returns backoff duration if yes.
100    ///
101    /// Retries two statuses, both of which upstream documents as "retry with
102    /// exponential backoff":
103    ///
104    /// - `429 Too Many Requests` — rate limited.
105    /// - `425 Too Early` — Polymarket's matching engine is restarting. It returns
106    ///   this with no body, so nothing was processed.
107    ///
108    /// Deliberately narrow: 5xx is *not* retried here. It is retriable in the
109    /// [`ApiError::is_retriable`] sense, but a 5xx can mean the request was
110    /// partially applied, and this loop resends non-idempotent writes. The two
111    /// statuses above are safe because neither reaches the matching engine — and
112    /// for order placement the resent body is byte-identical, so the order hash
113    /// is unchanged and the venue rejects a genuine double-submit as a duplicate.
114    /// Callers wanting broader retry semantics should drive them from
115    /// [`ApiError::is_retriable`] with their own idempotency judgement.
116    ///
117    /// When `retry_after` is `Some`, the server-provided delay is used instead of
118    /// the client-computed exponential backoff (clamped to `max_backoff_ms`).
119    pub fn should_retry(
120        &self,
121        status: StatusCode,
122        attempt: u32,
123        retry_after: Option<&str>,
124    ) -> Option<Duration> {
125        let retriable = status == StatusCode::TOO_MANY_REQUESTS || status == StatusCode::TOO_EARLY;
126        if retriable && attempt < self.retry_config.max_retries {
127            if let Some(delay) = retry_after.and_then(|v| v.parse::<f64>().ok()) {
128                let ms = (delay * 1000.0) as u64;
129                Some(Duration::from_millis(
130                    ms.min(self.retry_config.max_backoff_ms),
131                ))
132            } else {
133                Some(self.retry_config.backoff(attempt))
134            }
135        } else {
136            None
137        }
138    }
139
140    /// GET a URL and return the raw response body as bytes.
141    ///
142    /// Use this for endpoints that return non-JSON payloads (e.g. `application/zip`
143    /// downloads). Applies the same rate-limiting, concurrency gating, and
144    /// [`should_retry`](Self::should_retry) behavior as the JSON-oriented
145    /// [`Request`](crate::Request) helper.
146    ///
147    /// Non-2xx responses are mapped to [`ApiError`] via
148    /// [`ApiError::from_response`].
149    ///
150    /// # Errors
151    ///
152    /// Returns [`ApiError`] on URL-join failure, network errors, or non-2xx
153    /// responses.
154    pub async fn get_bytes(
155        &self,
156        path: &str,
157        query: &[(String, String)],
158    ) -> Result<Vec<u8>, ApiError> {
159        let url = self.base_url.join(path)?;
160        let mut attempt = 0u32;
161
162        loop {
163            let _permit = self.acquire_concurrency().await;
164            self.acquire_rate_limit(path, None).await;
165
166            let mut request = self.client.get(url.clone());
167            if !query.is_empty() {
168                request = request.query(query);
169            }
170
171            let response = request.send().await?;
172            let status = response.status();
173            let retry_after = retry_after_header(&response);
174
175            if let Some(backoff) = self.should_retry(status, attempt, retry_after.as_deref()) {
176                attempt += 1;
177                tracing::warn!(
178                    "Retriable status {} on {}, retry {} after {}ms",
179                    status,
180                    path,
181                    attempt,
182                    backoff.as_millis()
183                );
184                drop(_permit);
185                tokio::time::sleep(backoff).await;
186                continue;
187            }
188
189            if !status.is_success() {
190                return Err(ApiError::from_response(response).await);
191            }
192
193            let bytes = response.bytes().await?;
194            return Ok(bytes.to_vec());
195        }
196    }
197}
198
199/// Builder for configuring HTTP clients.
200///
201/// Provides a consistent way to configure HTTP clients across all API crates
202/// with sensible defaults.
203///
204/// # Example
205///
206/// ```
207/// use polyoxide_core::HttpClientBuilder;
208///
209/// let client = HttpClientBuilder::new("https://api.example.com")
210///     .timeout_ms(60_000)
211///     .pool_size(20)
212///     .build()
213///     .unwrap();
214/// ```
215pub struct HttpClientBuilder {
216    base_url: String,
217    timeout_ms: u64,
218    pool_size: usize,
219    rate_limiter: Option<RateLimiter>,
220    retry_config: RetryConfig,
221    max_concurrent: Option<usize>,
222}
223
224impl HttpClientBuilder {
225    /// Create a new HTTP client builder with the given base URL.
226    pub fn new(base_url: impl Into<String>) -> Self {
227        Self {
228            base_url: base_url.into(),
229            timeout_ms: DEFAULT_TIMEOUT_MS,
230            pool_size: DEFAULT_POOL_SIZE,
231            rate_limiter: None,
232            retry_config: RetryConfig::default(),
233            max_concurrent: None,
234        }
235    }
236
237    /// Set request timeout in milliseconds.
238    ///
239    /// Default: 30,000ms (30 seconds)
240    pub fn timeout_ms(mut self, timeout: u64) -> Self {
241        self.timeout_ms = timeout;
242        self
243    }
244
245    /// Set connection pool size per host.
246    ///
247    /// Default: 10 connections
248    pub fn pool_size(mut self, size: usize) -> Self {
249        self.pool_size = size;
250        self
251    }
252
253    /// Set a rate limiter for this client.
254    pub fn with_rate_limiter(mut self, limiter: RateLimiter) -> Self {
255        self.rate_limiter = Some(limiter);
256        self
257    }
258
259    /// Set retry configuration for 429 responses.
260    pub fn with_retry_config(mut self, config: RetryConfig) -> Self {
261        self.retry_config = config;
262        self
263    }
264
265    /// Set the maximum number of concurrent in-flight HTTP requests.
266    ///
267    /// Prevents Cloudflare 1015 rate-limit errors caused by request bursts
268    /// when many callers share the same client concurrently.
269    pub fn with_max_concurrent(mut self, max: usize) -> Self {
270        self.max_concurrent = Some(max);
271        self
272    }
273
274    /// Build the HTTP client.
275    pub fn build(self) -> Result<HttpClient, ApiError> {
276        let client = reqwest::Client::builder()
277            .timeout(Duration::from_millis(self.timeout_ms))
278            .connect_timeout(Duration::from_secs(10))
279            .redirect(reqwest::redirect::Policy::none())
280            .pool_max_idle_per_host(self.pool_size)
281            .build()?;
282
283        let base_url = Url::parse(&self.base_url)?;
284
285        Ok(HttpClient {
286            client,
287            base_url,
288            rate_limiter: self.rate_limiter,
289            retry_config: self.retry_config,
290            concurrency_limiter: self.max_concurrent.map(|n| Arc::new(Semaphore::new(n))),
291        })
292    }
293}
294
295impl Default for HttpClientBuilder {
296    fn default() -> Self {
297        Self {
298            base_url: String::new(),
299            timeout_ms: DEFAULT_TIMEOUT_MS,
300            pool_size: DEFAULT_POOL_SIZE,
301            rate_limiter: None,
302            retry_config: RetryConfig::default(),
303            max_concurrent: None,
304        }
305    }
306}
307
308#[cfg(test)]
309mod tests {
310    use super::*;
311
312    // ── should_retry() ───────────────────────────────────────────
313
314    #[test]
315    fn test_should_retry_429_under_max() {
316        let client = HttpClientBuilder::new("https://example.com")
317            .build()
318            .unwrap();
319        // Default max_retries=3, so attempts 0 and 2 should retry
320        assert!(client
321            .should_retry(StatusCode::TOO_MANY_REQUESTS, 0, None)
322            .is_some());
323        assert!(client
324            .should_retry(StatusCode::TOO_MANY_REQUESTS, 2, None)
325            .is_some());
326    }
327
328    #[test]
329    fn test_should_retry_429_at_max() {
330        let client = HttpClientBuilder::new("https://example.com")
331            .build()
332            .unwrap();
333        // attempt == max_retries → no retry
334        assert!(client
335            .should_retry(StatusCode::TOO_MANY_REQUESTS, 3, None)
336            .is_none());
337    }
338
339    #[test]
340    fn test_should_retry_425_under_max() {
341        let client = HttpClientBuilder::new("https://example.com")
342            .build()
343            .unwrap();
344        // 425 Too Early — Polymarket's matching engine restarting.
345        assert!(client
346            .should_retry(StatusCode::TOO_EARLY, 0, None)
347            .is_some());
348        assert!(client
349            .should_retry(StatusCode::TOO_EARLY, 2, None)
350            .is_some());
351    }
352
353    #[test]
354    fn test_should_retry_425_at_max() {
355        let client = HttpClientBuilder::new("https://example.com")
356            .build()
357            .unwrap();
358        assert!(client
359            .should_retry(StatusCode::TOO_EARLY, 3, None)
360            .is_none());
361    }
362
363    #[test]
364    fn test_should_retry_ignores_other_statuses() {
365        let client = HttpClientBuilder::new("https://example.com")
366            .build()
367            .unwrap();
368        for status in [
369            StatusCode::OK,
370            StatusCode::INTERNAL_SERVER_ERROR,
371            StatusCode::BAD_GATEWAY,
372            StatusCode::BAD_REQUEST,
373            StatusCode::FORBIDDEN,
374            StatusCode::NOT_FOUND,
375            // 503 is deliberately excluded even though post-only mode sends it
376            // with a Retry-After: the documented wait is ~79s, far too long to
377            // block inside a request, and it rejects orders wholesale.
378            StatusCode::SERVICE_UNAVAILABLE,
379        ] {
380            assert!(
381                client.should_retry(status, 0, None).is_none(),
382                "expected None for {status}"
383            );
384        }
385    }
386
387    #[test]
388    fn test_should_retry_5xx_not_retried_despite_being_is_retriable() {
389        // The two notions differ on purpose. `ApiError::is_retriable` describes the
390        // error; this loop resends non-idempotent writes, so it stays narrower.
391        let client = HttpClientBuilder::new("https://example.com")
392            .build()
393            .unwrap();
394        assert!(client
395            .should_retry(StatusCode::INTERNAL_SERVER_ERROR, 0, None)
396            .is_none());
397        assert!(ApiError::Api {
398            status: 500,
399            message: String::new()
400        }
401        .is_retriable());
402    }
403
404    #[test]
405    fn test_should_retry_custom_config() {
406        let client = HttpClientBuilder::new("https://example.com")
407            .with_retry_config(RetryConfig {
408                max_retries: 1,
409                ..RetryConfig::default()
410            })
411            .build()
412            .unwrap();
413        assert!(client
414            .should_retry(StatusCode::TOO_MANY_REQUESTS, 0, None)
415            .is_some());
416        assert!(client
417            .should_retry(StatusCode::TOO_MANY_REQUESTS, 1, None)
418            .is_none());
419    }
420
421    #[test]
422    fn test_should_retry_uses_retry_after_header() {
423        let client = HttpClientBuilder::new("https://example.com")
424            .build()
425            .unwrap();
426        let d = client
427            .should_retry(StatusCode::TOO_MANY_REQUESTS, 0, Some("2"))
428            .unwrap();
429        assert_eq!(d, Duration::from_millis(2000));
430    }
431
432    #[test]
433    fn test_should_retry_retry_after_fractional_seconds() {
434        let client = HttpClientBuilder::new("https://example.com")
435            .build()
436            .unwrap();
437        let d = client
438            .should_retry(StatusCode::TOO_MANY_REQUESTS, 0, Some("0.5"))
439            .unwrap();
440        assert_eq!(d, Duration::from_millis(500));
441    }
442
443    #[test]
444    fn test_should_retry_retry_after_clamped_to_max_backoff() {
445        let client = HttpClientBuilder::new("https://example.com")
446            .build()
447            .unwrap();
448        // Default max_backoff_ms = 10_000; header says 60s
449        let d = client
450            .should_retry(StatusCode::TOO_MANY_REQUESTS, 0, Some("60"))
451            .unwrap();
452        assert_eq!(d, Duration::from_millis(10_000));
453    }
454
455    #[test]
456    fn test_should_retry_retry_after_invalid_falls_back() {
457        let client = HttpClientBuilder::new("https://example.com")
458            .build()
459            .unwrap();
460        // Non-numeric Retry-After (HTTP-date format) falls back to computed backoff
461        let d = client
462            .should_retry(
463                StatusCode::TOO_MANY_REQUESTS,
464                0,
465                Some("Wed, 21 Oct 2025 07:28:00 GMT"),
466            )
467            .unwrap();
468        // Should be in the jitter range for attempt 0: [375, 625]ms
469        let ms = d.as_millis() as u64;
470        assert!(
471            (375..=625).contains(&ms),
472            "expected fallback backoff in [375, 625], got {ms}"
473        );
474    }
475
476    // ── Builder wiring ───────────────────────────────────────────
477
478    #[tokio::test]
479    async fn test_builder_with_rate_limiter() {
480        let client = HttpClientBuilder::new("https://example.com")
481            .with_rate_limiter(RateLimiter::clob_default())
482            .build()
483            .unwrap();
484        let start = std::time::Instant::now();
485        client
486            .acquire_rate_limit("/order", Some(&reqwest::Method::POST))
487            .await;
488        assert!(start.elapsed() < Duration::from_millis(50));
489    }
490
491    #[tokio::test]
492    async fn test_builder_without_rate_limiter() {
493        let client = HttpClientBuilder::new("https://example.com")
494            .build()
495            .unwrap();
496        let start = std::time::Instant::now();
497        client
498            .acquire_rate_limit("/order", Some(&reqwest::Method::POST))
499            .await;
500        assert!(start.elapsed() < Duration::from_millis(10));
501    }
502
503    // ── Concurrency limiter ─────────────────────────────────────
504
505    #[tokio::test]
506    async fn test_acquire_concurrency_none_when_not_configured() {
507        let client = HttpClientBuilder::new("https://example.com")
508            .build()
509            .unwrap();
510        assert!(client.acquire_concurrency().await.is_none());
511    }
512
513    #[tokio::test]
514    async fn test_acquire_concurrency_returns_permit() {
515        let client = HttpClientBuilder::new("https://example.com")
516            .with_max_concurrent(2)
517            .build()
518            .unwrap();
519        let permit = client.acquire_concurrency().await;
520        assert!(permit.is_some());
521    }
522
523    #[tokio::test]
524    async fn test_concurrency_shared_across_clones() {
525        let client = HttpClientBuilder::new("https://example.com")
526            .with_max_concurrent(1)
527            .build()
528            .unwrap();
529        let clone = client.clone();
530
531        // Hold the only permit from the original
532        let _permit = client.acquire_concurrency().await.unwrap();
533
534        // Clone should block because concurrency=1 and permit is held
535        let result =
536            tokio::time::timeout(Duration::from_millis(50), clone.acquire_concurrency()).await;
537        assert!(result.is_err(), "clone should block when permit is held");
538    }
539
540    #[tokio::test]
541    async fn test_concurrency_limits_parallel_tasks() {
542        let client = HttpClientBuilder::new("https://example.com")
543            .with_max_concurrent(2)
544            .build()
545            .unwrap();
546
547        let start = std::time::Instant::now();
548        let mut handles = Vec::new();
549        for _ in 0..4 {
550            let c = client.clone();
551            handles.push(tokio::spawn(async move {
552                let _permit = c.acquire_concurrency().await;
553                tokio::time::sleep(Duration::from_millis(50)).await;
554            }));
555        }
556        for h in handles {
557            h.await.unwrap();
558        }
559        // 4 tasks, concurrency 2, 50ms each => ~100ms minimum
560        assert!(
561            start.elapsed() >= Duration::from_millis(90),
562            "expected ~100ms, got {:?}",
563            start.elapsed()
564        );
565    }
566
567    #[tokio::test]
568    async fn test_builder_with_max_concurrent() {
569        let client = HttpClientBuilder::new("https://example.com")
570            .with_max_concurrent(5)
571            .build()
572            .unwrap();
573        // Should be able to acquire 5 permits
574        let mut permits = Vec::new();
575        for _ in 0..5 {
576            permits.push(client.acquire_concurrency().await);
577        }
578        assert!(permits.iter().all(|p| p.is_some()));
579
580        // 6th should block
581        let result =
582            tokio::time::timeout(Duration::from_millis(50), client.acquire_concurrency()).await;
583        assert!(result.is_err());
584    }
585
586    // ── get_bytes() ──────────────────────────────────────────────
587
588    #[tokio::test]
589    async fn test_get_bytes_returns_body_verbatim() {
590        let mut server = mockito::Server::new_async().await;
591        // Intentionally non-UTF-8 bytes to prove we're not assuming text.
592        let body: Vec<u8> = vec![0x50, 0x4B, 0x03, 0x04, 0x00, 0xFF, 0xFE, 0x42];
593        let mock = server
594            .mock("GET", "/v1/accounting/snapshot")
595            .match_query(mockito::Matcher::UrlEncoded("user".into(), "0xabc".into()))
596            .with_status(200)
597            .with_header("content-type", "application/zip")
598            .with_body(body.clone())
599            .create_async()
600            .await;
601
602        let client = HttpClientBuilder::new(server.url()).build().unwrap();
603        let out = client
604            .get_bytes(
605                "/v1/accounting/snapshot",
606                &[("user".to_string(), "0xabc".to_string())],
607            )
608            .await
609            .unwrap();
610        assert_eq!(out, body);
611        mock.assert_async().await;
612    }
613
614    #[tokio::test]
615    async fn test_get_bytes_maps_non_2xx_to_api_error() {
616        let mut server = mockito::Server::new_async().await;
617        let mock = server
618            .mock("GET", "/does-not-exist")
619            .with_status(404)
620            .with_header("content-type", "application/json")
621            .with_body(r#"{"error": "not found"}"#)
622            .create_async()
623            .await;
624
625        let client = HttpClientBuilder::new(server.url()).build().unwrap();
626        let err = client.get_bytes("/does-not-exist", &[]).await.unwrap_err();
627        match err {
628            ApiError::Api { status, message } => {
629                assert_eq!(status, 404);
630                assert_eq!(message, "not found");
631            }
632            other => panic!("expected ApiError::Api, got {other:?}"),
633        }
634        mock.assert_async().await;
635    }
636
637    #[tokio::test]
638    async fn test_get_bytes_no_query_params() {
639        let mut server = mockito::Server::new_async().await;
640        let mock = server
641            .mock("GET", "/raw")
642            .with_status(200)
643            .with_body(&b"hello"[..])
644            .create_async()
645            .await;
646
647        let client = HttpClientBuilder::new(server.url()).build().unwrap();
648        let out = client.get_bytes("/raw", &[]).await.unwrap();
649        assert_eq!(out, b"hello");
650        mock.assert_async().await;
651    }
652
653    #[test]
654    fn with_base_url_retargets_and_shares_transport() {
655        let client = HttpClientBuilder::new("https://data-api.polymarket.com")
656            .with_max_concurrent(4)
657            .build()
658            .unwrap();
659        let sibling = client
660            .with_base_url("https://user-pnl-api.polymarket.com")
661            .unwrap();
662
663        assert_eq!(
664            sibling.base_url.host_str(),
665            Some("user-pnl-api.polymarket.com")
666        );
667        // The original is untouched — this returns a clone, not a mutation.
668        assert_eq!(client.base_url.host_str(), Some("data-api.polymarket.com"));
669        // Both must draw on the same concurrency budget, since the limit exists
670        // to stop this process bursting rather than to pace any one host.
671        assert!(sibling.concurrency_limiter.is_some());
672    }
673
674    #[test]
675    fn with_base_url_rejects_a_malformed_url() {
676        let client = HttpClientBuilder::new("https://data-api.polymarket.com")
677            .build()
678            .unwrap();
679        assert!(client.with_base_url("not-a-url").is_err());
680    }
681
682    #[test]
683    fn base_url_path_prefixes_are_dropped() {
684        // Documented footgun, pinned so it cannot change silently: request
685        // paths are absolute, so they replace the base path entirely. If this
686        // test ever fails, the doc comment on with_base_url needs updating too.
687        let client = HttpClientBuilder::new("http://localhost:8080/proxy")
688            .build()
689            .unwrap();
690        assert_eq!(
691            client.base_url.join("/user-pnl").unwrap().as_str(),
692            "http://localhost:8080/user-pnl",
693            "a path prefix in the base URL is not preserved"
694        );
695
696        let with_slash = client
697            .with_base_url("http://localhost:8080/proxy/")
698            .unwrap();
699        assert_eq!(
700            with_slash.base_url.join("/user-pnl").unwrap().as_str(),
701            "http://localhost:8080/user-pnl",
702            "a trailing slash does not preserve the prefix either"
703        );
704    }
705}