1use 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
26pub const PUBLISHED_WEIGHT_PER_MINUTE: u32 = 6000;
28
29pub const WEIGHT_LAYER: LayerId = LayerId("binance-spot-weight");
32
33#[derive(Debug, Clone, Copy, PartialEq, Eq)]
35pub enum Cost {
36 Weight(u32),
38}
39
40impl 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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
56#[non_exhaustive]
57pub enum Route {
58 Ping,
60 Time,
62 ExchangeInfo,
64 Depth {
66 limit: Option<u32>,
68 },
69 Trades,
71 AggTrades,
73 Klines,
75 AvgPrice,
77 Ticker24h {
79 all: bool,
81 },
82 Price {
84 all: bool,
86 },
87 BookTicker {
89 all: bool,
91 },
92}
93
94impl Route {
95 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 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#[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 pub fn new() -> Self {
202 Self::with_clock(Clock::System)
203 }
204
205 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 #[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 #[cfg(test)]
236 pub(crate) fn is_shared_with(&self, other: &Self) -> bool {
237 Arc::ptr_eq(&self.inner, &other.inner)
238 }
239
240 pub fn per_minute(&self) -> u32 {
242 self.inner.per_minute()
243 }
244
245 #[cfg(test)]
247 pub(crate) fn runtime(&self) -> &Arc<dyn Runtime> {
248 self.inner.runtime()
249 }
250
251 pub fn used(&self) -> u32 {
253 self.inner.used()
254 }
255
256 pub(crate) fn until_next_minute(&self) -> Duration {
259 self.inner.until_next_minute()
260 }
261}
262
263impl 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 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 #[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 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}