use std::sync::Arc;
use std::time::Duration;
use polyoxide_core::{AttemptInfo, Charge, LayerId, Refused, RequestMeta, ResponseMeta, Throttle};
use polyoxide_rt::Runtime;
use crate::shared::request::Priced;
use crate::shared::weight::{Clock, Engine};
pub use crate::shared::weight::{DEFAULT_BAN, MAX_COOLDOWN};
pub const PUBLISHED_WEIGHT_PER_MINUTE: u32 = 6000;
pub const WEIGHT_LAYER: LayerId = LayerId("binance-spot-weight");
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Cost {
Weight(u32),
}
impl From<Cost> for polyoxide_core::Cost {
fn from(cost: Cost) -> Self {
match cost {
Cost::Weight(weight) => Self {
layer: WEIGHT_LAYER,
units: weight,
exact: true,
},
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[non_exhaustive]
pub enum Route {
Ping,
Time,
ExchangeInfo,
Depth {
limit: Option<u32>,
},
Trades,
AggTrades,
Klines,
AvgPrice,
Ticker24h {
all: bool,
},
Price {
all: bool,
},
BookTicker {
all: bool,
},
}
impl Route {
pub fn path(self) -> &'static str {
match self {
Self::Ping => "/api/v3/ping",
Self::Time => "/api/v3/time",
Self::ExchangeInfo => "/api/v3/exchangeInfo",
Self::Depth { .. } => "/api/v3/depth",
Self::Trades => "/api/v3/trades",
Self::AggTrades => "/api/v3/aggTrades",
Self::Klines => "/api/v3/klines",
Self::AvgPrice => "/api/v3/avgPrice",
Self::Ticker24h { .. } => "/api/v3/ticker/24hr",
Self::Price { .. } => "/api/v3/ticker/price",
Self::BookTicker { .. } => "/api/v3/ticker/bookTicker",
}
}
pub fn cost(self) -> Cost {
match self {
Self::Ping | Self::Time => Cost::Weight(1),
Self::ExchangeInfo => Cost::Weight(20),
Self::Depth { limit: None } => Cost::Weight(5),
Self::Depth { limit: Some(limit) } => Cost::Weight(match limit {
0..=100 => 5,
101..=500 => 25,
501..=1000 => 50,
_ => 250,
}),
Self::Trades => Cost::Weight(25),
Self::AggTrades => Cost::Weight(4),
Self::Klines | Self::AvgPrice => Cost::Weight(2),
Self::Ticker24h { all } => Cost::Weight(if all { 80 } else { 2 }),
Self::Price { all } | Self::BookTicker { all } => Cost::Weight(if all { 4 } else { 2 }),
}
}
}
impl Priced for Route {
type Cost = Cost;
fn path(self) -> &'static str {
Route::path(self)
}
fn cost(self) -> Cost {
Route::cost(self)
}
}
#[derive(Debug, Clone)]
pub struct WeightBudget {
inner: Arc<Engine>,
}
impl Default for WeightBudget {
fn default() -> Self {
Self::new()
}
}
impl WeightBudget {
pub fn new() -> Self {
Self::with_clock(Clock::System)
}
pub fn new_on(runtime: Arc<dyn Runtime>) -> Self {
Self::with_clock_on(Clock::System, runtime)
}
fn with_clock(clock: Clock) -> Self {
Self::with_clock_on(clock, polyoxide_rt::default_runtime())
}
fn with_clock_on(clock: Clock, runtime: Arc<dyn Runtime>) -> Self {
Self {
inner: Arc::new(Engine::new(
PUBLISHED_WEIGHT_PER_MINUTE,
None,
clock,
runtime,
)),
}
}
#[cfg(test)]
pub(crate) fn at_ms(start_ms: u64) -> Self {
Self::with_clock(Clock::Manual(Arc::new(std::sync::atomic::AtomicU64::new(
start_ms,
))))
}
#[cfg(test)]
pub(crate) fn is_shared_with(&self, other: &Self) -> bool {
Arc::ptr_eq(&self.inner, &other.inner)
}
pub fn per_minute(&self) -> u32 {
self.inner.per_minute()
}
#[cfg(test)]
pub(crate) fn runtime(&self) -> &Arc<dyn Runtime> {
self.inner.runtime()
}
pub fn used(&self) -> u32 {
self.inner.used()
}
pub(crate) fn until_next_minute(&self) -> Duration {
self.inner.until_next_minute()
}
}
impl Throttle for WeightBudget {
async fn acquire(&self, meta: &RequestMeta<'_>) -> Result<Charge, Refused> {
Ok(self.inner.charge(meta, WEIGHT_LAYER, None).await)
}
fn observe(&self, charge: &Charge, response: &ResponseMeta<'_>, _attempt: &AttemptInfo) {
self.inner.observe(charge, response, WEIGHT_LAYER);
}
fn hold(&self, delay: Duration) {
self.inner.begin_cooldown(delay);
}
}
#[cfg(test)]
mod tests {
use polyoxide_core::http::{
header::{HeaderMap, HeaderValue},
Method, StatusCode,
};
use super::*;
const ON_A_MINUTE: u64 = 1_793_620_800_000;
#[test]
fn documented_weights() {
let w = Cost::Weight;
let table = [
(Route::Ping, w(1)),
(Route::Time, w(1)),
(Route::ExchangeInfo, w(20)),
(Route::Depth { limit: None }, w(5)),
(Route::Depth { limit: Some(1) }, w(5)),
(Route::Depth { limit: Some(7) }, w(5)),
(Route::Depth { limit: Some(100) }, w(5)),
(Route::Depth { limit: Some(101) }, w(25)),
(Route::Depth { limit: Some(500) }, w(25)),
(Route::Depth { limit: Some(501) }, w(50)),
(Route::Depth { limit: Some(1000) }, w(50)),
(Route::Depth { limit: Some(1001) }, w(250)),
(Route::Depth { limit: Some(5000) }, w(250)),
(Route::Depth { limit: Some(5001) }, w(250)),
(Route::Trades, w(25)),
(Route::AggTrades, w(4)),
(Route::Klines, w(2)),
(Route::AvgPrice, w(2)),
(Route::Ticker24h { all: false }, w(2)),
(Route::Ticker24h { all: true }, w(80)),
(Route::Price { all: false }, w(2)),
(Route::Price { all: true }, w(4)),
(Route::BookTicker { all: false }, w(2)),
(Route::BookTicker { all: true }, w(4)),
];
for (route, cost) in table {
assert_eq!(route.cost(), cost, "{route:?}");
}
}
#[test]
fn every_route_has_its_documented_path() {
let table = [
(Route::Ping, "/api/v3/ping"),
(Route::Time, "/api/v3/time"),
(Route::ExchangeInfo, "/api/v3/exchangeInfo"),
(Route::Depth { limit: None }, "/api/v3/depth"),
(Route::Trades, "/api/v3/trades"),
(Route::AggTrades, "/api/v3/aggTrades"),
(Route::Klines, "/api/v3/klines"),
(Route::AvgPrice, "/api/v3/avgPrice"),
(Route::Ticker24h { all: true }, "/api/v3/ticker/24hr"),
(Route::Ticker24h { all: false }, "/api/v3/ticker/24hr"),
(Route::Price { all: true }, "/api/v3/ticker/price"),
(Route::Price { all: false }, "/api/v3/ticker/price"),
(Route::BookTicker { all: true }, "/api/v3/ticker/bookTicker"),
(
Route::BookTicker { all: false },
"/api/v3/ticker/bookTicker",
),
];
for (route, path) in table {
assert_eq!(route.path(), path, "{route:?}");
}
}
#[test]
fn the_budget_aims_a_tenth_below_the_published_limit() {
assert_eq!(PUBLISHED_WEIGHT_PER_MINUTE, 6000);
assert_eq!(WeightBudget::new().per_minute(), 5400);
}
#[test]
fn a_cost_reaches_core_as_an_exact_charge_on_the_spot_layer() {
let cost = polyoxide_core::Cost::from(Cost::Weight(20));
assert_eq!(cost.layer, WEIGHT_LAYER);
assert_eq!(cost.units, 20);
assert!(cost.exact);
assert_eq!(WEIGHT_LAYER, LayerId("binance-spot-weight"));
}
fn meta<'a>(method: &'a Method, costs: &'a [polyoxide_core::Cost]) -> RequestMeta<'a> {
RequestMeta {
method,
path: "/api/v3/exchangeInfo",
query: &[],
costs,
}
}
#[tokio::test]
async fn the_throttle_charges_the_weight_layer_and_reads_the_header_back() {
let budget = WeightBudget::at_ms(ON_A_MINUTE);
let get = Method::GET;
let costs = [polyoxide_core::Cost::from(Cost::Weight(20))];
let charge = Throttle::acquire(&budget, &meta(&get, &costs))
.await
.unwrap();
assert_eq!(budget.used(), 20);
let mut headers = HeaderMap::new();
headers.insert("x-mbx-used-weight-1m", HeaderValue::from_static("100"));
let response = ResponseMeta {
status: StatusCode::OK,
headers: &headers,
};
let attempt = AttemptInfo {
attempt: 0,
retries_left: 0,
};
budget.observe(&charge, &response, &attempt);
assert_eq!(budget.used(), 100, "the server's count raises the minute's");
}
#[tokio::test]
async fn a_cost_on_another_layer_is_not_charged() {
let budget = WeightBudget::at_ms(ON_A_MINUTE);
let get = Method::GET;
let costs = [polyoxide_core::Cost {
layer: LayerId("some-other-layer"),
units: 50,
exact: true,
}];
Throttle::acquire(&budget, &meta(&get, &costs))
.await
.unwrap();
assert_eq!(budget.used(), 0);
}
#[test]
fn a_clone_shares_the_budget() {
let budget = WeightBudget::at_ms(ON_A_MINUTE);
let clone = budget.clone();
assert!(budget.is_shared_with(&clone));
assert!(!budget.is_shared_with(&WeightBudget::at_ms(ON_A_MINUTE)));
}
#[derive(Debug, Default)]
struct Counting {
inner: polyoxide_rt::TokioRuntime,
sleeps: std::sync::Mutex<Vec<Duration>>,
}
impl Runtime for Counting {
fn spawn(&self, task: polyoxide_rt::BoxFuture<()>) {
self.inner.spawn(task);
}
fn sleep(&self, duration: Duration) -> polyoxide_rt::BoxFuture<()> {
self.sleeps.lock().unwrap().push(duration);
self.inner.sleep(duration)
}
fn yield_now(&self) -> polyoxide_rt::BoxFuture<()> {
Box::pin(tokio::task::yield_now())
}
fn now(&self) -> polyoxide_rt::Instant {
self.inner.now()
}
}
#[tokio::test(start_paused = true)]
async fn the_wait_for_the_next_minute_is_slept_on_the_budgets_runtime() {
let counting = Arc::new(Counting::default());
let budget = WeightBudget::with_clock_on(
Clock::Tokio {
start: tokio::time::Instant::now(),
start_ms: ON_A_MINUTE + 15_000,
},
counting.clone(),
);
let get = Method::GET;
let full = [polyoxide_core::Cost::from(Cost::Weight(5400))];
Throttle::acquire(&budget, &meta(&get, &full))
.await
.unwrap();
let one = [polyoxide_core::Cost::from(Cost::Weight(1))];
Throttle::acquire(&budget, &meta(&get, &one)).await.unwrap();
let slept = counting.sleeps.lock().unwrap().clone();
assert!(
slept.contains(&Duration::from_secs(45)),
"the wait for the next minute did not sleep on the runtime: {slept:?}"
);
}
#[tokio::test(start_paused = true)]
async fn a_cooldown_is_waited_out_on_the_budgets_runtime() {
let counting = Arc::new(Counting::default());
let budget = WeightBudget::new_on(counting.clone());
assert!(Arc::ptr_eq(
budget.runtime(),
&(counting.clone() as Arc<dyn Runtime>)
));
Throttle::hold(&budget, Duration::from_secs(7));
let get = Method::GET;
let costs = [polyoxide_core::Cost::from(Cost::Weight(1))];
Throttle::acquire(&budget, &meta(&get, &costs))
.await
.unwrap();
let slept = counting.sleeps.lock().unwrap().clone();
assert!(
slept
.iter()
.any(|d| *d > Duration::from_secs(6) && *d <= Duration::from_secs(7)),
"the cooldown was not waited out on the runtime: {slept:?}"
);
}
}