1use std::sync::Arc;
2use std::time::Duration;
3
4use reqwest::Method;
5use tokio::sync::{OwnedSemaphorePermit, Semaphore};
6use url::Url;
7
8use reqwest::header::RETRY_AFTER;
9
10use crate::error::ApiError;
11use crate::hooks::{
12 DefaultRetryPolicy, DynRetryPolicy, DynThrottle, NoThrottle, RequestParts, RetryPolicy,
13 Throttle,
14};
15use crate::rate_limit::{RateLimiter, RetryConfig};
16
17pub fn retry_after_header(response: &reqwest::Response) -> Option<String> {
19 response
20 .headers()
21 .get(RETRY_AFTER)?
22 .to_str()
23 .ok()
24 .map(String::from)
25}
26
27pub const DEFAULT_TIMEOUT_MS: u64 = 30_000;
29pub const DEFAULT_POOL_SIZE: usize = 10;
31
32#[derive(Clone)]
38pub struct HttpClient {
39 pub client: reqwest::Client,
41 pub base_url: Url,
43 pub(crate) throttle: Arc<DynThrottle<'static>>,
44 pub(crate) policy: Arc<DynRetryPolicy<'static>>,
45 pub(crate) retry_config: RetryConfig,
46 concurrency_limiter: Option<Arc<Semaphore>>,
47}
48
49impl std::fmt::Debug for HttpClient {
52 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
53 f.debug_struct("HttpClient")
54 .field("client", &self.client)
55 .field("base_url", &self.base_url)
56 .field("retry_config", &self.retry_config)
57 .field("concurrency_limiter", &self.concurrency_limiter)
58 .finish_non_exhaustive()
59 }
60}
61
62impl HttpClient {
63 pub fn with_base_url(&self, base_url: &str) -> Result<Self, ApiError> {
91 Ok(Self {
92 base_url: Url::parse(base_url)?,
93 ..self.clone()
94 })
95 }
96
97 pub async fn acquire_concurrency(&self) -> Option<OwnedSemaphorePermit> {
109 let sem = self.concurrency_limiter.as_ref()?;
110 Some(
111 sem.clone()
112 .acquire_owned()
113 .await
114 .expect("concurrency semaphore is never closed"),
115 )
116 }
117
118 pub async fn get_bytes(
131 &self,
132 path: &str,
133 query: &[(String, String)],
134 ) -> Result<Vec<u8>, ApiError> {
135 let mut parts = RequestParts::new(Method::GET, path);
136 parts.query = query.to_vec();
137 let response = self.send(parts, &[], None).await?;
138
139 if !response.status().is_success() {
141 return Err(ApiError::from_response(response).await);
142 }
143
144 let bytes = response.bytes().await?;
145 Ok(bytes.to_vec())
146 }
147}
148
149pub struct HttpClientBuilder {
166 base_url: String,
167 timeout_ms: u64,
168 pool_size: usize,
169 throttle: Option<Arc<DynThrottle<'static>>>,
170 policy: Option<Arc<DynRetryPolicy<'static>>>,
171 retry_config: RetryConfig,
172 max_concurrent: Option<usize>,
173 gzip: Option<bool>,
174}
175
176impl HttpClientBuilder {
177 pub fn new(base_url: impl Into<String>) -> Self {
179 Self {
180 base_url: base_url.into(),
181 timeout_ms: DEFAULT_TIMEOUT_MS,
182 pool_size: DEFAULT_POOL_SIZE,
183 throttle: None,
184 policy: None,
185 retry_config: RetryConfig::default(),
186 max_concurrent: None,
187 gzip: None,
188 }
189 }
190
191 pub fn timeout_ms(mut self, timeout: u64) -> Self {
195 self.timeout_ms = timeout;
196 self
197 }
198
199 pub fn pool_size(mut self, size: usize) -> Self {
203 self.pool_size = size;
204 self
205 }
206
207 pub fn with_rate_limiter(self, limiter: RateLimiter) -> Self {
210 self.with_throttle(limiter)
211 }
212
213 pub fn with_throttle(mut self, throttle: impl Throttle + 'static) -> Self {
219 self.throttle = Some(DynThrottle::new_arc(throttle));
220 self
221 }
222
223 pub fn with_retry_policy(mut self, policy: impl RetryPolicy + 'static) -> Self {
228 self.policy = Some(DynRetryPolicy::new_arc(policy));
229 self
230 }
231
232 pub fn with_retry_config(mut self, config: RetryConfig) -> Self {
234 self.retry_config = config;
235 self
236 }
237
238 pub fn with_max_concurrent(mut self, max: usize) -> Self {
243 self.max_concurrent = Some(max);
244 self
245 }
246
247 pub fn gzip(mut self, enabled: bool) -> Self {
255 self.gzip = Some(enabled);
256 self
257 }
258
259 pub fn build(self) -> Result<HttpClient, ApiError> {
261 let mut builder = reqwest::Client::builder();
262 if let Some(enabled) = self.gzip {
263 builder = builder.gzip(enabled);
264 }
265 let client = builder
266 .timeout(Duration::from_millis(self.timeout_ms))
267 .connect_timeout(Duration::from_secs(10))
268 .redirect(reqwest::redirect::Policy::none())
269 .pool_max_idle_per_host(self.pool_size)
270 .build()?;
271
272 let base_url = Url::parse(&self.base_url)?;
273
274 Ok(HttpClient {
275 client,
276 base_url,
277 throttle: self
278 .throttle
279 .unwrap_or_else(|| DynThrottle::new_arc(NoThrottle)),
280 policy: self
281 .policy
282 .unwrap_or_else(|| DynRetryPolicy::new_arc(DefaultRetryPolicy)),
283 retry_config: self.retry_config,
284 concurrency_limiter: self.max_concurrent.map(|n| Arc::new(Semaphore::new(n))),
285 })
286 }
287}
288
289impl Default for HttpClientBuilder {
290 fn default() -> Self {
291 Self::new(String::new())
292 }
293}
294
295#[cfg(test)]
296mod tests {
297 use super::*;
298 use reqwest::StatusCode;
299
300 fn retries(config: &RetryConfig, status: StatusCode, attempt: u32) -> bool {
311 use crate::hooks::{Outcome, ResponseMeta};
312
313 let headers = reqwest::header::HeaderMap::new();
314 let response = ResponseMeta {
315 status,
316 headers: &headers,
317 };
318 matches!(
319 crate::polymarket::PolymarketRetryPolicy
320 .decide(&response, &config.attempt_info(attempt), config)
321 .outcome,
322 Outcome::Retry(_)
323 )
324 }
325
326 #[test]
327 fn test_should_retry_429_under_max() {
328 let config = RetryConfig::default();
329 assert!(retries(&config, StatusCode::TOO_MANY_REQUESTS, 0));
331 assert!(retries(&config, StatusCode::TOO_MANY_REQUESTS, 2));
332 }
333
334 #[test]
335 fn test_should_retry_429_at_max() {
336 let config = RetryConfig::default();
337 assert!(!retries(&config, StatusCode::TOO_MANY_REQUESTS, 3));
339 }
340
341 #[test]
342 fn test_should_retry_425_under_max() {
343 let config = RetryConfig::default();
344 assert!(retries(&config, StatusCode::TOO_EARLY, 0));
346 assert!(retries(&config, StatusCode::TOO_EARLY, 2));
347 }
348
349 #[test]
350 fn test_should_retry_425_at_max() {
351 let config = RetryConfig::default();
352 assert!(!retries(&config, StatusCode::TOO_EARLY, 3));
353 }
354
355 #[test]
356 fn test_should_retry_ignores_other_statuses() {
357 let config = RetryConfig::default();
358 for status in [
359 StatusCode::OK,
360 StatusCode::INTERNAL_SERVER_ERROR,
361 StatusCode::BAD_GATEWAY,
362 StatusCode::BAD_REQUEST,
363 StatusCode::FORBIDDEN,
364 StatusCode::NOT_FOUND,
365 StatusCode::SERVICE_UNAVAILABLE,
369 ] {
370 assert!(!retries(&config, status, 0), "{status} was retried");
371 }
372 }
373
374 #[test]
375 fn test_should_retry_5xx_not_retried_despite_being_is_retriable() {
376 let config = RetryConfig::default();
378 assert!(!retries(&config, StatusCode::INTERNAL_SERVER_ERROR, 0));
379 let err = ApiError::from(crate::error::ErrorResponse::new(
380 StatusCode::INTERNAL_SERVER_ERROR,
381 Default::default(),
382 "",
383 ));
384 assert!(polyoxide_venue::Classify::is_retriable(&err));
385 }
386
387 #[test]
388 fn test_should_retry_custom_config() {
389 let config = RetryConfig {
390 max_retries: 1,
391 ..RetryConfig::default()
392 };
393 assert!(retries(&config, StatusCode::TOO_MANY_REQUESTS, 0));
394 assert!(!retries(&config, StatusCode::TOO_MANY_REQUESTS, 1));
395 }
396
397 #[test]
398 fn test_should_retry_uses_retry_after_header() {
399 let config = RetryConfig::default();
400 let d = config.retry_delay(0, Some("2"));
401 assert_eq!(d, Duration::from_millis(2000));
402 }
403
404 #[test]
405 fn test_should_retry_retry_after_fractional_seconds() {
406 let config = RetryConfig::default();
407 let d = config.retry_delay(0, Some("1.5"));
412 assert_eq!(d, Duration::from_millis(1500));
413 }
414
415 #[test]
416 fn retry_after_below_our_own_backoff_does_not_shorten_the_wait() {
417 let config = RetryConfig::default();
418 for header in ["0", "0.0", "-1", "-30", "0.0001"] {
425 let d = config.retry_delay(0, Some(header));
426 assert!(
427 d >= Duration::from_millis(375),
428 "Retry-After: {header:?} produced a {d:?} sleep; the floor is the \
429 client's own attempt-0 backoff, >=375ms after jitter"
430 );
431 }
432 }
433
434 #[test]
435 fn retry_after_zero_still_backs_off_exponentially_across_attempts() {
436 let config = RetryConfig::default();
437 for (attempt, min_ms) in [(0u32, 375u64), (1, 750), (2, 1_500)] {
441 let d = config.retry_delay(attempt, Some("0"));
442 assert!(
443 d >= Duration::from_millis(min_ms),
444 "attempt {attempt} with Retry-After: 0 slept {d:?}, expected >={min_ms}ms"
445 );
446 }
447 }
448
449 #[test]
450 fn test_should_retry_retry_after_clamped_to_max_backoff() {
451 let config = RetryConfig::default();
452 let d = config.retry_delay(0, Some("60"));
454 assert_eq!(d, Duration::from_millis(10_000));
455 }
456
457 #[test]
458 fn test_should_retry_retry_after_invalid_falls_back() {
459 let config = RetryConfig::default();
460 let d = config.retry_delay(0, Some("Wed, 21 Oct 2025 07:28:00 GMT"));
462 let ms = d.as_millis() as u64;
464 assert!(
465 (375..=625).contains(&ms),
466 "expected fallback backoff in [375, 625], got {ms}"
467 );
468 }
469
470 #[derive(Default)]
474 struct SignedAt(std::sync::Mutex<Option<std::time::Instant>>);
475
476 impl crate::hooks::Authenticator for SignedAt {
477 async fn sign(&self, _parts: &mut RequestParts, _attempt: u32) -> Result<(), ApiError> {
478 *self.0.lock().unwrap() = Some(std::time::Instant::now());
479 Ok(())
480 }
481 }
482
483 async fn time_to_sign(builder: HttpClientBuilder, server: &mockito::ServerGuard) -> Duration {
486 let client = builder.build().unwrap();
487 let client = client.with_base_url(&server.url()).unwrap();
488 let signed = SignedAt::default();
489 let start = std::time::Instant::now();
490 client
491 .send(
492 RequestParts::new(Method::POST, "/order"),
493 &[],
494 Some(crate::hooks::DynAuthenticator::from_ref(&signed)),
495 )
496 .await
497 .unwrap();
498 let at = signed.0.lock().unwrap().expect("the request was signed");
499 at - start
500 }
501
502 #[tokio::test]
503 async fn test_builder_with_rate_limiter() {
504 let mut server = mockito::Server::new_async().await;
505 let _order = server.mock("POST", "/order").create_async().await;
506 let builder = HttpClientBuilder::new("https://example.com")
507 .with_rate_limiter(crate::polymarket::clob_limits());
508 let elapsed = time_to_sign(builder, &server).await;
509 assert!(elapsed < Duration::from_millis(50));
510 }
511
512 #[tokio::test]
513 async fn test_builder_without_rate_limiter() {
514 let mut server = mockito::Server::new_async().await;
515 let _order = server.mock("POST", "/order").create_async().await;
516 let builder = HttpClientBuilder::new("https://example.com");
517 let elapsed = time_to_sign(builder, &server).await;
518 assert!(elapsed < Duration::from_millis(10));
519 }
520
521 #[tokio::test]
524 async fn test_acquire_concurrency_none_when_not_configured() {
525 let client = HttpClientBuilder::new("https://example.com")
526 .build()
527 .unwrap();
528 assert!(client.acquire_concurrency().await.is_none());
529 }
530
531 #[tokio::test]
532 async fn test_acquire_concurrency_returns_permit() {
533 let client = HttpClientBuilder::new("https://example.com")
534 .with_max_concurrent(2)
535 .build()
536 .unwrap();
537 let permit = client.acquire_concurrency().await;
538 assert!(permit.is_some());
539 }
540
541 #[tokio::test]
542 async fn test_concurrency_shared_across_clones() {
543 let client = HttpClientBuilder::new("https://example.com")
544 .with_max_concurrent(1)
545 .build()
546 .unwrap();
547 let clone = client.clone();
548
549 let _permit = client.acquire_concurrency().await.unwrap();
551
552 let result =
554 tokio::time::timeout(Duration::from_millis(50), clone.acquire_concurrency()).await;
555 assert!(result.is_err(), "clone should block when permit is held");
556 }
557
558 #[tokio::test]
559 async fn test_concurrency_limits_parallel_tasks() {
560 let client = HttpClientBuilder::new("https://example.com")
561 .with_max_concurrent(2)
562 .build()
563 .unwrap();
564
565 let start = std::time::Instant::now();
566 let mut handles = Vec::new();
567 for _ in 0..4 {
568 let c = client.clone();
569 handles.push(tokio::spawn(async move {
570 let _permit = c.acquire_concurrency().await;
571 tokio::time::sleep(Duration::from_millis(50)).await;
572 }));
573 }
574 for h in handles {
575 h.await.unwrap();
576 }
577 assert!(
579 start.elapsed() >= Duration::from_millis(90),
580 "expected ~100ms, got {:?}",
581 start.elapsed()
582 );
583 }
584
585 #[tokio::test]
586 async fn test_builder_with_max_concurrent() {
587 let client = HttpClientBuilder::new("https://example.com")
588 .with_max_concurrent(5)
589 .build()
590 .unwrap();
591 let mut permits = Vec::new();
593 for _ in 0..5 {
594 permits.push(client.acquire_concurrency().await);
595 }
596 assert!(permits.iter().all(|p| p.is_some()));
597
598 let result =
600 tokio::time::timeout(Duration::from_millis(50), client.acquire_concurrency()).await;
601 assert!(result.is_err());
602 }
603
604 #[tokio::test]
607 async fn test_get_bytes_returns_body_verbatim() {
608 let mut server = mockito::Server::new_async().await;
609 let body: Vec<u8> = vec![0x50, 0x4B, 0x03, 0x04, 0x00, 0xFF, 0xFE, 0x42];
611 let mock = server
612 .mock("GET", "/v1/accounting/snapshot")
613 .match_query(mockito::Matcher::UrlEncoded("user".into(), "0xabc".into()))
614 .with_status(200)
615 .with_header("content-type", "application/zip")
616 .with_body(body.clone())
617 .create_async()
618 .await;
619
620 let client = HttpClientBuilder::new(server.url()).build().unwrap();
621 let out = client
622 .get_bytes(
623 "/v1/accounting/snapshot",
624 &[("user".to_string(), "0xabc".to_string())],
625 )
626 .await
627 .unwrap();
628 assert_eq!(out, body);
629 mock.assert_async().await;
630 }
631
632 #[tokio::test]
633 async fn test_get_bytes_maps_non_2xx_to_api_error() {
634 let mut server = mockito::Server::new_async().await;
635 let mock = server
636 .mock("GET", "/does-not-exist")
637 .with_status(404)
638 .with_header("content-type", "application/json")
639 .with_body(r#"{"error": "not found"}"#)
640 .create_async()
641 .await;
642
643 let client = HttpClientBuilder::new(server.url()).build().unwrap();
644 let err = client.get_bytes("/does-not-exist", &[]).await.unwrap_err();
645 match err {
646 ApiError::Response(response) => {
647 assert_eq!(response.status, StatusCode::NOT_FOUND);
648 assert_eq!(response.message, "not found");
649 }
650 other => panic!("expected ApiError::Response, got {other:?}"),
651 }
652 mock.assert_async().await;
653 }
654
655 #[tokio::test]
656 async fn test_get_bytes_no_query_params() {
657 let mut server = mockito::Server::new_async().await;
658 let mock = server
659 .mock("GET", "/raw")
660 .with_status(200)
661 .with_body(&b"hello"[..])
662 .create_async()
663 .await;
664
665 let client = HttpClientBuilder::new(server.url()).build().unwrap();
666 let out = client.get_bytes("/raw", &[]).await.unwrap();
667 assert_eq!(out, b"hello");
668 mock.assert_async().await;
669 }
670
671 #[tokio::test]
674 async fn gzip_follows_reqwest_unless_set() {
675 let mut server = mockito::Server::new_async().await;
679 let mock = server
680 .mock("GET", "/plain")
681 .match_header("accept-encoding", mockito::Matcher::Regex("gzip".into()))
682 .with_body("ok")
683 .create_async()
684 .await;
685
686 let client = HttpClientBuilder::new(server.url()).build().unwrap();
687 assert_eq!(client.get_bytes("/plain", &[]).await.unwrap(), b"ok");
688 mock.assert_async().await;
689 }
690
691 #[tokio::test]
692 async fn gzip_false_asks_for_no_compression() {
693 let mut server = mockito::Server::new_async().await;
694 let mock = server
695 .mock("GET", "/plain")
696 .match_header("accept-encoding", mockito::Matcher::Missing)
697 .with_body("ok")
698 .create_async()
699 .await;
700
701 let client = HttpClientBuilder::new(server.url())
702 .gzip(false)
703 .build()
704 .unwrap();
705 assert_eq!(client.get_bytes("/plain", &[]).await.unwrap(), b"ok");
706 mock.assert_async().await;
707 }
708
709 #[tokio::test]
710 async fn gzip_asks_for_and_decodes_a_compressed_body() {
711 const GZIPPED: &[u8] = &[
713 0x1f, 0x8b, 0x08, 0x00, 0x00, 0x00, 0x00, 0x00, 0x02, 0xff, 0xab, 0x56, 0x2a, 0x4e,
714 0x2d, 0x2a, 0x4b, 0x2d, 0x0a, 0xc9, 0xcc, 0x4d, 0x55, 0xb2, 0x32, 0xac, 0x05, 0x00,
715 0xe2, 0x1d, 0x3e, 0x1a, 0x10, 0x00, 0x00, 0x00,
716 ];
717 let mut server = mockito::Server::new_async().await;
718 let mock = server
719 .mock("GET", "/time")
720 .match_header("accept-encoding", mockito::Matcher::Regex("gzip".into()))
721 .with_header("content-encoding", "gzip")
722 .with_body(GZIPPED)
723 .create_async()
724 .await;
725
726 let client = HttpClientBuilder::new(server.url())
727 .gzip(true)
728 .build()
729 .unwrap();
730 let body = client.get_bytes("/time", &[]).await.unwrap();
731 assert_eq!(body, br#"{"serverTime":1}"#);
732 mock.assert_async().await;
733 }
734
735 #[test]
736 fn with_base_url_retargets_and_shares_transport() {
737 let client = HttpClientBuilder::new("https://data-api.polymarket.com")
738 .with_max_concurrent(4)
739 .build()
740 .unwrap();
741 let sibling = client
742 .with_base_url("https://user-pnl-api.polymarket.com")
743 .unwrap();
744
745 assert_eq!(
746 sibling.base_url.host_str(),
747 Some("user-pnl-api.polymarket.com")
748 );
749 assert_eq!(client.base_url.host_str(), Some("data-api.polymarket.com"));
751 assert!(sibling.concurrency_limiter.is_some());
754 }
755
756 #[test]
757 fn with_base_url_rejects_a_malformed_url() {
758 let client = HttpClientBuilder::new("https://data-api.polymarket.com")
759 .build()
760 .unwrap();
761 assert!(client.with_base_url("not-a-url").is_err());
762 }
763
764 #[test]
765 fn base_url_path_prefixes_are_dropped() {
766 let client = HttpClientBuilder::new("http://localhost:8080/proxy")
770 .build()
771 .unwrap();
772 assert_eq!(
773 client.base_url.join("/user-pnl").unwrap().as_str(),
774 "http://localhost:8080/user-pnl",
775 "a path prefix in the base URL is not preserved"
776 );
777
778 let with_slash = client
779 .with_base_url("http://localhost:8080/proxy/")
780 .unwrap();
781 assert_eq!(
782 with_slash.base_url.join("/user-pnl").unwrap().as_str(),
783 "http://localhost:8080/user-pnl",
784 "a trailing slash does not preserve the prefix either"
785 );
786 }
787}