Skip to main content

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}