Skip to main content

polyoxide_core/
client.rs

1use std::time::Duration;
2
3use reqwest::StatusCode;
4use url::Url;
5
6use reqwest::header::RETRY_AFTER;
7
8use crate::error::ApiError;
9use crate::rate_limit::{RateLimiter, RetryConfig};
10
11/// Extract the `Retry-After` header value as a string, if present and valid UTF-8.
12pub fn retry_after_header(response: &reqwest::Response) -> Option<String> {
13    response
14        .headers()
15        .get(RETRY_AFTER)?
16        .to_str()
17        .ok()
18        .map(String::from)
19}
20
21/// Default request timeout in milliseconds
22pub const DEFAULT_TIMEOUT_MS: u64 = 30_000;
23/// Default connection pool size per host
24pub const DEFAULT_POOL_SIZE: usize = 10;
25
26/// Shared HTTP client with base URL, optional rate limiter, and retry config.
27///
28/// This is the common structure used by all API clients to hold
29/// the configured reqwest client, base URL, and rate-limiting state.
30#[derive(Debug, Clone)]
31pub struct HttpClient {
32    /// The underlying reqwest HTTP client
33    pub client: reqwest::Client,
34    /// Base URL for API requests
35    pub base_url: Url,
36    rate_limiter: Option<RateLimiter>,
37    retry_config: RetryConfig,
38}
39
40impl HttpClient {
41    /// Await rate limiter for the given endpoint path + method.
42    pub async fn acquire_rate_limit(&self, path: &str, method: Option<&reqwest::Method>) {
43        if let Some(rl) = &self.rate_limiter {
44            rl.acquire(path, method).await;
45        }
46    }
47
48    /// Check if a 429 response should be retried; returns backoff duration if yes.
49    ///
50    /// When `retry_after` is `Some`, the server-provided delay is used instead of
51    /// the client-computed exponential backoff (clamped to `max_backoff_ms`).
52    pub fn should_retry(
53        &self,
54        status: StatusCode,
55        attempt: u32,
56        retry_after: Option<&str>,
57    ) -> Option<Duration> {
58        if status == StatusCode::TOO_MANY_REQUESTS && attempt < self.retry_config.max_retries {
59            if let Some(delay) = retry_after.and_then(|v| v.parse::<f64>().ok()) {
60                let ms = (delay * 1000.0) as u64;
61                Some(Duration::from_millis(
62                    ms.min(self.retry_config.max_backoff_ms),
63                ))
64            } else {
65                Some(self.retry_config.backoff(attempt))
66            }
67        } else {
68            None
69        }
70    }
71}
72
73/// Builder for configuring HTTP clients.
74///
75/// Provides a consistent way to configure HTTP clients across all API crates
76/// with sensible defaults.
77///
78/// # Example
79///
80/// ```
81/// use polyoxide_core::HttpClientBuilder;
82///
83/// let client = HttpClientBuilder::new("https://api.example.com")
84///     .timeout_ms(60_000)
85///     .pool_size(20)
86///     .build()
87///     .unwrap();
88/// ```
89pub struct HttpClientBuilder {
90    base_url: String,
91    timeout_ms: u64,
92    pool_size: usize,
93    rate_limiter: Option<RateLimiter>,
94    retry_config: RetryConfig,
95}
96
97impl HttpClientBuilder {
98    /// Create a new HTTP client builder with the given base URL.
99    pub fn new(base_url: impl Into<String>) -> Self {
100        Self {
101            base_url: base_url.into(),
102            timeout_ms: DEFAULT_TIMEOUT_MS,
103            pool_size: DEFAULT_POOL_SIZE,
104            rate_limiter: None,
105            retry_config: RetryConfig::default(),
106        }
107    }
108
109    /// Set request timeout in milliseconds.
110    ///
111    /// Default: 30,000ms (30 seconds)
112    pub fn timeout_ms(mut self, timeout: u64) -> Self {
113        self.timeout_ms = timeout;
114        self
115    }
116
117    /// Set connection pool size per host.
118    ///
119    /// Default: 10 connections
120    pub fn pool_size(mut self, size: usize) -> Self {
121        self.pool_size = size;
122        self
123    }
124
125    /// Set a rate limiter for this client.
126    pub fn with_rate_limiter(mut self, limiter: RateLimiter) -> Self {
127        self.rate_limiter = Some(limiter);
128        self
129    }
130
131    /// Set retry configuration for 429 responses.
132    pub fn with_retry_config(mut self, config: RetryConfig) -> Self {
133        self.retry_config = config;
134        self
135    }
136
137    /// Build the HTTP client.
138    pub fn build(self) -> Result<HttpClient, ApiError> {
139        let client = reqwest::Client::builder()
140            .timeout(Duration::from_millis(self.timeout_ms))
141            .pool_max_idle_per_host(self.pool_size)
142            .build()?;
143
144        let base_url = Url::parse(&self.base_url)?;
145
146        Ok(HttpClient {
147            client,
148            base_url,
149            rate_limiter: self.rate_limiter,
150            retry_config: self.retry_config,
151        })
152    }
153}
154
155impl Default for HttpClientBuilder {
156    fn default() -> Self {
157        Self {
158            base_url: String::new(),
159            timeout_ms: DEFAULT_TIMEOUT_MS,
160            pool_size: DEFAULT_POOL_SIZE,
161            rate_limiter: None,
162            retry_config: RetryConfig::default(),
163        }
164    }
165}
166
167#[cfg(test)]
168mod tests {
169    use super::*;
170
171    // ── should_retry() ───────────────────────────────────────────
172
173    #[test]
174    fn test_should_retry_429_under_max() {
175        let client = HttpClientBuilder::new("https://example.com")
176            .build()
177            .unwrap();
178        // Default max_retries=3, so attempts 0 and 2 should retry
179        assert!(client
180            .should_retry(StatusCode::TOO_MANY_REQUESTS, 0, None)
181            .is_some());
182        assert!(client
183            .should_retry(StatusCode::TOO_MANY_REQUESTS, 2, None)
184            .is_some());
185    }
186
187    #[test]
188    fn test_should_retry_429_at_max() {
189        let client = HttpClientBuilder::new("https://example.com")
190            .build()
191            .unwrap();
192        // attempt == max_retries → no retry
193        assert!(client
194            .should_retry(StatusCode::TOO_MANY_REQUESTS, 3, None)
195            .is_none());
196    }
197
198    #[test]
199    fn test_should_retry_non_429_returns_none() {
200        let client = HttpClientBuilder::new("https://example.com")
201            .build()
202            .unwrap();
203        for status in [
204            StatusCode::OK,
205            StatusCode::INTERNAL_SERVER_ERROR,
206            StatusCode::BAD_REQUEST,
207            StatusCode::FORBIDDEN,
208        ] {
209            assert!(
210                client.should_retry(status, 0, None).is_none(),
211                "expected None for {status}"
212            );
213        }
214    }
215
216    #[test]
217    fn test_should_retry_custom_config() {
218        let client = HttpClientBuilder::new("https://example.com")
219            .with_retry_config(RetryConfig {
220                max_retries: 1,
221                ..RetryConfig::default()
222            })
223            .build()
224            .unwrap();
225        assert!(client
226            .should_retry(StatusCode::TOO_MANY_REQUESTS, 0, None)
227            .is_some());
228        assert!(client
229            .should_retry(StatusCode::TOO_MANY_REQUESTS, 1, None)
230            .is_none());
231    }
232
233    #[test]
234    fn test_should_retry_uses_retry_after_header() {
235        let client = HttpClientBuilder::new("https://example.com")
236            .build()
237            .unwrap();
238        let d = client
239            .should_retry(StatusCode::TOO_MANY_REQUESTS, 0, Some("2"))
240            .unwrap();
241        assert_eq!(d, Duration::from_millis(2000));
242    }
243
244    #[test]
245    fn test_should_retry_retry_after_fractional_seconds() {
246        let client = HttpClientBuilder::new("https://example.com")
247            .build()
248            .unwrap();
249        let d = client
250            .should_retry(StatusCode::TOO_MANY_REQUESTS, 0, Some("0.5"))
251            .unwrap();
252        assert_eq!(d, Duration::from_millis(500));
253    }
254
255    #[test]
256    fn test_should_retry_retry_after_clamped_to_max_backoff() {
257        let client = HttpClientBuilder::new("https://example.com")
258            .build()
259            .unwrap();
260        // Default max_backoff_ms = 10_000; header says 60s
261        let d = client
262            .should_retry(StatusCode::TOO_MANY_REQUESTS, 0, Some("60"))
263            .unwrap();
264        assert_eq!(d, Duration::from_millis(10_000));
265    }
266
267    #[test]
268    fn test_should_retry_retry_after_invalid_falls_back() {
269        let client = HttpClientBuilder::new("https://example.com")
270            .build()
271            .unwrap();
272        // Non-numeric Retry-After (HTTP-date format) falls back to computed backoff
273        let d = client
274            .should_retry(
275                StatusCode::TOO_MANY_REQUESTS,
276                0,
277                Some("Wed, 21 Oct 2025 07:28:00 GMT"),
278            )
279            .unwrap();
280        // Should be in the jitter range for attempt 0: [375, 625]ms
281        let ms = d.as_millis() as u64;
282        assert!(
283            (375..=625).contains(&ms),
284            "expected fallback backoff in [375, 625], got {ms}"
285        );
286    }
287
288    // ── Builder wiring ───────────────────────────────────────────
289
290    #[tokio::test]
291    async fn test_builder_with_rate_limiter() {
292        let client = HttpClientBuilder::new("https://example.com")
293            .with_rate_limiter(RateLimiter::clob_default())
294            .build()
295            .unwrap();
296        let start = std::time::Instant::now();
297        client
298            .acquire_rate_limit("/order", Some(&reqwest::Method::POST))
299            .await;
300        assert!(start.elapsed() < Duration::from_millis(50));
301    }
302
303    #[tokio::test]
304    async fn test_builder_without_rate_limiter() {
305        let client = HttpClientBuilder::new("https://example.com")
306            .build()
307            .unwrap();
308        let start = std::time::Instant::now();
309        client
310            .acquire_rate_limit("/order", Some(&reqwest::Method::POST))
311            .await;
312        assert!(start.elapsed() < Duration::from_millis(10));
313    }
314}