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 #[test]
301 fn test_should_retry_uses_retry_after_header() {
302 let config = RetryConfig::default();
303 let d = config.retry_delay(0, Some("2"));
304 assert_eq!(d, Duration::from_millis(2000));
305 }
306
307 #[test]
308 fn test_should_retry_retry_after_fractional_seconds() {
309 let config = RetryConfig::default();
310 let d = config.retry_delay(0, Some("1.5"));
315 assert_eq!(d, Duration::from_millis(1500));
316 }
317
318 #[test]
319 fn retry_after_below_our_own_backoff_does_not_shorten_the_wait() {
320 let config = RetryConfig::default();
321 for header in ["0", "0.0", "-1", "-30", "0.0001"] {
328 let d = config.retry_delay(0, Some(header));
329 assert!(
330 d >= Duration::from_millis(375),
331 "Retry-After: {header:?} produced a {d:?} sleep; the floor is the \
332 client's own attempt-0 backoff, >=375ms after jitter"
333 );
334 }
335 }
336
337 #[test]
338 fn retry_after_zero_still_backs_off_exponentially_across_attempts() {
339 let config = RetryConfig::default();
340 for (attempt, min_ms) in [(0u32, 375u64), (1, 750), (2, 1_500)] {
344 let d = config.retry_delay(attempt, Some("0"));
345 assert!(
346 d >= Duration::from_millis(min_ms),
347 "attempt {attempt} with Retry-After: 0 slept {d:?}, expected >={min_ms}ms"
348 );
349 }
350 }
351
352 #[test]
353 fn test_should_retry_retry_after_clamped_to_max_backoff() {
354 let config = RetryConfig::default();
355 let d = config.retry_delay(0, Some("60"));
357 assert_eq!(d, Duration::from_millis(10_000));
358 }
359
360 #[test]
361 fn test_should_retry_retry_after_invalid_falls_back() {
362 let config = RetryConfig::default();
363 let d = config.retry_delay(0, Some("Wed, 21 Oct 2025 07:28:00 GMT"));
365 let ms = d.as_millis() as u64;
367 assert!(
368 (375..=625).contains(&ms),
369 "expected fallback backoff in [375, 625], got {ms}"
370 );
371 }
372
373 #[derive(Default)]
377 struct SignedAt(std::sync::Mutex<Option<std::time::Instant>>);
378
379 impl crate::hooks::Authenticator for SignedAt {
380 async fn sign(&self, _parts: &mut RequestParts, _attempt: u32) -> Result<(), ApiError> {
381 *self.0.lock().unwrap() = Some(std::time::Instant::now());
382 Ok(())
383 }
384 }
385
386 async fn time_to_sign(builder: HttpClientBuilder, server: &mockito::ServerGuard) -> Duration {
389 let client = builder.build().unwrap();
390 let client = client.with_base_url(&server.url()).unwrap();
391 let signed = SignedAt::default();
392 let start = std::time::Instant::now();
393 client
394 .send(
395 RequestParts::new(Method::POST, "/order"),
396 &[],
397 Some(crate::hooks::DynAuthenticator::from_ref(&signed)),
398 )
399 .await
400 .unwrap();
401 let at = signed.0.lock().unwrap().expect("the request was signed");
402 at - start
403 }
404
405 #[tokio::test]
406 async fn test_builder_without_rate_limiter() {
407 let mut server = mockito::Server::new_async().await;
408 let _order = server.mock("POST", "/order").create_async().await;
409 let builder = HttpClientBuilder::new("https://example.com");
410 let elapsed = time_to_sign(builder, &server).await;
411 assert!(elapsed < Duration::from_millis(10));
412 }
413
414 #[tokio::test]
417 async fn test_acquire_concurrency_none_when_not_configured() {
418 let client = HttpClientBuilder::new("https://example.com")
419 .build()
420 .unwrap();
421 assert!(client.acquire_concurrency().await.is_none());
422 }
423
424 #[tokio::test]
425 async fn test_acquire_concurrency_returns_permit() {
426 let client = HttpClientBuilder::new("https://example.com")
427 .with_max_concurrent(2)
428 .build()
429 .unwrap();
430 let permit = client.acquire_concurrency().await;
431 assert!(permit.is_some());
432 }
433
434 #[tokio::test]
435 async fn test_concurrency_shared_across_clones() {
436 let client = HttpClientBuilder::new("https://example.com")
437 .with_max_concurrent(1)
438 .build()
439 .unwrap();
440 let clone = client.clone();
441
442 let _permit = client.acquire_concurrency().await.unwrap();
444
445 let result =
447 tokio::time::timeout(Duration::from_millis(50), clone.acquire_concurrency()).await;
448 assert!(result.is_err(), "clone should block when permit is held");
449 }
450
451 #[tokio::test]
452 async fn test_concurrency_limits_parallel_tasks() {
453 let client = HttpClientBuilder::new("https://example.com")
454 .with_max_concurrent(2)
455 .build()
456 .unwrap();
457
458 let start = std::time::Instant::now();
459 let mut handles = Vec::new();
460 for _ in 0..4 {
461 let c = client.clone();
462 handles.push(tokio::spawn(async move {
463 let _permit = c.acquire_concurrency().await;
464 tokio::time::sleep(Duration::from_millis(50)).await;
465 }));
466 }
467 for h in handles {
468 h.await.unwrap();
469 }
470 assert!(
472 start.elapsed() >= Duration::from_millis(90),
473 "expected ~100ms, got {:?}",
474 start.elapsed()
475 );
476 }
477
478 #[tokio::test]
479 async fn test_builder_with_max_concurrent() {
480 let client = HttpClientBuilder::new("https://example.com")
481 .with_max_concurrent(5)
482 .build()
483 .unwrap();
484 let mut permits = Vec::new();
486 for _ in 0..5 {
487 permits.push(client.acquire_concurrency().await);
488 }
489 assert!(permits.iter().all(|p| p.is_some()));
490
491 let result =
493 tokio::time::timeout(Duration::from_millis(50), client.acquire_concurrency()).await;
494 assert!(result.is_err());
495 }
496
497 #[tokio::test]
500 async fn test_get_bytes_returns_body_verbatim() {
501 let mut server = mockito::Server::new_async().await;
502 let body: Vec<u8> = vec![0x50, 0x4B, 0x03, 0x04, 0x00, 0xFF, 0xFE, 0x42];
504 let mock = server
505 .mock("GET", "/v1/accounting/snapshot")
506 .match_query(mockito::Matcher::UrlEncoded("user".into(), "0xabc".into()))
507 .with_status(200)
508 .with_header("content-type", "application/zip")
509 .with_body(body.clone())
510 .create_async()
511 .await;
512
513 let client = HttpClientBuilder::new(server.url()).build().unwrap();
514 let out = client
515 .get_bytes(
516 "/v1/accounting/snapshot",
517 &[("user".to_string(), "0xabc".to_string())],
518 )
519 .await
520 .unwrap();
521 assert_eq!(out, body);
522 mock.assert_async().await;
523 }
524
525 #[tokio::test]
526 async fn test_get_bytes_maps_non_2xx_to_api_error() {
527 let mut server = mockito::Server::new_async().await;
528 let mock = server
529 .mock("GET", "/does-not-exist")
530 .with_status(404)
531 .with_header("content-type", "application/json")
532 .with_body(r#"{"error": "not found"}"#)
533 .create_async()
534 .await;
535
536 let client = HttpClientBuilder::new(server.url()).build().unwrap();
537 let err = client.get_bytes("/does-not-exist", &[]).await.unwrap_err();
538 match err {
539 ApiError::Response(response) => {
540 assert_eq!(response.status, StatusCode::NOT_FOUND);
541 assert_eq!(response.message, "not found");
542 }
543 other => panic!("expected ApiError::Response, got {other:?}"),
544 }
545 mock.assert_async().await;
546 }
547
548 #[tokio::test]
549 async fn test_get_bytes_no_query_params() {
550 let mut server = mockito::Server::new_async().await;
551 let mock = server
552 .mock("GET", "/raw")
553 .with_status(200)
554 .with_body(&b"hello"[..])
555 .create_async()
556 .await;
557
558 let client = HttpClientBuilder::new(server.url()).build().unwrap();
559 let out = client.get_bytes("/raw", &[]).await.unwrap();
560 assert_eq!(out, b"hello");
561 mock.assert_async().await;
562 }
563
564 #[tokio::test]
567 async fn gzip_follows_reqwest_unless_set() {
568 let mut server = mockito::Server::new_async().await;
572 let mock = server
573 .mock("GET", "/plain")
574 .match_header("accept-encoding", mockito::Matcher::Regex("gzip".into()))
575 .with_body("ok")
576 .create_async()
577 .await;
578
579 let client = HttpClientBuilder::new(server.url()).build().unwrap();
580 assert_eq!(client.get_bytes("/plain", &[]).await.unwrap(), b"ok");
581 mock.assert_async().await;
582 }
583
584 #[tokio::test]
585 async fn gzip_false_asks_for_no_compression() {
586 let mut server = mockito::Server::new_async().await;
587 let mock = server
588 .mock("GET", "/plain")
589 .match_header("accept-encoding", mockito::Matcher::Missing)
590 .with_body("ok")
591 .create_async()
592 .await;
593
594 let client = HttpClientBuilder::new(server.url())
595 .gzip(false)
596 .build()
597 .unwrap();
598 assert_eq!(client.get_bytes("/plain", &[]).await.unwrap(), b"ok");
599 mock.assert_async().await;
600 }
601
602 #[tokio::test]
603 async fn gzip_asks_for_and_decodes_a_compressed_body() {
604 const GZIPPED: &[u8] = &[
606 0x1f, 0x8b, 0x08, 0x00, 0x00, 0x00, 0x00, 0x00, 0x02, 0xff, 0xab, 0x56, 0x2a, 0x4e,
607 0x2d, 0x2a, 0x4b, 0x2d, 0x0a, 0xc9, 0xcc, 0x4d, 0x55, 0xb2, 0x32, 0xac, 0x05, 0x00,
608 0xe2, 0x1d, 0x3e, 0x1a, 0x10, 0x00, 0x00, 0x00,
609 ];
610 let mut server = mockito::Server::new_async().await;
611 let mock = server
612 .mock("GET", "/time")
613 .match_header("accept-encoding", mockito::Matcher::Regex("gzip".into()))
614 .with_header("content-encoding", "gzip")
615 .with_body(GZIPPED)
616 .create_async()
617 .await;
618
619 let client = HttpClientBuilder::new(server.url())
620 .gzip(true)
621 .build()
622 .unwrap();
623 let body = client.get_bytes("/time", &[]).await.unwrap();
624 assert_eq!(body, br#"{"serverTime":1}"#);
625 mock.assert_async().await;
626 }
627
628 #[test]
629 fn with_base_url_retargets_and_shares_transport() {
630 let client = HttpClientBuilder::new("https://api.example.com")
631 .with_max_concurrent(4)
632 .build()
633 .unwrap();
634 let sibling = client.with_base_url("https://sibling.example.com").unwrap();
635
636 assert_eq!(sibling.base_url.host_str(), Some("sibling.example.com"));
637 assert_eq!(client.base_url.host_str(), Some("api.example.com"));
639 assert!(sibling.concurrency_limiter.is_some());
642 }
643
644 #[test]
645 fn with_base_url_rejects_a_malformed_url() {
646 let client = HttpClientBuilder::new("https://api.example.com")
647 .build()
648 .unwrap();
649 assert!(client.with_base_url("not-a-url").is_err());
650 }
651
652 #[test]
653 fn base_url_path_prefixes_are_dropped() {
654 let client = HttpClientBuilder::new("http://localhost:8080/proxy")
658 .build()
659 .unwrap();
660 assert_eq!(
661 client.base_url.join("/user-pnl").unwrap().as_str(),
662 "http://localhost:8080/user-pnl",
663 "a path prefix in the base URL is not preserved"
664 );
665
666 let with_slash = client
667 .with_base_url("http://localhost:8080/proxy/")
668 .unwrap();
669 assert_eq!(
670 with_slash.base_url.join("/user-pnl").unwrap().as_str(),
671 "http://localhost:8080/user-pnl",
672 "a trailing slash does not preserve the prefix either"
673 );
674 }
675}