exchange_apiws/http.rs
1//! Generic HTTP plumbing for unauthenticated REST clients.
2//!
3//! [`PublicRestClient`] is the foundation for any exchange integration that
4//! doesn't require signing — Binance public endpoints, Bybit public
5//! endpoints, and the public-data side of Kraken and Crypto.com all build
6//! on it. The authenticated [`KuCoinClient`](crate::client::KuCoinClient)
7//! shares helper functions defined here (`percent_encode`,
8//! `build_query_string`, `jitter_secs`) but adds its own signing layer.
9//!
10//! Responsibilities:
11//! - reqwest HTTP client with rustls + configurable timeout
12//! - jittered exponential-backoff retry on transient network errors
13//! - HTTP 429 handling via the standard `Retry-After` header
14//! - **No** envelope unwrapping — exchange-specific shapes are the caller's
15//! responsibility.
16//!
17//! The crate-internal `send_with_retry` helper factors out the
18//! 429/`Retry-After` + transient-network backoff loop so the signed private
19//! REST clients (Bybit, Kraken, Crypto.com) get the same rate-limit hardening
20//! as the public client without each re-implementing it. It re-signs per
21//! attempt via a caller-supplied request builder, so per-request
22//! timestamps/nonces stay fresh across retries.
23
24use std::time::Duration;
25
26#[cfg(any(
27 feature = "binance",
28 feature = "bybit",
29 feature = "kraken",
30 feature = "cryptocom"
31))]
32use reqwest::RequestBuilder;
33use reqwest::{Client, StatusCode};
34use serde::de::DeserializeOwned;
35use tracing::warn;
36
37use crate::error::{ExchangeError, Result};
38
39// ── Shared helpers (also used by KuCoinClient) ────────────────────────────────
40
41/// Percent-encode a single query parameter value (RFC 3986 §2.3).
42///
43/// Only unreserved characters (`A–Z`, `a–z`, `0–9`, `-`, `_`, `.`, `~`) are
44/// left unencoded; everything else becomes `%XX`. Safe to use in URLs and
45/// HMAC pre-hashes.
46pub(crate) fn percent_encode(s: &str) -> String {
47 let mut out = String::with_capacity(s.len());
48 for byte in s.bytes() {
49 match byte {
50 b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => {
51 out.push(byte as char);
52 }
53 b => {
54 out.push('%');
55 out.push(
56 char::from_digit(u32::from(b) >> 4, 16)
57 .unwrap()
58 .to_ascii_uppercase(),
59 );
60 out.push(
61 char::from_digit(u32::from(b) & 0xF, 16)
62 .unwrap()
63 .to_ascii_uppercase(),
64 );
65 }
66 }
67 }
68 out
69}
70
71/// Build a percent-encoded query string from key-value pairs.
72///
73/// Returns `""` when `params` is empty, otherwise
74/// `"?key=value&key2=value2"` with all values percent-encoded.
75pub(crate) fn build_query_string(params: &[(&str, &str)]) -> String {
76 if params.is_empty() {
77 return String::new();
78 }
79 let pairs: Vec<String> = params
80 .iter()
81 .map(|(k, v)| format!("{}={}", percent_encode(k), percent_encode(v)))
82 .collect();
83 format!("?{}", pairs.join("&"))
84}
85
86/// Return a ±25 % jitter factor for `base`.
87///
88/// Uses sub-second system time as a cheap entropy source — no `rand`
89/// dependency. The distribution isn't perfectly uniform but is sufficient
90/// to spread out concurrent retry bursts.
91pub(crate) fn jitter_secs(base: f64) -> f64 {
92 let nanos = std::time::SystemTime::now()
93 .duration_since(std::time::UNIX_EPOCH)
94 .unwrap_or_default()
95 .subsec_nanos();
96 let factor = (f64::from(nanos) / 1_000_000_000.0 - 0.5) * 0.5;
97 base * factor
98}
99
100/// Default number of HTTP retry attempts for transient failures.
101pub(crate) const DEFAULT_RETRIES: u32 = 3;
102
103/// Default exponential backoff base (seconds).
104pub(crate) const DEFAULT_BACKOFF: f64 = 1.5;
105
106/// Cap on consecutive 429 sleeps per call. Prevents infinite loops if the
107/// exchange keeps returning rate-limited.
108pub(crate) const MAX_RATE_LIMIT_RETRIES: u32 = 5;
109
110/// Default per-request timeout (seconds).
111const DEFAULT_HTTP_TIMEOUT_SECS: u64 = 10;
112
113// ── PublicRestClient ──────────────────────────────────────────────────────────
114
115/// Shared unauthenticated HTTP client for exchange public REST endpoints.
116///
117/// Create once and clone cheaply — the underlying `reqwest::Client` pools
118/// connections across calls.
119///
120/// # Example
121///
122/// ```no_run
123/// use exchange_apiws::http::PublicRestClient;
124/// use serde::Deserialize;
125///
126/// # async fn example() -> exchange_apiws::Result<()> {
127/// #[derive(Deserialize)]
128/// struct ServerTime { serverTime: u64 }
129///
130/// let client = PublicRestClient::new("https://api.binance.com")?;
131/// let ts: ServerTime = client.get("/api/v3/time", &[]).await?;
132/// println!("Binance server time: {}", ts.serverTime);
133/// # Ok(())
134/// # }
135/// ```
136#[derive(Clone)]
137pub struct PublicRestClient {
138 http: Client,
139 base_url: String,
140}
141
142impl PublicRestClient {
143 /// Build a client pointed at `base_url` with the default 10 s timeout.
144 ///
145 /// # Errors
146 /// Returns [`ExchangeError::Config`] if the underlying `reqwest` client
147 /// cannot be built (e.g. TLS initialisation failure).
148 pub fn new(base_url: impl Into<String>) -> Result<Self> {
149 Self::with_timeout(base_url, Duration::from_secs(DEFAULT_HTTP_TIMEOUT_SECS))
150 }
151
152 /// Build a client with a caller-specified per-request timeout.
153 ///
154 /// # Errors
155 /// Returns [`ExchangeError::Config`] if the underlying `reqwest` client
156 /// cannot be built.
157 pub fn with_timeout(base_url: impl Into<String>, timeout: Duration) -> Result<Self> {
158 crate::tls::ensure_crypto_provider();
159 let http = Client::builder()
160 .timeout(timeout)
161 .build()
162 .map_err(|e| ExchangeError::Config(format!("failed to build HTTP client: {e}")))?;
163 Ok(Self {
164 http,
165 base_url: base_url.into(),
166 })
167 }
168
169 /// The base URL the client was constructed with.
170 #[must_use]
171 pub fn base_url(&self) -> &str {
172 &self.base_url
173 }
174
175 /// Public GET with jittered exponential-backoff retry.
176 ///
177 /// `params` are percent-encoded and appended as a query string. The
178 /// response body is deserialized directly as `T` with no envelope
179 /// unwrapping — the caller is responsible for handling exchange-specific
180 /// response shapes (Binance bare JSON, Bybit `retCode`, etc.).
181 ///
182 /// Retry policy:
183 /// - Network errors (connect, timeout, DNS) are retried a few times
184 /// with jittered exponential backoff.
185 /// - HTTP 429 responses honour the `Retry-After` header (seconds form).
186 /// They do not consume the retry budget but are capped at a small
187 /// fixed count before giving up.
188 /// - Other 4xx/5xx responses surface as [`ExchangeError::Api`] without retry.
189 pub async fn get<T: DeserializeOwned>(&self, path: &str, params: &[(&str, &str)]) -> Result<T> {
190 let qs = build_query_string(params);
191 let url = format!("{}{path}{qs}", self.base_url);
192
193 let mut last_err: Option<ExchangeError> = None;
194 let mut rate_limit_hits: u32 = 0;
195 let mut attempt: u32 = 0;
196
197 while attempt < DEFAULT_RETRIES {
198 let send_result = self.http.get(&url).send().await;
199 let resp = match send_result {
200 Ok(r) => r,
201 Err(e) if attempt < DEFAULT_RETRIES - 1 => {
202 let base = DEFAULT_BACKOFF.powi(attempt.cast_signed() + 1);
203 let wait = (base + jitter_secs(base)).max(0.1);
204 warn!(
205 attempt,
206 path,
207 error = %e,
208 wait_secs = wait,
209 "public GET failed, retrying"
210 );
211 tokio::time::sleep(Duration::from_secs_f64(wait)).await;
212 last_err = Some(ExchangeError::Http(e));
213 attempt += 1;
214 continue;
215 }
216 Err(e) => return Err(ExchangeError::Http(e)),
217 };
218
219 if resp.status() == StatusCode::TOO_MANY_REQUESTS {
220 rate_limit_hits += 1;
221 if rate_limit_hits > MAX_RATE_LIMIT_RETRIES {
222 return Err(ExchangeError::Api {
223 code: "429".into(),
224 message: format!(
225 "GET {path} was rate-limited \
226 {MAX_RATE_LIMIT_RETRIES} times; giving up"
227 ),
228 });
229 }
230 let wait = parse_retry_after(&resp).unwrap_or(Duration::from_secs(2));
231 warn!(
232 attempt,
233 path,
234 wait_ms = wait.as_millis(),
235 rate_limit_hits,
236 "public GET rate-limited — waiting before retry"
237 );
238 tokio::time::sleep(wait).await;
239 last_err = Some(ExchangeError::Api {
240 code: "429".into(),
241 message: "rate limited".into(),
242 });
243 // Rate-limit sleeps don't consume the retry budget — the
244 // cap above bounds them instead.
245 continue;
246 }
247
248 if !resp.status().is_success() {
249 let code = resp.status().as_u16().to_string();
250 let message = resp
251 .text()
252 .await
253 .unwrap_or_else(|_| String::from("no body"));
254 return Err(ExchangeError::Api { code, message });
255 }
256
257 return Ok(resp.json::<T>().await?);
258 }
259
260 Err(last_err.unwrap_or_else(|| ExchangeError::Api {
261 code: "retry_exhausted".into(),
262 message: format!("GET {path} failed after {DEFAULT_RETRIES} attempts"),
263 }))
264 }
265}
266
267/// Parse the HTTP `Retry-After` header (RFC 7231 §7.1.3).
268///
269/// Supports the integer-seconds form used by Binance, Bybit, and most other
270/// exchange APIs. HTTP-date form falls back to the caller's default since
271/// it's vanishingly rare in practice for rate-limit responses.
272fn parse_retry_after(resp: &reqwest::Response) -> Option<Duration> {
273 resp.headers()
274 .get("retry-after")
275 .and_then(|h| h.to_str().ok())
276 .and_then(|s| s.parse::<u64>().ok())
277 .map(Duration::from_secs)
278}
279
280/// Jittered exponential backoff wait for retry attempt `attempt` (0-based).
281///
282/// Mirrors the schedule the public/KuCoin retry loops use:
283/// `DEFAULT_BACKOFF^(attempt+1)` seconds plus ±25 % jitter, floored at
284/// 100 ms so a tight loop still yields.
285// Only the signed private REST clients reuse this; gate it so a KuCoin-only
286// (no optional exchanges) build doesn't trip `-D warnings` on dead code.
287#[cfg(any(
288 feature = "binance",
289 feature = "bybit",
290 feature = "kraken",
291 feature = "cryptocom"
292))]
293fn backoff_wait(attempt: u32) -> Duration {
294 let base = DEFAULT_BACKOFF.powi(attempt.cast_signed() + 1);
295 Duration::from_secs_f64((base + jitter_secs(base)).max(0.1))
296}
297
298/// Send a signed request with the shared 429 + transient-network retry policy.
299///
300/// This is the rate-limit hardening behind the signed private REST clients
301/// (Bybit, Kraken, Crypto.com). It returns the **first non-429
302/// [`reqwest::Response`]** — success-path and non-2xx error mapping stay the
303/// caller's job, so existing envelope-unwrapping behaviour is unchanged.
304///
305/// `build` is called once per attempt to produce a fresh [`RequestBuilder`]
306/// (reqwest consumes it on `.send()`). Re-invoking it per attempt is what
307/// keeps each venue's per-request timestamp / nonce / signature fresh on a
308/// retry rather than replaying a stale, now-rejected signature.
309///
310/// Retry policy (matches [`PublicRestClient::get`]):
311/// - Transient network errors (connect / timeout / DNS) are retried up to
312/// [`DEFAULT_RETRIES`] times with jittered exponential backoff; the last
313/// attempt's error surfaces as [`ExchangeError::Http`].
314/// - HTTP 429 responses honour the `Retry-After` header (integer-seconds
315/// form), falling back to the same exponential backoff when the header is
316/// absent. 429 sleeps do **not** consume the transient-error budget but are
317/// capped at [`MAX_RATE_LIMIT_RETRIES`]; a persistent 429 surfaces as
318/// [`ExchangeError::Api`] with code `"429"` rather than looping forever.
319///
320/// `label` is only used for log lines (e.g. `"Bybit POST /v5/order/create"`).
321// Gated to the signed-exchange features that consume it, so the KuCoin-only
322// build stays free of dead-code warnings under `-D warnings`.
323#[cfg(any(
324 feature = "binance",
325 feature = "bybit",
326 feature = "kraken",
327 feature = "cryptocom"
328))]
329pub(crate) async fn send_with_retry<F>(label: &str, mut build: F) -> Result<reqwest::Response>
330where
331 F: FnMut() -> RequestBuilder,
332{
333 let mut last_err: Option<ExchangeError> = None;
334 let mut rate_limit_hits: u32 = 0;
335 let mut attempt: u32 = 0;
336
337 while attempt < DEFAULT_RETRIES {
338 let resp = match build().send().await {
339 Ok(r) => r,
340 Err(e) if attempt < DEFAULT_RETRIES - 1 => {
341 let wait = backoff_wait(attempt);
342 warn!(
343 attempt,
344 label,
345 error = %e,
346 wait_secs = wait.as_secs_f64(),
347 "request failed, retrying"
348 );
349 tokio::time::sleep(wait).await;
350 last_err = Some(ExchangeError::Http(e));
351 attempt += 1;
352 continue;
353 }
354 Err(e) => return Err(ExchangeError::Http(e)),
355 };
356
357 if resp.status() == StatusCode::TOO_MANY_REQUESTS {
358 rate_limit_hits += 1;
359 if rate_limit_hits > MAX_RATE_LIMIT_RETRIES {
360 return Err(ExchangeError::Api {
361 code: "429".into(),
362 message: format!(
363 "{label} was rate-limited {MAX_RATE_LIMIT_RETRIES} times; giving up"
364 ),
365 });
366 }
367 // Prefer the server's Retry-After; otherwise fall back to the
368 // exponential backoff schedule.
369 let wait = parse_retry_after(&resp).unwrap_or_else(|| backoff_wait(attempt));
370 warn!(
371 attempt,
372 label,
373 wait_ms = wait.as_millis(),
374 rate_limit_hits,
375 "rate-limited (HTTP 429) — waiting before retry"
376 );
377 tokio::time::sleep(wait).await;
378 last_err = Some(ExchangeError::Api {
379 code: "429".into(),
380 message: "rate limited".into(),
381 });
382 // Rate-limit sleeps don't consume the retry budget — the cap
383 // above bounds them instead.
384 continue;
385 }
386
387 return Ok(resp);
388 }
389
390 Err(last_err.unwrap_or_else(|| ExchangeError::Api {
391 code: "retry_exhausted".into(),
392 message: format!("{label} failed after {DEFAULT_RETRIES} attempts"),
393 }))
394}
395
396// ── Unit tests ────────────────────────────────────────────────────────────────
397
398#[cfg(test)]
399mod tests {
400 use super::*;
401
402 #[test]
403 fn percent_encode_leaves_unreserved_chars_unchanged() {
404 assert_eq!(percent_encode("XBTUSDTM"), "XBTUSDTM");
405 assert_eq!(percent_encode("abc-123_def.ghi~"), "abc-123_def.ghi~");
406 }
407
408 #[test]
409 fn percent_encode_encodes_special_chars() {
410 assert_eq!(percent_encode("a b"), "a%20b");
411 assert_eq!(percent_encode("a=b&c=d"), "a%3Db%26c%3Dd");
412 assert_eq!(percent_encode("a+b"), "a%2Bb");
413 }
414
415 #[test]
416 fn build_query_string_empty() {
417 assert_eq!(build_query_string(&[]), "");
418 }
419
420 #[test]
421 fn build_query_string_encodes_values() {
422 let qs = build_query_string(&[("symbol", "XBT USDT"), ("side", "buy&sell")]);
423 assert_eq!(qs, "?symbol=XBT%20USDT&side=buy%26sell");
424 }
425
426 #[test]
427 fn jitter_stays_within_25_percent() {
428 let base = 4.0_f64;
429 for _ in 0..100 {
430 let j = jitter_secs(base);
431 assert!(
432 j.abs() <= base.mul_add(0.25, 1e-9),
433 "jitter {j} exceeded ±25% of {base}"
434 );
435 }
436 }
437
438 // The retry helper + its backoff math are gated to the signed-exchange
439 // features that consume them; gate the tests the same way so the
440 // KuCoin-only build doesn't reference cfg'd-out items.
441 #[cfg(any(
442 feature = "binance",
443 feature = "bybit",
444 feature = "kraken",
445 feature = "cryptocom"
446 ))]
447 #[test]
448 fn backoff_wait_grows_and_is_floored() {
449 // Backoff is jittered, so pin the loose invariants: always ≥ the 100 ms
450 // floor, and the (un-jittered) base grows attempt over attempt.
451 for attempt in 0..4 {
452 assert!(
453 backoff_wait(attempt) >= Duration::from_millis(100),
454 "attempt {attempt} fell below the 100 ms floor"
455 );
456 }
457 let lo = DEFAULT_BACKOFF.powi(1);
458 let hi = DEFAULT_BACKOFF.powi(4);
459 assert!(hi > lo, "backoff base must increase with the attempt count");
460 }
461
462 // ── send_with_retry: 429 / Retry-After behaviour (wiremock-backed) ─────────
463
464 #[cfg(any(
465 feature = "binance",
466 feature = "bybit",
467 feature = "kraken",
468 feature = "cryptocom"
469 ))]
470 use wiremock::matchers::{method, path};
471 #[cfg(any(
472 feature = "binance",
473 feature = "bybit",
474 feature = "kraken",
475 feature = "cryptocom"
476 ))]
477 use wiremock::{Mock, MockServer, ResponseTemplate};
478
479 #[cfg(any(
480 feature = "binance",
481 feature = "bybit",
482 feature = "kraken",
483 feature = "cryptocom"
484 ))]
485 #[tokio::test]
486 async fn send_with_retry_honours_retry_after_then_succeeds() {
487 let server = MockServer::start().await;
488
489 // First hit: 429 with Retry-After: 1s. Then a 200.
490 Mock::given(method("POST"))
491 .and(path("/signed"))
492 .respond_with(ResponseTemplate::new(429).insert_header("Retry-After", "1"))
493 .up_to_n_times(1)
494 .expect(1)
495 .mount(&server)
496 .await;
497 Mock::given(method("POST"))
498 .and(path("/signed"))
499 .respond_with(ResponseTemplate::new(200).set_body_string("ok"))
500 .expect(1)
501 .mount(&server)
502 .await;
503
504 let client = Client::new();
505 let url = format!("{}/signed", server.uri());
506 // Count builder invocations to prove we re-sign per attempt.
507 let mut builds = 0_u32;
508 let resp = send_with_retry("test POST /signed", || {
509 builds += 1;
510 client.post(&url)
511 })
512 .await
513 .expect("expected eventual success after a 429");
514
515 assert_eq!(resp.status(), StatusCode::OK);
516 assert_eq!(resp.text().await.unwrap(), "ok");
517 assert_eq!(builds, 2, "builder must run once per attempt (429 + retry)");
518 }
519
520 #[cfg(any(
521 feature = "binance",
522 feature = "bybit",
523 feature = "kraken",
524 feature = "cryptocom"
525 ))]
526 #[tokio::test]
527 async fn send_with_retry_caps_persistent_429s() {
528 let server = MockServer::start().await;
529
530 // Every response is an immediate 429. The dedicated cap must fire
531 // rather than looping forever.
532 Mock::given(method("POST"))
533 .and(path("/limited"))
534 .respond_with(ResponseTemplate::new(429).insert_header("Retry-After", "0"))
535 .mount(&server)
536 .await;
537
538 let client = Client::new();
539 let url = format!("{}/limited", server.uri());
540 let result = send_with_retry("test POST /limited", || client.post(&url)).await;
541
542 match result {
543 Err(ExchangeError::Api { code, message }) => {
544 assert_eq!(code, "429");
545 assert!(
546 message.contains("giving up"),
547 "expected the rate-limit-cap error, got: {message}"
548 );
549 }
550 other => panic!("expected Api(429) cap error, got {other:?}"),
551 }
552 }
553
554 #[cfg(any(
555 feature = "binance",
556 feature = "bybit",
557 feature = "kraken",
558 feature = "cryptocom"
559 ))]
560 #[tokio::test]
561 async fn send_with_retry_returns_non_429_errors_unretried() {
562 let server = MockServer::start().await;
563
564 // A 400 is returned as-is (non-429) — the helper hands the response
565 // back so the caller can map it; it must NOT retry. expect(1) enforces
566 // the single hit.
567 Mock::given(method("POST"))
568 .and(path("/bad"))
569 .respond_with(ResponseTemplate::new(400).set_body_string("nope"))
570 .expect(1)
571 .mount(&server)
572 .await;
573
574 let client = Client::new();
575 let url = format!("{}/bad", server.uri());
576 let resp = send_with_retry("test POST /bad", || client.post(&url))
577 .await
578 .expect("non-429 responses come back as Ok(resp) for the caller to map");
579 assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
580 }
581}