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> {
56 Ok(Self {
57 base_url: Url::parse(base_url)?,
58 ..self.clone()
59 })
60 }
61
62 pub async fn acquire_rate_limit(&self, path: &str, method: Option<&reqwest::Method>) {
64 if let Some(rl) = &self.rate_limiter {
65 rl.acquire(path, method).await;
66 }
67 }
68
69 pub async fn acquire_concurrency(&self) -> Option<OwnedSemaphorePermit> {
75 let sem = self.concurrency_limiter.as_ref()?;
76 Some(
77 sem.clone()
78 .acquire_owned()
79 .await
80 .expect("concurrency semaphore is never closed"),
81 )
82 }
83
84 pub fn should_retry(
89 &self,
90 status: StatusCode,
91 attempt: u32,
92 retry_after: Option<&str>,
93 ) -> Option<Duration> {
94 if status == StatusCode::TOO_MANY_REQUESTS && attempt < self.retry_config.max_retries {
95 if let Some(delay) = retry_after.and_then(|v| v.parse::<f64>().ok()) {
96 let ms = (delay * 1000.0) as u64;
97 Some(Duration::from_millis(
98 ms.min(self.retry_config.max_backoff_ms),
99 ))
100 } else {
101 Some(self.retry_config.backoff(attempt))
102 }
103 } else {
104 None
105 }
106 }
107
108 pub async fn get_bytes(
122 &self,
123 path: &str,
124 query: &[(String, String)],
125 ) -> Result<Vec<u8>, ApiError> {
126 let url = self.base_url.join(path)?;
127 let mut attempt = 0u32;
128
129 loop {
130 let _permit = self.acquire_concurrency().await;
131 self.acquire_rate_limit(path, None).await;
132
133 let mut request = self.client.get(url.clone());
134 if !query.is_empty() {
135 request = request.query(query);
136 }
137
138 let response = request.send().await?;
139 let status = response.status();
140 let retry_after = retry_after_header(&response);
141
142 if let Some(backoff) = self.should_retry(status, attempt, retry_after.as_deref()) {
143 attempt += 1;
144 tracing::warn!(
145 "Rate limited (429) on {}, retry {} after {}ms",
146 path,
147 attempt,
148 backoff.as_millis()
149 );
150 drop(_permit);
151 tokio::time::sleep(backoff).await;
152 continue;
153 }
154
155 if !status.is_success() {
156 return Err(ApiError::from_response(response).await);
157 }
158
159 let bytes = response.bytes().await?;
160 return Ok(bytes.to_vec());
161 }
162 }
163}
164
165pub struct HttpClientBuilder {
182 base_url: String,
183 timeout_ms: u64,
184 pool_size: usize,
185 rate_limiter: Option<RateLimiter>,
186 retry_config: RetryConfig,
187 max_concurrent: Option<usize>,
188}
189
190impl HttpClientBuilder {
191 pub fn new(base_url: impl Into<String>) -> Self {
193 Self {
194 base_url: base_url.into(),
195 timeout_ms: DEFAULT_TIMEOUT_MS,
196 pool_size: DEFAULT_POOL_SIZE,
197 rate_limiter: None,
198 retry_config: RetryConfig::default(),
199 max_concurrent: None,
200 }
201 }
202
203 pub fn timeout_ms(mut self, timeout: u64) -> Self {
207 self.timeout_ms = timeout;
208 self
209 }
210
211 pub fn pool_size(mut self, size: usize) -> Self {
215 self.pool_size = size;
216 self
217 }
218
219 pub fn with_rate_limiter(mut self, limiter: RateLimiter) -> Self {
221 self.rate_limiter = Some(limiter);
222 self
223 }
224
225 pub fn with_retry_config(mut self, config: RetryConfig) -> Self {
227 self.retry_config = config;
228 self
229 }
230
231 pub fn with_max_concurrent(mut self, max: usize) -> Self {
236 self.max_concurrent = Some(max);
237 self
238 }
239
240 pub fn build(self) -> Result<HttpClient, ApiError> {
242 let client = reqwest::Client::builder()
243 .timeout(Duration::from_millis(self.timeout_ms))
244 .connect_timeout(Duration::from_secs(10))
245 .redirect(reqwest::redirect::Policy::none())
246 .pool_max_idle_per_host(self.pool_size)
247 .build()?;
248
249 let base_url = Url::parse(&self.base_url)?;
250
251 Ok(HttpClient {
252 client,
253 base_url,
254 rate_limiter: self.rate_limiter,
255 retry_config: self.retry_config,
256 concurrency_limiter: self.max_concurrent.map(|n| Arc::new(Semaphore::new(n))),
257 })
258 }
259}
260
261impl Default for HttpClientBuilder {
262 fn default() -> Self {
263 Self {
264 base_url: String::new(),
265 timeout_ms: DEFAULT_TIMEOUT_MS,
266 pool_size: DEFAULT_POOL_SIZE,
267 rate_limiter: None,
268 retry_config: RetryConfig::default(),
269 max_concurrent: None,
270 }
271 }
272}
273
274#[cfg(test)]
275mod tests {
276 use super::*;
277
278 #[test]
281 fn test_should_retry_429_under_max() {
282 let client = HttpClientBuilder::new("https://example.com")
283 .build()
284 .unwrap();
285 assert!(client
287 .should_retry(StatusCode::TOO_MANY_REQUESTS, 0, None)
288 .is_some());
289 assert!(client
290 .should_retry(StatusCode::TOO_MANY_REQUESTS, 2, None)
291 .is_some());
292 }
293
294 #[test]
295 fn test_should_retry_429_at_max() {
296 let client = HttpClientBuilder::new("https://example.com")
297 .build()
298 .unwrap();
299 assert!(client
301 .should_retry(StatusCode::TOO_MANY_REQUESTS, 3, None)
302 .is_none());
303 }
304
305 #[test]
306 fn test_should_retry_non_429_returns_none() {
307 let client = HttpClientBuilder::new("https://example.com")
308 .build()
309 .unwrap();
310 for status in [
311 StatusCode::OK,
312 StatusCode::INTERNAL_SERVER_ERROR,
313 StatusCode::BAD_REQUEST,
314 StatusCode::FORBIDDEN,
315 ] {
316 assert!(
317 client.should_retry(status, 0, None).is_none(),
318 "expected None for {status}"
319 );
320 }
321 }
322
323 #[test]
324 fn test_should_retry_custom_config() {
325 let client = HttpClientBuilder::new("https://example.com")
326 .with_retry_config(RetryConfig {
327 max_retries: 1,
328 ..RetryConfig::default()
329 })
330 .build()
331 .unwrap();
332 assert!(client
333 .should_retry(StatusCode::TOO_MANY_REQUESTS, 0, None)
334 .is_some());
335 assert!(client
336 .should_retry(StatusCode::TOO_MANY_REQUESTS, 1, None)
337 .is_none());
338 }
339
340 #[test]
341 fn test_should_retry_uses_retry_after_header() {
342 let client = HttpClientBuilder::new("https://example.com")
343 .build()
344 .unwrap();
345 let d = client
346 .should_retry(StatusCode::TOO_MANY_REQUESTS, 0, Some("2"))
347 .unwrap();
348 assert_eq!(d, Duration::from_millis(2000));
349 }
350
351 #[test]
352 fn test_should_retry_retry_after_fractional_seconds() {
353 let client = HttpClientBuilder::new("https://example.com")
354 .build()
355 .unwrap();
356 let d = client
357 .should_retry(StatusCode::TOO_MANY_REQUESTS, 0, Some("0.5"))
358 .unwrap();
359 assert_eq!(d, Duration::from_millis(500));
360 }
361
362 #[test]
363 fn test_should_retry_retry_after_clamped_to_max_backoff() {
364 let client = HttpClientBuilder::new("https://example.com")
365 .build()
366 .unwrap();
367 let d = client
369 .should_retry(StatusCode::TOO_MANY_REQUESTS, 0, Some("60"))
370 .unwrap();
371 assert_eq!(d, Duration::from_millis(10_000));
372 }
373
374 #[test]
375 fn test_should_retry_retry_after_invalid_falls_back() {
376 let client = HttpClientBuilder::new("https://example.com")
377 .build()
378 .unwrap();
379 let d = client
381 .should_retry(
382 StatusCode::TOO_MANY_REQUESTS,
383 0,
384 Some("Wed, 21 Oct 2025 07:28:00 GMT"),
385 )
386 .unwrap();
387 let ms = d.as_millis() as u64;
389 assert!(
390 (375..=625).contains(&ms),
391 "expected fallback backoff in [375, 625], got {ms}"
392 );
393 }
394
395 #[tokio::test]
398 async fn test_builder_with_rate_limiter() {
399 let client = HttpClientBuilder::new("https://example.com")
400 .with_rate_limiter(RateLimiter::clob_default())
401 .build()
402 .unwrap();
403 let start = std::time::Instant::now();
404 client
405 .acquire_rate_limit("/order", Some(&reqwest::Method::POST))
406 .await;
407 assert!(start.elapsed() < Duration::from_millis(50));
408 }
409
410 #[tokio::test]
411 async fn test_builder_without_rate_limiter() {
412 let client = HttpClientBuilder::new("https://example.com")
413 .build()
414 .unwrap();
415 let start = std::time::Instant::now();
416 client
417 .acquire_rate_limit("/order", Some(&reqwest::Method::POST))
418 .await;
419 assert!(start.elapsed() < Duration::from_millis(10));
420 }
421
422 #[tokio::test]
425 async fn test_acquire_concurrency_none_when_not_configured() {
426 let client = HttpClientBuilder::new("https://example.com")
427 .build()
428 .unwrap();
429 assert!(client.acquire_concurrency().await.is_none());
430 }
431
432 #[tokio::test]
433 async fn test_acquire_concurrency_returns_permit() {
434 let client = HttpClientBuilder::new("https://example.com")
435 .with_max_concurrent(2)
436 .build()
437 .unwrap();
438 let permit = client.acquire_concurrency().await;
439 assert!(permit.is_some());
440 }
441
442 #[tokio::test]
443 async fn test_concurrency_shared_across_clones() {
444 let client = HttpClientBuilder::new("https://example.com")
445 .with_max_concurrent(1)
446 .build()
447 .unwrap();
448 let clone = client.clone();
449
450 let _permit = client.acquire_concurrency().await.unwrap();
452
453 let result =
455 tokio::time::timeout(Duration::from_millis(50), clone.acquire_concurrency()).await;
456 assert!(result.is_err(), "clone should block when permit is held");
457 }
458
459 #[tokio::test]
460 async fn test_concurrency_limits_parallel_tasks() {
461 let client = HttpClientBuilder::new("https://example.com")
462 .with_max_concurrent(2)
463 .build()
464 .unwrap();
465
466 let start = std::time::Instant::now();
467 let mut handles = Vec::new();
468 for _ in 0..4 {
469 let c = client.clone();
470 handles.push(tokio::spawn(async move {
471 let _permit = c.acquire_concurrency().await;
472 tokio::time::sleep(Duration::from_millis(50)).await;
473 }));
474 }
475 for h in handles {
476 h.await.unwrap();
477 }
478 assert!(
480 start.elapsed() >= Duration::from_millis(90),
481 "expected ~100ms, got {:?}",
482 start.elapsed()
483 );
484 }
485
486 #[tokio::test]
487 async fn test_builder_with_max_concurrent() {
488 let client = HttpClientBuilder::new("https://example.com")
489 .with_max_concurrent(5)
490 .build()
491 .unwrap();
492 let mut permits = Vec::new();
494 for _ in 0..5 {
495 permits.push(client.acquire_concurrency().await);
496 }
497 assert!(permits.iter().all(|p| p.is_some()));
498
499 let result =
501 tokio::time::timeout(Duration::from_millis(50), client.acquire_concurrency()).await;
502 assert!(result.is_err());
503 }
504
505 #[tokio::test]
508 async fn test_get_bytes_returns_body_verbatim() {
509 let mut server = mockito::Server::new_async().await;
510 let body: Vec<u8> = vec![0x50, 0x4B, 0x03, 0x04, 0x00, 0xFF, 0xFE, 0x42];
512 let mock = server
513 .mock("GET", "/v1/accounting/snapshot")
514 .match_query(mockito::Matcher::UrlEncoded("user".into(), "0xabc".into()))
515 .with_status(200)
516 .with_header("content-type", "application/zip")
517 .with_body(body.clone())
518 .create_async()
519 .await;
520
521 let client = HttpClientBuilder::new(server.url()).build().unwrap();
522 let out = client
523 .get_bytes(
524 "/v1/accounting/snapshot",
525 &[("user".to_string(), "0xabc".to_string())],
526 )
527 .await
528 .unwrap();
529 assert_eq!(out, body);
530 mock.assert_async().await;
531 }
532
533 #[tokio::test]
534 async fn test_get_bytes_maps_non_2xx_to_api_error() {
535 let mut server = mockito::Server::new_async().await;
536 let mock = server
537 .mock("GET", "/does-not-exist")
538 .with_status(404)
539 .with_header("content-type", "application/json")
540 .with_body(r#"{"error": "not found"}"#)
541 .create_async()
542 .await;
543
544 let client = HttpClientBuilder::new(server.url()).build().unwrap();
545 let err = client.get_bytes("/does-not-exist", &[]).await.unwrap_err();
546 match err {
547 ApiError::Api { status, message } => {
548 assert_eq!(status, 404);
549 assert_eq!(message, "not found");
550 }
551 other => panic!("expected ApiError::Api, got {other:?}"),
552 }
553 mock.assert_async().await;
554 }
555
556 #[tokio::test]
557 async fn test_get_bytes_no_query_params() {
558 let mut server = mockito::Server::new_async().await;
559 let mock = server
560 .mock("GET", "/raw")
561 .with_status(200)
562 .with_body(&b"hello"[..])
563 .create_async()
564 .await;
565
566 let client = HttpClientBuilder::new(server.url()).build().unwrap();
567 let out = client.get_bytes("/raw", &[]).await.unwrap();
568 assert_eq!(out, b"hello");
569 mock.assert_async().await;
570 }
571}