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 429 response should be retried; returns backoff duration if yes.
100    ///
101    /// When `retry_after` is `Some`, the server-provided delay is used instead of
102    /// the client-computed exponential backoff (clamped to `max_backoff_ms`).
103    pub fn should_retry(
104        &self,
105        status: StatusCode,
106        attempt: u32,
107        retry_after: Option<&str>,
108    ) -> Option<Duration> {
109        if status == StatusCode::TOO_MANY_REQUESTS && attempt < self.retry_config.max_retries {
110            if let Some(delay) = retry_after.and_then(|v| v.parse::<f64>().ok()) {
111                let ms = (delay * 1000.0) as u64;
112                Some(Duration::from_millis(
113                    ms.min(self.retry_config.max_backoff_ms),
114                ))
115            } else {
116                Some(self.retry_config.backoff(attempt))
117            }
118        } else {
119            None
120        }
121    }
122
123    /// GET a URL and return the raw response body as bytes.
124    ///
125    /// Use this for endpoints that return non-JSON payloads (e.g. `application/zip`
126    /// downloads). Applies the same rate-limiting, concurrency gating, and 429
127    /// retry behavior as the JSON-oriented [`Request`](crate::Request) helper.
128    ///
129    /// Non-2xx responses are mapped to [`ApiError`] via
130    /// [`ApiError::from_response`].
131    ///
132    /// # Errors
133    ///
134    /// Returns [`ApiError`] on URL-join failure, network errors, or non-2xx
135    /// responses.
136    pub async fn get_bytes(
137        &self,
138        path: &str,
139        query: &[(String, String)],
140    ) -> Result<Vec<u8>, ApiError> {
141        let url = self.base_url.join(path)?;
142        let mut attempt = 0u32;
143
144        loop {
145            let _permit = self.acquire_concurrency().await;
146            self.acquire_rate_limit(path, None).await;
147
148            let mut request = self.client.get(url.clone());
149            if !query.is_empty() {
150                request = request.query(query);
151            }
152
153            let response = request.send().await?;
154            let status = response.status();
155            let retry_after = retry_after_header(&response);
156
157            if let Some(backoff) = self.should_retry(status, attempt, retry_after.as_deref()) {
158                attempt += 1;
159                tracing::warn!(
160                    "Rate limited (429) on {}, retry {} after {}ms",
161                    path,
162                    attempt,
163                    backoff.as_millis()
164                );
165                drop(_permit);
166                tokio::time::sleep(backoff).await;
167                continue;
168            }
169
170            if !status.is_success() {
171                return Err(ApiError::from_response(response).await);
172            }
173
174            let bytes = response.bytes().await?;
175            return Ok(bytes.to_vec());
176        }
177    }
178}
179
180/// Builder for configuring HTTP clients.
181///
182/// Provides a consistent way to configure HTTP clients across all API crates
183/// with sensible defaults.
184///
185/// # Example
186///
187/// ```
188/// use polyoxide_core::HttpClientBuilder;
189///
190/// let client = HttpClientBuilder::new("https://api.example.com")
191///     .timeout_ms(60_000)
192///     .pool_size(20)
193///     .build()
194///     .unwrap();
195/// ```
196pub struct HttpClientBuilder {
197    base_url: String,
198    timeout_ms: u64,
199    pool_size: usize,
200    rate_limiter: Option<RateLimiter>,
201    retry_config: RetryConfig,
202    max_concurrent: Option<usize>,
203}
204
205impl HttpClientBuilder {
206    /// Create a new HTTP client builder with the given base URL.
207    pub fn new(base_url: impl Into<String>) -> Self {
208        Self {
209            base_url: base_url.into(),
210            timeout_ms: DEFAULT_TIMEOUT_MS,
211            pool_size: DEFAULT_POOL_SIZE,
212            rate_limiter: None,
213            retry_config: RetryConfig::default(),
214            max_concurrent: None,
215        }
216    }
217
218    /// Set request timeout in milliseconds.
219    ///
220    /// Default: 30,000ms (30 seconds)
221    pub fn timeout_ms(mut self, timeout: u64) -> Self {
222        self.timeout_ms = timeout;
223        self
224    }
225
226    /// Set connection pool size per host.
227    ///
228    /// Default: 10 connections
229    pub fn pool_size(mut self, size: usize) -> Self {
230        self.pool_size = size;
231        self
232    }
233
234    /// Set a rate limiter for this client.
235    pub fn with_rate_limiter(mut self, limiter: RateLimiter) -> Self {
236        self.rate_limiter = Some(limiter);
237        self
238    }
239
240    /// Set retry configuration for 429 responses.
241    pub fn with_retry_config(mut self, config: RetryConfig) -> Self {
242        self.retry_config = config;
243        self
244    }
245
246    /// Set the maximum number of concurrent in-flight HTTP requests.
247    ///
248    /// Prevents Cloudflare 1015 rate-limit errors caused by request bursts
249    /// when many callers share the same client concurrently.
250    pub fn with_max_concurrent(mut self, max: usize) -> Self {
251        self.max_concurrent = Some(max);
252        self
253    }
254
255    /// Build the HTTP client.
256    pub fn build(self) -> Result<HttpClient, ApiError> {
257        let client = reqwest::Client::builder()
258            .timeout(Duration::from_millis(self.timeout_ms))
259            .connect_timeout(Duration::from_secs(10))
260            .redirect(reqwest::redirect::Policy::none())
261            .pool_max_idle_per_host(self.pool_size)
262            .build()?;
263
264        let base_url = Url::parse(&self.base_url)?;
265
266        Ok(HttpClient {
267            client,
268            base_url,
269            rate_limiter: self.rate_limiter,
270            retry_config: self.retry_config,
271            concurrency_limiter: self.max_concurrent.map(|n| Arc::new(Semaphore::new(n))),
272        })
273    }
274}
275
276impl Default for HttpClientBuilder {
277    fn default() -> Self {
278        Self {
279            base_url: String::new(),
280            timeout_ms: DEFAULT_TIMEOUT_MS,
281            pool_size: DEFAULT_POOL_SIZE,
282            rate_limiter: None,
283            retry_config: RetryConfig::default(),
284            max_concurrent: None,
285        }
286    }
287}
288
289#[cfg(test)]
290mod tests {
291    use super::*;
292
293    // ── should_retry() ───────────────────────────────────────────
294
295    #[test]
296    fn test_should_retry_429_under_max() {
297        let client = HttpClientBuilder::new("https://example.com")
298            .build()
299            .unwrap();
300        // Default max_retries=3, so attempts 0 and 2 should retry
301        assert!(client
302            .should_retry(StatusCode::TOO_MANY_REQUESTS, 0, None)
303            .is_some());
304        assert!(client
305            .should_retry(StatusCode::TOO_MANY_REQUESTS, 2, None)
306            .is_some());
307    }
308
309    #[test]
310    fn test_should_retry_429_at_max() {
311        let client = HttpClientBuilder::new("https://example.com")
312            .build()
313            .unwrap();
314        // attempt == max_retries → no retry
315        assert!(client
316            .should_retry(StatusCode::TOO_MANY_REQUESTS, 3, None)
317            .is_none());
318    }
319
320    #[test]
321    fn test_should_retry_non_429_returns_none() {
322        let client = HttpClientBuilder::new("https://example.com")
323            .build()
324            .unwrap();
325        for status in [
326            StatusCode::OK,
327            StatusCode::INTERNAL_SERVER_ERROR,
328            StatusCode::BAD_REQUEST,
329            StatusCode::FORBIDDEN,
330        ] {
331            assert!(
332                client.should_retry(status, 0, None).is_none(),
333                "expected None for {status}"
334            );
335        }
336    }
337
338    #[test]
339    fn test_should_retry_custom_config() {
340        let client = HttpClientBuilder::new("https://example.com")
341            .with_retry_config(RetryConfig {
342                max_retries: 1,
343                ..RetryConfig::default()
344            })
345            .build()
346            .unwrap();
347        assert!(client
348            .should_retry(StatusCode::TOO_MANY_REQUESTS, 0, None)
349            .is_some());
350        assert!(client
351            .should_retry(StatusCode::TOO_MANY_REQUESTS, 1, None)
352            .is_none());
353    }
354
355    #[test]
356    fn test_should_retry_uses_retry_after_header() {
357        let client = HttpClientBuilder::new("https://example.com")
358            .build()
359            .unwrap();
360        let d = client
361            .should_retry(StatusCode::TOO_MANY_REQUESTS, 0, Some("2"))
362            .unwrap();
363        assert_eq!(d, Duration::from_millis(2000));
364    }
365
366    #[test]
367    fn test_should_retry_retry_after_fractional_seconds() {
368        let client = HttpClientBuilder::new("https://example.com")
369            .build()
370            .unwrap();
371        let d = client
372            .should_retry(StatusCode::TOO_MANY_REQUESTS, 0, Some("0.5"))
373            .unwrap();
374        assert_eq!(d, Duration::from_millis(500));
375    }
376
377    #[test]
378    fn test_should_retry_retry_after_clamped_to_max_backoff() {
379        let client = HttpClientBuilder::new("https://example.com")
380            .build()
381            .unwrap();
382        // Default max_backoff_ms = 10_000; header says 60s
383        let d = client
384            .should_retry(StatusCode::TOO_MANY_REQUESTS, 0, Some("60"))
385            .unwrap();
386        assert_eq!(d, Duration::from_millis(10_000));
387    }
388
389    #[test]
390    fn test_should_retry_retry_after_invalid_falls_back() {
391        let client = HttpClientBuilder::new("https://example.com")
392            .build()
393            .unwrap();
394        // Non-numeric Retry-After (HTTP-date format) falls back to computed backoff
395        let d = client
396            .should_retry(
397                StatusCode::TOO_MANY_REQUESTS,
398                0,
399                Some("Wed, 21 Oct 2025 07:28:00 GMT"),
400            )
401            .unwrap();
402        // Should be in the jitter range for attempt 0: [375, 625]ms
403        let ms = d.as_millis() as u64;
404        assert!(
405            (375..=625).contains(&ms),
406            "expected fallback backoff in [375, 625], got {ms}"
407        );
408    }
409
410    // ── Builder wiring ───────────────────────────────────────────
411
412    #[tokio::test]
413    async fn test_builder_with_rate_limiter() {
414        let client = HttpClientBuilder::new("https://example.com")
415            .with_rate_limiter(RateLimiter::clob_default())
416            .build()
417            .unwrap();
418        let start = std::time::Instant::now();
419        client
420            .acquire_rate_limit("/order", Some(&reqwest::Method::POST))
421            .await;
422        assert!(start.elapsed() < Duration::from_millis(50));
423    }
424
425    #[tokio::test]
426    async fn test_builder_without_rate_limiter() {
427        let client = HttpClientBuilder::new("https://example.com")
428            .build()
429            .unwrap();
430        let start = std::time::Instant::now();
431        client
432            .acquire_rate_limit("/order", Some(&reqwest::Method::POST))
433            .await;
434        assert!(start.elapsed() < Duration::from_millis(10));
435    }
436
437    // ── Concurrency limiter ─────────────────────────────────────
438
439    #[tokio::test]
440    async fn test_acquire_concurrency_none_when_not_configured() {
441        let client = HttpClientBuilder::new("https://example.com")
442            .build()
443            .unwrap();
444        assert!(client.acquire_concurrency().await.is_none());
445    }
446
447    #[tokio::test]
448    async fn test_acquire_concurrency_returns_permit() {
449        let client = HttpClientBuilder::new("https://example.com")
450            .with_max_concurrent(2)
451            .build()
452            .unwrap();
453        let permit = client.acquire_concurrency().await;
454        assert!(permit.is_some());
455    }
456
457    #[tokio::test]
458    async fn test_concurrency_shared_across_clones() {
459        let client = HttpClientBuilder::new("https://example.com")
460            .with_max_concurrent(1)
461            .build()
462            .unwrap();
463        let clone = client.clone();
464
465        // Hold the only permit from the original
466        let _permit = client.acquire_concurrency().await.unwrap();
467
468        // Clone should block because concurrency=1 and permit is held
469        let result =
470            tokio::time::timeout(Duration::from_millis(50), clone.acquire_concurrency()).await;
471        assert!(result.is_err(), "clone should block when permit is held");
472    }
473
474    #[tokio::test]
475    async fn test_concurrency_limits_parallel_tasks() {
476        let client = HttpClientBuilder::new("https://example.com")
477            .with_max_concurrent(2)
478            .build()
479            .unwrap();
480
481        let start = std::time::Instant::now();
482        let mut handles = Vec::new();
483        for _ in 0..4 {
484            let c = client.clone();
485            handles.push(tokio::spawn(async move {
486                let _permit = c.acquire_concurrency().await;
487                tokio::time::sleep(Duration::from_millis(50)).await;
488            }));
489        }
490        for h in handles {
491            h.await.unwrap();
492        }
493        // 4 tasks, concurrency 2, 50ms each => ~100ms minimum
494        assert!(
495            start.elapsed() >= Duration::from_millis(90),
496            "expected ~100ms, got {:?}",
497            start.elapsed()
498        );
499    }
500
501    #[tokio::test]
502    async fn test_builder_with_max_concurrent() {
503        let client = HttpClientBuilder::new("https://example.com")
504            .with_max_concurrent(5)
505            .build()
506            .unwrap();
507        // Should be able to acquire 5 permits
508        let mut permits = Vec::new();
509        for _ in 0..5 {
510            permits.push(client.acquire_concurrency().await);
511        }
512        assert!(permits.iter().all(|p| p.is_some()));
513
514        // 6th should block
515        let result =
516            tokio::time::timeout(Duration::from_millis(50), client.acquire_concurrency()).await;
517        assert!(result.is_err());
518    }
519
520    // ── get_bytes() ──────────────────────────────────────────────
521
522    #[tokio::test]
523    async fn test_get_bytes_returns_body_verbatim() {
524        let mut server = mockito::Server::new_async().await;
525        // Intentionally non-UTF-8 bytes to prove we're not assuming text.
526        let body: Vec<u8> = vec![0x50, 0x4B, 0x03, 0x04, 0x00, 0xFF, 0xFE, 0x42];
527        let mock = server
528            .mock("GET", "/v1/accounting/snapshot")
529            .match_query(mockito::Matcher::UrlEncoded("user".into(), "0xabc".into()))
530            .with_status(200)
531            .with_header("content-type", "application/zip")
532            .with_body(body.clone())
533            .create_async()
534            .await;
535
536        let client = HttpClientBuilder::new(server.url()).build().unwrap();
537        let out = client
538            .get_bytes(
539                "/v1/accounting/snapshot",
540                &[("user".to_string(), "0xabc".to_string())],
541            )
542            .await
543            .unwrap();
544        assert_eq!(out, body);
545        mock.assert_async().await;
546    }
547
548    #[tokio::test]
549    async fn test_get_bytes_maps_non_2xx_to_api_error() {
550        let mut server = mockito::Server::new_async().await;
551        let mock = server
552            .mock("GET", "/does-not-exist")
553            .with_status(404)
554            .with_header("content-type", "application/json")
555            .with_body(r#"{"error": "not found"}"#)
556            .create_async()
557            .await;
558
559        let client = HttpClientBuilder::new(server.url()).build().unwrap();
560        let err = client.get_bytes("/does-not-exist", &[]).await.unwrap_err();
561        match err {
562            ApiError::Api { status, message } => {
563                assert_eq!(status, 404);
564                assert_eq!(message, "not found");
565            }
566            other => panic!("expected ApiError::Api, got {other:?}"),
567        }
568        mock.assert_async().await;
569    }
570
571    #[tokio::test]
572    async fn test_get_bytes_no_query_params() {
573        let mut server = mockito::Server::new_async().await;
574        let mock = server
575            .mock("GET", "/raw")
576            .with_status(200)
577            .with_body(&b"hello"[..])
578            .create_async()
579            .await;
580
581        let client = HttpClientBuilder::new(server.url()).build().unwrap();
582        let out = client.get_bytes("/raw", &[]).await.unwrap();
583        assert_eq!(out, b"hello");
584        mock.assert_async().await;
585    }
586
587    #[test]
588    fn with_base_url_retargets_and_shares_transport() {
589        let client = HttpClientBuilder::new("https://data-api.polymarket.com")
590            .with_max_concurrent(4)
591            .build()
592            .unwrap();
593        let sibling = client
594            .with_base_url("https://user-pnl-api.polymarket.com")
595            .unwrap();
596
597        assert_eq!(
598            sibling.base_url.host_str(),
599            Some("user-pnl-api.polymarket.com")
600        );
601        // The original is untouched — this returns a clone, not a mutation.
602        assert_eq!(client.base_url.host_str(), Some("data-api.polymarket.com"));
603        // Both must draw on the same concurrency budget, since the limit exists
604        // to stop this process bursting rather than to pace any one host.
605        assert!(sibling.concurrency_limiter.is_some());
606    }
607
608    #[test]
609    fn with_base_url_rejects_a_malformed_url() {
610        let client = HttpClientBuilder::new("https://data-api.polymarket.com")
611            .build()
612            .unwrap();
613        assert!(client.with_base_url("not-a-url").is_err());
614    }
615
616    #[test]
617    fn base_url_path_prefixes_are_dropped() {
618        // Documented footgun, pinned so it cannot change silently: request
619        // paths are absolute, so they replace the base path entirely. If this
620        // test ever fails, the doc comment on with_base_url needs updating too.
621        let client = HttpClientBuilder::new("http://localhost:8080/proxy")
622            .build()
623            .unwrap();
624        assert_eq!(
625            client.base_url.join("/user-pnl").unwrap().as_str(),
626            "http://localhost:8080/user-pnl",
627            "a path prefix in the base URL is not preserved"
628        );
629
630        let with_slash = client
631            .with_base_url("http://localhost:8080/proxy/")
632            .unwrap();
633        assert_eq!(
634            with_slash.base_url.join("/user-pnl").unwrap().as_str(),
635            "http://localhost:8080/user-pnl",
636            "a trailing slash does not preserve the prefix either"
637        );
638    }
639}