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(
120 &self,
121 status: StatusCode,
122 attempt: u32,
123 retry_after: Option<&str>,
124 ) -> Option<Duration> {
125 let retriable = status == StatusCode::TOO_MANY_REQUESTS || status == StatusCode::TOO_EARLY;
126 if retriable && attempt < self.retry_config.max_retries {
127 if let Some(delay) = retry_after.and_then(|v| v.parse::<f64>().ok()) {
128 let ms = (delay * 1000.0) as u64;
129 Some(Duration::from_millis(
130 ms.min(self.retry_config.max_backoff_ms),
131 ))
132 } else {
133 Some(self.retry_config.backoff(attempt))
134 }
135 } else {
136 None
137 }
138 }
139
140 pub async fn get_bytes(
155 &self,
156 path: &str,
157 query: &[(String, String)],
158 ) -> Result<Vec<u8>, ApiError> {
159 let url = self.base_url.join(path)?;
160 let mut attempt = 0u32;
161
162 loop {
163 let _permit = self.acquire_concurrency().await;
164 self.acquire_rate_limit(path, None).await;
165
166 let mut request = self.client.get(url.clone());
167 if !query.is_empty() {
168 request = request.query(query);
169 }
170
171 let response = request.send().await?;
172 let status = response.status();
173 let retry_after = retry_after_header(&response);
174
175 if let Some(backoff) = self.should_retry(status, attempt, retry_after.as_deref()) {
176 attempt += 1;
177 tracing::warn!(
178 "Retriable status {} on {}, retry {} after {}ms",
179 status,
180 path,
181 attempt,
182 backoff.as_millis()
183 );
184 drop(_permit);
185 tokio::time::sleep(backoff).await;
186 continue;
187 }
188
189 if !status.is_success() {
190 return Err(ApiError::from_response(response).await);
191 }
192
193 let bytes = response.bytes().await?;
194 return Ok(bytes.to_vec());
195 }
196 }
197}
198
199pub struct HttpClientBuilder {
216 base_url: String,
217 timeout_ms: u64,
218 pool_size: usize,
219 rate_limiter: Option<RateLimiter>,
220 retry_config: RetryConfig,
221 max_concurrent: Option<usize>,
222}
223
224impl HttpClientBuilder {
225 pub fn new(base_url: impl Into<String>) -> Self {
227 Self {
228 base_url: base_url.into(),
229 timeout_ms: DEFAULT_TIMEOUT_MS,
230 pool_size: DEFAULT_POOL_SIZE,
231 rate_limiter: None,
232 retry_config: RetryConfig::default(),
233 max_concurrent: None,
234 }
235 }
236
237 pub fn timeout_ms(mut self, timeout: u64) -> Self {
241 self.timeout_ms = timeout;
242 self
243 }
244
245 pub fn pool_size(mut self, size: usize) -> Self {
249 self.pool_size = size;
250 self
251 }
252
253 pub fn with_rate_limiter(mut self, limiter: RateLimiter) -> Self {
255 self.rate_limiter = Some(limiter);
256 self
257 }
258
259 pub fn with_retry_config(mut self, config: RetryConfig) -> Self {
261 self.retry_config = config;
262 self
263 }
264
265 pub fn with_max_concurrent(mut self, max: usize) -> Self {
270 self.max_concurrent = Some(max);
271 self
272 }
273
274 pub fn build(self) -> Result<HttpClient, ApiError> {
276 let client = reqwest::Client::builder()
277 .timeout(Duration::from_millis(self.timeout_ms))
278 .connect_timeout(Duration::from_secs(10))
279 .redirect(reqwest::redirect::Policy::none())
280 .pool_max_idle_per_host(self.pool_size)
281 .build()?;
282
283 let base_url = Url::parse(&self.base_url)?;
284
285 Ok(HttpClient {
286 client,
287 base_url,
288 rate_limiter: self.rate_limiter,
289 retry_config: self.retry_config,
290 concurrency_limiter: self.max_concurrent.map(|n| Arc::new(Semaphore::new(n))),
291 })
292 }
293}
294
295impl Default for HttpClientBuilder {
296 fn default() -> Self {
297 Self {
298 base_url: String::new(),
299 timeout_ms: DEFAULT_TIMEOUT_MS,
300 pool_size: DEFAULT_POOL_SIZE,
301 rate_limiter: None,
302 retry_config: RetryConfig::default(),
303 max_concurrent: None,
304 }
305 }
306}
307
308#[cfg(test)]
309mod tests {
310 use super::*;
311
312 #[test]
315 fn test_should_retry_429_under_max() {
316 let client = HttpClientBuilder::new("https://example.com")
317 .build()
318 .unwrap();
319 assert!(client
321 .should_retry(StatusCode::TOO_MANY_REQUESTS, 0, None)
322 .is_some());
323 assert!(client
324 .should_retry(StatusCode::TOO_MANY_REQUESTS, 2, None)
325 .is_some());
326 }
327
328 #[test]
329 fn test_should_retry_429_at_max() {
330 let client = HttpClientBuilder::new("https://example.com")
331 .build()
332 .unwrap();
333 assert!(client
335 .should_retry(StatusCode::TOO_MANY_REQUESTS, 3, None)
336 .is_none());
337 }
338
339 #[test]
340 fn test_should_retry_425_under_max() {
341 let client = HttpClientBuilder::new("https://example.com")
342 .build()
343 .unwrap();
344 assert!(client
346 .should_retry(StatusCode::TOO_EARLY, 0, None)
347 .is_some());
348 assert!(client
349 .should_retry(StatusCode::TOO_EARLY, 2, None)
350 .is_some());
351 }
352
353 #[test]
354 fn test_should_retry_425_at_max() {
355 let client = HttpClientBuilder::new("https://example.com")
356 .build()
357 .unwrap();
358 assert!(client
359 .should_retry(StatusCode::TOO_EARLY, 3, None)
360 .is_none());
361 }
362
363 #[test]
364 fn test_should_retry_ignores_other_statuses() {
365 let client = HttpClientBuilder::new("https://example.com")
366 .build()
367 .unwrap();
368 for status in [
369 StatusCode::OK,
370 StatusCode::INTERNAL_SERVER_ERROR,
371 StatusCode::BAD_GATEWAY,
372 StatusCode::BAD_REQUEST,
373 StatusCode::FORBIDDEN,
374 StatusCode::NOT_FOUND,
375 StatusCode::SERVICE_UNAVAILABLE,
379 ] {
380 assert!(
381 client.should_retry(status, 0, None).is_none(),
382 "expected None for {status}"
383 );
384 }
385 }
386
387 #[test]
388 fn test_should_retry_5xx_not_retried_despite_being_is_retriable() {
389 let client = HttpClientBuilder::new("https://example.com")
392 .build()
393 .unwrap();
394 assert!(client
395 .should_retry(StatusCode::INTERNAL_SERVER_ERROR, 0, None)
396 .is_none());
397 assert!(ApiError::Api {
398 status: 500,
399 message: String::new()
400 }
401 .is_retriable());
402 }
403
404 #[test]
405 fn test_should_retry_custom_config() {
406 let client = HttpClientBuilder::new("https://example.com")
407 .with_retry_config(RetryConfig {
408 max_retries: 1,
409 ..RetryConfig::default()
410 })
411 .build()
412 .unwrap();
413 assert!(client
414 .should_retry(StatusCode::TOO_MANY_REQUESTS, 0, None)
415 .is_some());
416 assert!(client
417 .should_retry(StatusCode::TOO_MANY_REQUESTS, 1, None)
418 .is_none());
419 }
420
421 #[test]
422 fn test_should_retry_uses_retry_after_header() {
423 let client = HttpClientBuilder::new("https://example.com")
424 .build()
425 .unwrap();
426 let d = client
427 .should_retry(StatusCode::TOO_MANY_REQUESTS, 0, Some("2"))
428 .unwrap();
429 assert_eq!(d, Duration::from_millis(2000));
430 }
431
432 #[test]
433 fn test_should_retry_retry_after_fractional_seconds() {
434 let client = HttpClientBuilder::new("https://example.com")
435 .build()
436 .unwrap();
437 let d = client
438 .should_retry(StatusCode::TOO_MANY_REQUESTS, 0, Some("0.5"))
439 .unwrap();
440 assert_eq!(d, Duration::from_millis(500));
441 }
442
443 #[test]
444 fn test_should_retry_retry_after_clamped_to_max_backoff() {
445 let client = HttpClientBuilder::new("https://example.com")
446 .build()
447 .unwrap();
448 let d = client
450 .should_retry(StatusCode::TOO_MANY_REQUESTS, 0, Some("60"))
451 .unwrap();
452 assert_eq!(d, Duration::from_millis(10_000));
453 }
454
455 #[test]
456 fn test_should_retry_retry_after_invalid_falls_back() {
457 let client = HttpClientBuilder::new("https://example.com")
458 .build()
459 .unwrap();
460 let d = client
462 .should_retry(
463 StatusCode::TOO_MANY_REQUESTS,
464 0,
465 Some("Wed, 21 Oct 2025 07:28:00 GMT"),
466 )
467 .unwrap();
468 let ms = d.as_millis() as u64;
470 assert!(
471 (375..=625).contains(&ms),
472 "expected fallback backoff in [375, 625], got {ms}"
473 );
474 }
475
476 #[tokio::test]
479 async fn test_builder_with_rate_limiter() {
480 let client = HttpClientBuilder::new("https://example.com")
481 .with_rate_limiter(RateLimiter::clob_default())
482 .build()
483 .unwrap();
484 let start = std::time::Instant::now();
485 client
486 .acquire_rate_limit("/order", Some(&reqwest::Method::POST))
487 .await;
488 assert!(start.elapsed() < Duration::from_millis(50));
489 }
490
491 #[tokio::test]
492 async fn test_builder_without_rate_limiter() {
493 let client = HttpClientBuilder::new("https://example.com")
494 .build()
495 .unwrap();
496 let start = std::time::Instant::now();
497 client
498 .acquire_rate_limit("/order", Some(&reqwest::Method::POST))
499 .await;
500 assert!(start.elapsed() < Duration::from_millis(10));
501 }
502
503 #[tokio::test]
506 async fn test_acquire_concurrency_none_when_not_configured() {
507 let client = HttpClientBuilder::new("https://example.com")
508 .build()
509 .unwrap();
510 assert!(client.acquire_concurrency().await.is_none());
511 }
512
513 #[tokio::test]
514 async fn test_acquire_concurrency_returns_permit() {
515 let client = HttpClientBuilder::new("https://example.com")
516 .with_max_concurrent(2)
517 .build()
518 .unwrap();
519 let permit = client.acquire_concurrency().await;
520 assert!(permit.is_some());
521 }
522
523 #[tokio::test]
524 async fn test_concurrency_shared_across_clones() {
525 let client = HttpClientBuilder::new("https://example.com")
526 .with_max_concurrent(1)
527 .build()
528 .unwrap();
529 let clone = client.clone();
530
531 let _permit = client.acquire_concurrency().await.unwrap();
533
534 let result =
536 tokio::time::timeout(Duration::from_millis(50), clone.acquire_concurrency()).await;
537 assert!(result.is_err(), "clone should block when permit is held");
538 }
539
540 #[tokio::test]
541 async fn test_concurrency_limits_parallel_tasks() {
542 let client = HttpClientBuilder::new("https://example.com")
543 .with_max_concurrent(2)
544 .build()
545 .unwrap();
546
547 let start = std::time::Instant::now();
548 let mut handles = Vec::new();
549 for _ in 0..4 {
550 let c = client.clone();
551 handles.push(tokio::spawn(async move {
552 let _permit = c.acquire_concurrency().await;
553 tokio::time::sleep(Duration::from_millis(50)).await;
554 }));
555 }
556 for h in handles {
557 h.await.unwrap();
558 }
559 assert!(
561 start.elapsed() >= Duration::from_millis(90),
562 "expected ~100ms, got {:?}",
563 start.elapsed()
564 );
565 }
566
567 #[tokio::test]
568 async fn test_builder_with_max_concurrent() {
569 let client = HttpClientBuilder::new("https://example.com")
570 .with_max_concurrent(5)
571 .build()
572 .unwrap();
573 let mut permits = Vec::new();
575 for _ in 0..5 {
576 permits.push(client.acquire_concurrency().await);
577 }
578 assert!(permits.iter().all(|p| p.is_some()));
579
580 let result =
582 tokio::time::timeout(Duration::from_millis(50), client.acquire_concurrency()).await;
583 assert!(result.is_err());
584 }
585
586 #[tokio::test]
589 async fn test_get_bytes_returns_body_verbatim() {
590 let mut server = mockito::Server::new_async().await;
591 let body: Vec<u8> = vec![0x50, 0x4B, 0x03, 0x04, 0x00, 0xFF, 0xFE, 0x42];
593 let mock = server
594 .mock("GET", "/v1/accounting/snapshot")
595 .match_query(mockito::Matcher::UrlEncoded("user".into(), "0xabc".into()))
596 .with_status(200)
597 .with_header("content-type", "application/zip")
598 .with_body(body.clone())
599 .create_async()
600 .await;
601
602 let client = HttpClientBuilder::new(server.url()).build().unwrap();
603 let out = client
604 .get_bytes(
605 "/v1/accounting/snapshot",
606 &[("user".to_string(), "0xabc".to_string())],
607 )
608 .await
609 .unwrap();
610 assert_eq!(out, body);
611 mock.assert_async().await;
612 }
613
614 #[tokio::test]
615 async fn test_get_bytes_maps_non_2xx_to_api_error() {
616 let mut server = mockito::Server::new_async().await;
617 let mock = server
618 .mock("GET", "/does-not-exist")
619 .with_status(404)
620 .with_header("content-type", "application/json")
621 .with_body(r#"{"error": "not found"}"#)
622 .create_async()
623 .await;
624
625 let client = HttpClientBuilder::new(server.url()).build().unwrap();
626 let err = client.get_bytes("/does-not-exist", &[]).await.unwrap_err();
627 match err {
628 ApiError::Api { status, message } => {
629 assert_eq!(status, 404);
630 assert_eq!(message, "not found");
631 }
632 other => panic!("expected ApiError::Api, got {other:?}"),
633 }
634 mock.assert_async().await;
635 }
636
637 #[tokio::test]
638 async fn test_get_bytes_no_query_params() {
639 let mut server = mockito::Server::new_async().await;
640 let mock = server
641 .mock("GET", "/raw")
642 .with_status(200)
643 .with_body(&b"hello"[..])
644 .create_async()
645 .await;
646
647 let client = HttpClientBuilder::new(server.url()).build().unwrap();
648 let out = client.get_bytes("/raw", &[]).await.unwrap();
649 assert_eq!(out, b"hello");
650 mock.assert_async().await;
651 }
652
653 #[test]
654 fn with_base_url_retargets_and_shares_transport() {
655 let client = HttpClientBuilder::new("https://data-api.polymarket.com")
656 .with_max_concurrent(4)
657 .build()
658 .unwrap();
659 let sibling = client
660 .with_base_url("https://user-pnl-api.polymarket.com")
661 .unwrap();
662
663 assert_eq!(
664 sibling.base_url.host_str(),
665 Some("user-pnl-api.polymarket.com")
666 );
667 assert_eq!(client.base_url.host_str(), Some("data-api.polymarket.com"));
669 assert!(sibling.concurrency_limiter.is_some());
672 }
673
674 #[test]
675 fn with_base_url_rejects_a_malformed_url() {
676 let client = HttpClientBuilder::new("https://data-api.polymarket.com")
677 .build()
678 .unwrap();
679 assert!(client.with_base_url("not-a-url").is_err());
680 }
681
682 #[test]
683 fn base_url_path_prefixes_are_dropped() {
684 let client = HttpClientBuilder::new("http://localhost:8080/proxy")
688 .build()
689 .unwrap();
690 assert_eq!(
691 client.base_url.join("/user-pnl").unwrap().as_str(),
692 "http://localhost:8080/user-pnl",
693 "a path prefix in the base URL is not preserved"
694 );
695
696 let with_slash = client
697 .with_base_url("http://localhost:8080/proxy/")
698 .unwrap();
699 assert_eq!(
700 with_slash.base_url.join("/user-pnl").unwrap().as_str(),
701 "http://localhost:8080/user-pnl",
702 "a trailing slash does not preserve the prefix either"
703 );
704 }
705}