use std::{collections::HashMap, sync::Mutex, time::Instant};
use crate::{Path, enums::Category};
#[derive(Debug, Clone, Copy)]
pub struct BucketLimit {
pub requests_per_second: f64,
pub burst: u32,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub struct RateLimitKey {
pub path: Path,
pub category: Option<Category>,
}
impl RateLimitKey {
pub const fn new(path: Path) -> Self {
Self {
path,
category: None,
}
}
pub const fn with_category(path: Path, category: Category) -> Self {
Self {
path,
category: Some(category),
}
}
}
#[derive(Debug, Clone)]
pub struct RateLimiterConfig {
pub default: BucketLimit,
pub overrides: HashMap<RateLimitKey, BucketLimit>,
}
impl RateLimiterConfig {
pub fn uniform(requests_per_second: f64, burst: u32) -> Self {
Self {
default: BucketLimit {
requests_per_second,
burst,
},
overrides: HashMap::new(),
}
}
pub fn bybit_base_tier_defaults() -> Self {
let mut overrides = HashMap::new();
let mut set = |path: Path, category: Category, rps: f64, burst: u32| {
overrides.insert(
RateLimitKey::with_category(path, category),
BucketLimit {
requests_per_second: rps,
burst,
},
);
};
set(Path::TradeOrderCreate, Category::Linear, 20.0, 20);
set(Path::TradeOrderCreate, Category::Inverse, 10.0, 10);
set(Path::TradeOrderCreate, Category::Spot, 10.0, 10);
set(Path::TradeOrderCreate, Category::Option, 10.0, 10);
set(Path::TradeOrderAmend, Category::Linear, 10.0, 10);
set(Path::TradeOrderAmend, Category::Inverse, 10.0, 10);
set(Path::TradeOrderAmend, Category::Spot, 10.0, 10);
set(Path::TradeOrderAmend, Category::Option, 10.0, 10);
set(Path::TradeOrderCancel, Category::Linear, 20.0, 20);
set(Path::TradeOrderCancel, Category::Inverse, 10.0, 10);
set(Path::TradeOrderCancel, Category::Spot, 10.0, 10);
set(Path::TradeOrderCancel, Category::Option, 10.0, 10);
set(Path::TradeOrderCancelAll, Category::Linear, 20.0, 20);
set(Path::TradeOrderCancelAll, Category::Inverse, 1.0, 1);
set(Path::TradeOrderCancelAll, Category::Spot, 10.0, 10);
set(Path::TradeOrderCancelAll, Category::Option, 10.0, 10);
for path in [
Path::TradeOrderCreateBatch,
Path::TradeOrderAmendBatch,
Path::TradeOrderCancelBatch,
] {
set(path, Category::Linear, 20.0, 20);
set(path, Category::Inverse, 10.0, 10);
set(path, Category::Spot, 10.0, 10);
set(path, Category::Option, 10.0, 10);
}
overrides.insert(
RateLimitKey::new(Path::TradeOrderRealtime),
BucketLimit {
requests_per_second: 50.0,
burst: 50,
},
);
overrides.insert(
RateLimitKey::new(Path::TradeOrderHistory),
BucketLimit {
requests_per_second: 50.0,
burst: 50,
},
);
overrides.insert(
RateLimitKey::new(Path::AccountWalletBalance),
BucketLimit {
requests_per_second: 50.0,
burst: 50,
},
);
overrides.insert(
RateLimitKey::new(Path::AccountFeeRate),
BucketLimit {
requests_per_second: 5.0,
burst: 5,
},
);
Self {
default: BucketLimit {
requests_per_second: 5.0,
burst: 5,
},
overrides,
}
}
pub fn with_override(mut self, key: RateLimitKey, limit: BucketLimit) -> Self {
self.overrides.insert(key, limit);
self
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum Rejection {
RetryAfter(u64),
Unsatisfiable { cost: u32, burst: u32 },
}
pub(crate) struct RateLimiter {
config: RateLimiterConfig,
buckets: Mutex<HashMap<RateLimitKey, Bucket>>,
}
impl std::fmt::Debug for RateLimiter {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
let g = self.buckets.lock().unwrap();
f.debug_struct("RateLimiter")
.field("default", &self.config.default)
.field("overrides", &self.config.overrides.len())
.field("active_buckets", &g.len())
.finish()
}
}
struct Bucket {
limit: BucketLimit,
tokens: f64,
last_refill: Instant,
server_remaining: Option<u64>,
server_reset_at_ms: Option<u64>,
}
impl Bucket {
fn new(limit: BucketLimit) -> Self {
Self {
tokens: limit.burst as f64,
limit,
last_refill: Instant::now(),
server_remaining: None,
server_reset_at_ms: None,
}
}
}
impl RateLimiter {
pub(crate) fn new(config: RateLimiterConfig) -> Self {
Self {
config,
buckets: Mutex::new(HashMap::new()),
}
}
fn limit_for(&self, key: &RateLimitKey) -> BucketLimit {
self.config
.overrides
.get(key)
.copied()
.unwrap_or(self.config.default)
}
pub(crate) fn check(&self, key: RateLimitKey, cost: u32) -> Result<(), Rejection> {
let limit = self.limit_for(&key);
if cost > limit.burst {
return Err(Rejection::Unsatisfiable {
cost,
burst: limit.burst,
});
}
let mut g = self.buckets.lock().unwrap();
let bucket = g.entry(key).or_insert_with(|| Bucket::new(limit));
if let (Some(0), Some(reset_at)) = (bucket.server_remaining, bucket.server_reset_at_ms) {
let now_ms = unix_ms();
if now_ms < reset_at {
return Err(Rejection::RetryAfter(reset_at - now_ms));
}
bucket.server_remaining = None;
bucket.server_reset_at_ms = None;
}
let rps = bucket.limit.requests_per_second;
let refills = rps.is_finite() && rps > 0.0;
let now = Instant::now();
if refills {
let elapsed = now.duration_since(bucket.last_refill).as_secs_f64();
bucket.tokens = (bucket.tokens + elapsed * rps).min(bucket.limit.burst as f64);
}
bucket.last_refill = now;
let needed = cost as f64;
if bucket.tokens < needed {
if !refills {
return Err(Rejection::Unsatisfiable {
cost,
burst: bucket.limit.burst,
});
}
let wait_ms = ((needed - bucket.tokens) / rps * 1000.0).ceil() as u64;
return Err(Rejection::RetryAfter(wait_ms));
}
bucket.tokens -= needed;
Ok(())
}
pub(crate) fn update(
&self,
key: RateLimitKey,
remaining: Option<u64>,
reset_at_ms: Option<u64>,
) {
if remaining.is_none() && reset_at_ms.is_none() {
return;
}
let limit = self.limit_for(&key);
let mut g = self.buckets.lock().unwrap();
let bucket = g.entry(key).or_insert_with(|| Bucket::new(limit));
if let Some(r) = remaining {
bucket.server_remaining = Some(r);
}
if let Some(t) = reset_at_ms {
bucket.server_reset_at_ms = Some(t);
}
}
}
fn unix_ms() -> u64 {
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.as_millis() as u64
}
#[cfg(test)]
mod tests {
use super::*;
const KEY: RateLimitKey = RateLimitKey::new(Path::MarketTickers);
fn limiter(requests_per_second: f64, burst: u32) -> RateLimiter {
RateLimiter::new(RateLimiterConfig::uniform(requests_per_second, burst))
}
#[test]
fn burst_is_available_then_requests_are_rejected_with_retry_after() {
let rl = limiter(10.0, 2);
assert_eq!(rl.check(KEY, 1), Ok(()));
assert_eq!(rl.check(KEY, 1), Ok(()));
assert!(matches!(
rl.check(KEY, 1),
Err(Rejection::RetryAfter(ms)) if ms > 0 && ms <= 100
));
}
#[test]
fn tokens_refill_over_time() {
let rl = limiter(1000.0, 1);
assert_eq!(rl.check(KEY, 1), Ok(()));
std::thread::sleep(std::time::Duration::from_millis(5));
assert_eq!(rl.check(KEY, 1), Ok(()));
}
#[test]
fn cost_above_burst_is_unsatisfiable() {
let rl = limiter(10.0, 5);
assert_eq!(
rl.check(KEY, 10),
Err(Rejection::Unsatisfiable { cost: 10, burst: 5 })
);
}
#[test]
fn non_refilling_bucket_is_unsatisfiable_once_empty() {
let rl = limiter(0.0, 1);
assert_eq!(rl.check(KEY, 1), Ok(()));
assert_eq!(
rl.check(KEY, 1),
Err(Rejection::Unsatisfiable { cost: 1, burst: 1 })
);
}
#[test]
fn server_reported_exhaustion_blocks_until_reset() {
let rl = limiter(10.0, 10);
rl.update(KEY, Some(0), Some(unix_ms() + 60_000));
assert!(matches!(rl.check(KEY, 1), Err(Rejection::RetryAfter(_))));
}
#[test]
fn base_tier_defaults_cover_option_batches() {
let config = RateLimiterConfig::bybit_base_tier_defaults();
let key = RateLimitKey::with_category(Path::TradeOrderCreateBatch, Category::Option);
assert!(config.overrides[&key].burst >= 10);
}
}