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