Skip to main content

polyoxide_binance/spot/
weight.rs

1//! Binance spot's request-weight budget and the table of what each route costs.
2//!
3//! Binance charges each REST request a *weight* that depends on its route and
4//! its parameters, and refuses an IP whose weight in the current minute passes
5//! `exchangeInfo`'s `REQUEST_WEIGHT` limit of 6000. Core's `RateLimiter` counts
6//! requests per path, so it cannot model a cost that varies with `limit`;
7//! [`WeightBudget`] does, on the engine every Binance module shares. The table is
8//! [`Route::cost`]; the measurements behind it are in
9//! `docs/specs/binance/OBSERVED.md` (`## Spot`).
10//!
11//! `exchangeInfo` also lists a `RAW_REQUESTS` limit of 300,000 per five
12//! minutes. It is not modelled: every request weighs at least 1, so a budget of
13//! 5400 per minute caps a client at 27,000 requests per five minutes, under it.
14
15use std::sync::Arc;
16use std::time::Duration;
17
18use polyoxide_core::{AttemptInfo, Charge, LayerId, Refused, RequestMeta, ResponseMeta, Throttle};
19use polyoxide_rt::Runtime;
20
21use crate::shared::request::Priced;
22use crate::shared::weight::{Clock, Engine};
23
24pub use crate::shared::weight::{DEFAULT_BAN, MAX_COOLDOWN};
25
26/// `exchangeInfo`'s `REQUEST_WEIGHT` limit per minute, per IP.
27pub const PUBLISHED_WEIGHT_PER_MINUTE: u32 = 6000;
28
29/// The per-minute request-weight layer of a [`WeightBudget`], as a request's
30/// [`polyoxide_core::Cost`] names it.
31pub const WEIGHT_LAYER: LayerId = LayerId("binance-spot-weight");
32
33/// What one request costs, and which limit it draws on.
34#[derive(Debug, Clone, Copy, PartialEq, Eq)]
35pub enum Cost {
36    /// Request weight, against the per-minute budget.
37    Weight(u32),
38}
39
40/// The cost a request builder hands core's send loop: its weight against
41/// [`WEIGHT_LAYER`]. It is exact.
42impl From<Cost> for polyoxide_core::Cost {
43    fn from(cost: Cost) -> Self {
44        match cost {
45            Cost::Weight(weight) => Self {
46                layer: WEIGHT_LAYER,
47                units: weight,
48                exact: true,
49            },
50        }
51    }
52}
53
54/// A REST route, with the parameters its weight depends on.
55#[derive(Debug, Clone, Copy, PartialEq, Eq)]
56#[non_exhaustive]
57pub enum Route {
58    /// `GET /api/v3/ping`.
59    Ping,
60    /// `GET /api/v3/time`.
61    Time,
62    /// `GET /api/v3/exchangeInfo`.
63    ExchangeInfo,
64    /// `GET /api/v3/depth`.
65    Depth {
66        /// The `limit` parameter, if sent.
67        limit: Option<u32>,
68    },
69    /// `GET /api/v3/trades`.
70    Trades,
71    /// `GET /api/v3/aggTrades`.
72    AggTrades,
73    /// `GET /api/v3/klines`.
74    Klines,
75    /// `GET /api/v3/avgPrice`.
76    AvgPrice,
77    /// `GET /api/v3/ticker/24hr`: one symbol, or every symbol when `all`.
78    Ticker24h {
79        /// No `symbol` parameter.
80        all: bool,
81    },
82    /// `GET /api/v3/ticker/price`: one symbol, or every symbol when `all`.
83    Price {
84        /// No `symbol` parameter.
85        all: bool,
86    },
87    /// `GET /api/v3/ticker/bookTicker`: one symbol, or every symbol when `all`.
88    BookTicker {
89        /// No `symbol` parameter.
90        all: bool,
91    },
92}
93
94impl Route {
95    /// The route's path on `api.binance.com`.
96    pub fn path(self) -> &'static str {
97        match self {
98            Self::Ping => "/api/v3/ping",
99            Self::Time => "/api/v3/time",
100            Self::ExchangeInfo => "/api/v3/exchangeInfo",
101            Self::Depth { .. } => "/api/v3/depth",
102            Self::Trades => "/api/v3/trades",
103            Self::AggTrades => "/api/v3/aggTrades",
104            Self::Klines => "/api/v3/klines",
105            Self::AvgPrice => "/api/v3/avgPrice",
106            Self::Ticker24h { .. } => "/api/v3/ticker/24hr",
107            Self::Price { .. } => "/api/v3/ticker/price",
108            Self::BookTicker { .. } => "/api/v3/ticker/bookTicker",
109        }
110    }
111
112    /// What the request costs, as measured from `X-MBX-USED-WEIGHT-1M` deltas
113    /// on 2026-11-02 (`docs/specs/binance/OBSERVED.md`, `## Spot`, `### REST
114    /// weights`).
115    ///
116    /// `depth` costs 5 without a `limit` and for 1 to 100 levels, 25 for 101 to
117    /// 500, 50 for 501 to 1000 and 250 above, each band measured
118    /// (`OBSERVED.md`, `## Spot`). `exchangeInfo` costs 20 with a `symbol` or without. The
119    /// one-symbol tickers and `klines` and `avgPrice` cost 2, `trades` 25 and
120    /// `aggTrades` 4, whatever their `limit`.
121    pub fn cost(self) -> Cost {
122        match self {
123            Self::Ping | Self::Time => Cost::Weight(1),
124            Self::ExchangeInfo => Cost::Weight(20),
125            Self::Depth { limit: None } => Cost::Weight(5),
126            Self::Depth { limit: Some(limit) } => Cost::Weight(match limit {
127                0..=100 => 5,
128                101..=500 => 25,
129                501..=1000 => 50,
130                _ => 250,
131            }),
132            Self::Trades => Cost::Weight(25),
133            Self::AggTrades => Cost::Weight(4),
134            Self::Klines | Self::AvgPrice => Cost::Weight(2),
135            Self::Ticker24h { all } => Cost::Weight(if all { 80 } else { 2 }),
136            Self::Price { all } | Self::BookTicker { all } => Cost::Weight(if all { 4 } else { 2 }),
137        }
138    }
139}
140
141impl Priced for Route {
142    type Cost = Cost;
143
144    fn path(self) -> &'static str {
145        Route::path(self)
146    }
147
148    fn cost(self) -> Cost {
149        Route::cost(self)
150    }
151}
152
153/// The request-weight budget of one IP, shared by every client that clones it.
154///
155/// Binance limits weight per IP, not per client, so every
156/// [`Spot`](crate::spot::Spot) in a process should be built with one budget
157/// ([`SpotBuilder::weight_budget`](crate::spot::SpotBuilder::weight_budget)).
158///
159/// - **Window.** The UTC clock minute, as on the USDⓈ-M module: the server's
160///   count falls just after each minute boundary, where a sliding window would
161///   keep counting.
162/// - **Budget.** The published 6000 less a tenth, 5400. A request waits while
163///   its weight would take the minute past that.
164/// - **Server count.** Every response's `X-MBX-USED-WEIGHT-1M` raises the
165///   minute's count to at least what the server reports, because other
166///   processes on the same IP spend the same budget. The count never falls
167///   within a minute, and a response is applied only to the minute its request
168///   was charged in.
169/// - **Cooldown.** A `429` or `418` holds every request for the delay the
170///   server asked for, with a ceiling of [`MAX_COOLDOWN`], so a hold is only
171///   ever extended.
172/// - **Throttle.** The budget is the [`Throttle`] every [`Spot`](crate::spot::Spot)
173///   built with it sends through: a request's weight is charged before it is
174///   sent, and each response's header is applied to the minute it was charged
175///   in.
176/// - **In flight.** A response's header is applied only to the minute its
177///   request was charged in, so a request in flight across a minute boundary is
178///   counted by the server in a minute this budget no longer sees. The reserve
179///   of 600 absorbs that while the weight in flight stays under it: one `Spot`
180///   at its default of 4 concurrent requests has at most 320 in flight on a
181///   route at its default (4 × `tickers_24h`'s 80), and four `depth` calls
182///   with a `limit` above 1000 (250 each) pass it. A higher `max_concurrent`,
183///   or several clients sharing one budget, can pass it too.
184/// - **Hosts.** `data-api.binance.vision` shares this counter with
185///   `api.binance.com` (`OBSERVED.md`, `### One counter across hosts`), so a
186///   client pointed at either is charged against the one budget.
187#[derive(Debug, Clone)]
188pub struct WeightBudget {
189    inner: Arc<Engine>,
190}
191
192impl Default for WeightBudget {
193    fn default() -> Self {
194        Self::new()
195    }
196}
197
198impl WeightBudget {
199    /// A budget for Binance spot's published limit, less the reserve, on the
200    /// target's default runtime.
201    pub fn new() -> Self {
202        Self::with_clock(Clock::System)
203    }
204
205    /// As [`new`](Self::new), keeping time on `runtime`: its cooldown and its
206    /// wait for the next minute both sleep on it.
207    pub fn new_on(runtime: Arc<dyn Runtime>) -> Self {
208        Self::with_clock_on(Clock::System, runtime)
209    }
210
211    fn with_clock(clock: Clock) -> Self {
212        Self::with_clock_on(clock, polyoxide_rt::default_runtime())
213    }
214
215    fn with_clock_on(clock: Clock, runtime: Arc<dyn Runtime>) -> Self {
216        Self {
217            inner: Arc::new(Engine::new(
218                PUBLISHED_WEIGHT_PER_MINUTE,
219                None,
220                clock,
221                runtime,
222            )),
223        }
224    }
225
226    /// A budget on a clock a test sets by hand, starting at `start_ms`.
227    #[cfg(test)]
228    pub(crate) fn at_ms(start_ms: u64) -> Self {
229        Self::with_clock(Clock::Manual(Arc::new(std::sync::atomic::AtomicU64::new(
230            start_ms,
231        ))))
232    }
233
234    /// Whether two handles charge one budget.
235    #[cfg(test)]
236    pub(crate) fn is_shared_with(&self, other: &Self) -> bool {
237        Arc::ptr_eq(&self.inner, &other.inner)
238    }
239
240    /// The most weight this budget spends in one minute.
241    pub fn per_minute(&self) -> u32 {
242        self.inner.per_minute()
243    }
244
245    /// The runtime this budget keeps time on: its hold's.
246    #[cfg(test)]
247    pub(crate) fn runtime(&self) -> &Arc<dyn Runtime> {
248        self.inner.runtime()
249    }
250
251    /// Weight charged or reported in the current UTC minute.
252    pub fn used(&self) -> u32 {
253        self.inner.used()
254    }
255
256    /// Time left in the current UTC minute: what a `429` with no `Retry-After`
257    /// and no retry left holds for.
258    pub(crate) fn until_next_minute(&self) -> Duration {
259        self.inner.until_next_minute()
260    }
261}
262
263/// The budget as core's send loop sees it.
264///
265/// [`acquire`](Throttle::acquire) charges the request's [`WEIGHT_LAYER`] cost
266/// against the minute, after any cooldown, and records the UTC minute it was
267/// charged in. [`observe`](Throttle::observe) applies `X-MBX-USED-WEIGHT-1M`
268/// to that minute alone, whatever the status. [`hold`](Throttle::hold) extends
269/// the cooldown.
270impl Throttle for WeightBudget {
271    async fn acquire(&self, meta: &RequestMeta<'_>) -> Result<Charge, Refused> {
272        Ok(self.inner.charge(meta, WEIGHT_LAYER, None).await)
273    }
274
275    fn observe(&self, charge: &Charge, response: &ResponseMeta<'_>, _attempt: &AttemptInfo) {
276        self.inner.observe(charge, response, WEIGHT_LAYER);
277    }
278
279    fn hold(&self, delay: Duration) {
280        self.inner.begin_cooldown(delay);
281    }
282}
283
284#[cfg(test)]
285mod tests {
286    use polyoxide_core::http::{
287        header::{HeaderMap, HeaderValue},
288        Method, StatusCode,
289    };
290
291    use super::*;
292
293    /// 2026-11-02 12:00:00 UTC, a minute boundary.
294    const ON_A_MINUTE: u64 = 1_793_620_800_000;
295
296    #[test]
297    fn documented_weights() {
298        let w = Cost::Weight;
299        let table = [
300            (Route::Ping, w(1)),
301            (Route::Time, w(1)),
302            (Route::ExchangeInfo, w(20)),
303            (Route::Depth { limit: None }, w(5)),
304            (Route::Depth { limit: Some(1) }, w(5)),
305            (Route::Depth { limit: Some(7) }, w(5)),
306            (Route::Depth { limit: Some(100) }, w(5)),
307            (Route::Depth { limit: Some(101) }, w(25)),
308            (Route::Depth { limit: Some(500) }, w(25)),
309            (Route::Depth { limit: Some(501) }, w(50)),
310            (Route::Depth { limit: Some(1000) }, w(50)),
311            (Route::Depth { limit: Some(1001) }, w(250)),
312            (Route::Depth { limit: Some(5000) }, w(250)),
313            (Route::Depth { limit: Some(5001) }, w(250)),
314            (Route::Trades, w(25)),
315            (Route::AggTrades, w(4)),
316            (Route::Klines, w(2)),
317            (Route::AvgPrice, w(2)),
318            (Route::Ticker24h { all: false }, w(2)),
319            (Route::Ticker24h { all: true }, w(80)),
320            (Route::Price { all: false }, w(2)),
321            (Route::Price { all: true }, w(4)),
322            (Route::BookTicker { all: false }, w(2)),
323            (Route::BookTicker { all: true }, w(4)),
324        ];
325        for (route, cost) in table {
326            assert_eq!(route.cost(), cost, "{route:?}");
327        }
328    }
329
330    #[test]
331    fn every_route_has_its_documented_path() {
332        let table = [
333            (Route::Ping, "/api/v3/ping"),
334            (Route::Time, "/api/v3/time"),
335            (Route::ExchangeInfo, "/api/v3/exchangeInfo"),
336            (Route::Depth { limit: None }, "/api/v3/depth"),
337            (Route::Trades, "/api/v3/trades"),
338            (Route::AggTrades, "/api/v3/aggTrades"),
339            (Route::Klines, "/api/v3/klines"),
340            (Route::AvgPrice, "/api/v3/avgPrice"),
341            (Route::Ticker24h { all: true }, "/api/v3/ticker/24hr"),
342            (Route::Ticker24h { all: false }, "/api/v3/ticker/24hr"),
343            (Route::Price { all: true }, "/api/v3/ticker/price"),
344            (Route::Price { all: false }, "/api/v3/ticker/price"),
345            (Route::BookTicker { all: true }, "/api/v3/ticker/bookTicker"),
346            (
347                Route::BookTicker { all: false },
348                "/api/v3/ticker/bookTicker",
349            ),
350        ];
351        for (route, path) in table {
352            assert_eq!(route.path(), path, "{route:?}");
353        }
354    }
355
356    #[test]
357    fn the_budget_aims_a_tenth_below_the_published_limit() {
358        assert_eq!(PUBLISHED_WEIGHT_PER_MINUTE, 6000);
359        assert_eq!(WeightBudget::new().per_minute(), 5400);
360    }
361
362    #[test]
363    fn a_cost_reaches_core_as_an_exact_charge_on_the_spot_layer() {
364        let cost = polyoxide_core::Cost::from(Cost::Weight(20));
365        assert_eq!(cost.layer, WEIGHT_LAYER);
366        assert_eq!(cost.units, 20);
367        assert!(cost.exact);
368        assert_eq!(WEIGHT_LAYER, LayerId("binance-spot-weight"));
369    }
370
371    fn meta<'a>(method: &'a Method, costs: &'a [polyoxide_core::Cost]) -> RequestMeta<'a> {
372        RequestMeta {
373            method,
374            path: "/api/v3/exchangeInfo",
375            query: &[],
376            costs,
377        }
378    }
379
380    #[tokio::test]
381    async fn the_throttle_charges_the_weight_layer_and_reads_the_header_back() {
382        let budget = WeightBudget::at_ms(ON_A_MINUTE);
383        let get = Method::GET;
384        let costs = [polyoxide_core::Cost::from(Cost::Weight(20))];
385        let charge = Throttle::acquire(&budget, &meta(&get, &costs))
386            .await
387            .unwrap();
388        assert_eq!(budget.used(), 20);
389
390        let mut headers = HeaderMap::new();
391        headers.insert("x-mbx-used-weight-1m", HeaderValue::from_static("100"));
392        let response = ResponseMeta {
393            status: StatusCode::OK,
394            headers: &headers,
395        };
396        let attempt = AttemptInfo {
397            attempt: 0,
398            retries_left: 0,
399        };
400        budget.observe(&charge, &response, &attempt);
401        assert_eq!(budget.used(), 100, "the server's count raises the minute's");
402    }
403
404    #[tokio::test]
405    async fn a_cost_on_another_layer_is_not_charged() {
406        let budget = WeightBudget::at_ms(ON_A_MINUTE);
407        let get = Method::GET;
408        let costs = [polyoxide_core::Cost {
409            layer: LayerId("some-other-layer"),
410            units: 50,
411            exact: true,
412        }];
413        Throttle::acquire(&budget, &meta(&get, &costs))
414            .await
415            .unwrap();
416        assert_eq!(budget.used(), 0);
417    }
418
419    #[test]
420    fn a_clone_shares_the_budget() {
421        let budget = WeightBudget::at_ms(ON_A_MINUTE);
422        let clone = budget.clone();
423        assert!(budget.is_shared_with(&clone));
424        assert!(!budget.is_shared_with(&WeightBudget::at_ms(ON_A_MINUTE)));
425    }
426
427    /// A runtime over tokio's that records its sleeps, so a paused-clock test
428    /// can say which waits went through it.
429    #[derive(Debug, Default)]
430    struct Counting {
431        inner: polyoxide_rt::TokioRuntime,
432        sleeps: std::sync::Mutex<Vec<Duration>>,
433    }
434
435    impl Runtime for Counting {
436        fn spawn(&self, task: polyoxide_rt::BoxFuture<()>) {
437            self.inner.spawn(task);
438        }
439
440        fn sleep(&self, duration: Duration) -> polyoxide_rt::BoxFuture<()> {
441            self.sleeps.lock().unwrap().push(duration);
442            self.inner.sleep(duration)
443        }
444
445        fn yield_now(&self) -> polyoxide_rt::BoxFuture<()> {
446            Box::pin(tokio::task::yield_now())
447        }
448
449        fn now(&self) -> polyoxide_rt::Instant {
450            self.inner.now()
451        }
452    }
453
454    #[tokio::test(start_paused = true)]
455    async fn the_wait_for_the_next_minute_is_slept_on_the_budgets_runtime() {
456        let counting = Arc::new(Counting::default());
457        let budget = WeightBudget::with_clock_on(
458            Clock::Tokio {
459                start: tokio::time::Instant::now(),
460                start_ms: ON_A_MINUTE + 15_000,
461            },
462            counting.clone(),
463        );
464        let get = Method::GET;
465        let full = [polyoxide_core::Cost::from(Cost::Weight(5400))];
466        Throttle::acquire(&budget, &meta(&get, &full))
467            .await
468            .unwrap();
469        let one = [polyoxide_core::Cost::from(Cost::Weight(1))];
470        Throttle::acquire(&budget, &meta(&get, &one)).await.unwrap();
471
472        // 15 s into the minute, 45 s are left of it.
473        let slept = counting.sleeps.lock().unwrap().clone();
474        assert!(
475            slept.contains(&Duration::from_secs(45)),
476            "the wait for the next minute did not sleep on the runtime: {slept:?}"
477        );
478    }
479
480    #[tokio::test(start_paused = true)]
481    async fn a_cooldown_is_waited_out_on_the_budgets_runtime() {
482        let counting = Arc::new(Counting::default());
483        let budget = WeightBudget::new_on(counting.clone());
484        assert!(Arc::ptr_eq(
485            budget.runtime(),
486            &(counting.clone() as Arc<dyn Runtime>)
487        ));
488
489        Throttle::hold(&budget, Duration::from_secs(7));
490        let get = Method::GET;
491        let costs = [polyoxide_core::Cost::from(Cost::Weight(1))];
492        Throttle::acquire(&budget, &meta(&get, &costs))
493            .await
494            .unwrap();
495
496        let slept = counting.sleeps.lock().unwrap().clone();
497        assert!(
498            slept
499                .iter()
500                .any(|d| *d > Duration::from_secs(6) && *d <= Duration::from_secs(7)),
501            "the cooldown was not waited out on the runtime: {slept:?}"
502        );
503    }
504}