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
13pub 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
23pub const DEFAULT_TIMEOUT_MS: u64 = 30_000;
25pub const DEFAULT_POOL_SIZE: usize = 10;
27
28#[derive(Debug, Clone)]
33pub struct HttpClient {
34 pub client: reqwest::Client,
36 pub base_url: Url,
38 rate_limiter: Option<RateLimiter>,
39 retry_config: RetryConfig,
40 concurrency_limiter: Option<Arc<Semaphore>>,
41}
42
43impl HttpClient {
44 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 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 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 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 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
180pub 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 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 pub fn timeout_ms(mut self, timeout: u64) -> Self {
222 self.timeout_ms = timeout;
223 self
224 }
225
226 pub fn pool_size(mut self, size: usize) -> Self {
230 self.pool_size = size;
231 self
232 }
233
234 pub fn with_rate_limiter(mut self, limiter: RateLimiter) -> Self {
236 self.rate_limiter = Some(limiter);
237 self
238 }
239
240 pub fn with_retry_config(mut self, config: RetryConfig) -> Self {
242 self.retry_config = config;
243 self
244 }
245
246 pub fn with_max_concurrent(mut self, max: usize) -> Self {
251 self.max_concurrent = Some(max);
252 self
253 }
254
255 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 #[test]
296 fn test_should_retry_429_under_max() {
297 let client = HttpClientBuilder::new("https://example.com")
298 .build()
299 .unwrap();
300 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 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 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 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 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 #[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 #[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 let _permit = client.acquire_concurrency().await.unwrap();
467
468 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 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 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 let result =
516 tokio::time::timeout(Duration::from_millis(50), client.acquire_concurrency()).await;
517 assert!(result.is_err());
518 }
519
520 #[tokio::test]
523 async fn test_get_bytes_returns_body_verbatim() {
524 let mut server = mockito::Server::new_async().await;
525 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 assert_eq!(client.base_url.host_str(), Some("data-api.polymarket.com"));
603 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 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}