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
11pub 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
21pub const DEFAULT_TIMEOUT_MS: u64 = 30_000;
23pub const DEFAULT_POOL_SIZE: usize = 10;
25
26#[derive(Debug, Clone)]
31pub struct HttpClient {
32 pub client: reqwest::Client,
34 pub base_url: Url,
36 rate_limiter: Option<RateLimiter>,
37 retry_config: RetryConfig,
38}
39
40impl HttpClient {
41 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 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
73pub 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 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 pub fn timeout_ms(mut self, timeout: u64) -> Self {
113 self.timeout_ms = timeout;
114 self
115 }
116
117 pub fn pool_size(mut self, size: usize) -> Self {
121 self.pool_size = size;
122 self
123 }
124
125 pub fn with_rate_limiter(mut self, limiter: RateLimiter) -> Self {
127 self.rate_limiter = Some(limiter);
128 self
129 }
130
131 pub fn with_retry_config(mut self, config: RetryConfig) -> Self {
133 self.retry_config = config;
134 self
135 }
136
137 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 #[test]
174 fn test_should_retry_429_under_max() {
175 let client = HttpClientBuilder::new("https://example.com")
176 .build()
177 .unwrap();
178 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 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 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 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 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 #[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}