use std::sync::{Arc, Mutex};
use std::time::Duration;
use governor::Quota;
use reqwest::Method;
use tokio::time::Instant;
type DirectLimiter = governor::RateLimiter<
governor::state::NotKeyed,
governor::state::InMemoryState,
governor::clock::DefaultClock,
>;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[allow(dead_code)]
enum MatchMode {
Prefix,
Exact,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
struct RateSpec {
count: u32,
period: Duration,
}
struct Bucket {
#[cfg_attr(not(test), allow(dead_code))]
spec: RateSpec,
limiter: DirectLimiter,
}
impl Bucket {
fn new(count: u32, period: Duration) -> Arc<Self> {
Arc::new(Self {
spec: RateSpec { count, period },
limiter: DirectLimiter::direct(quota(count, period)),
})
}
}
struct EndpointLimit {
path_prefix: &'static str,
method: Option<Method>,
match_mode: MatchMode,
buckets: Vec<Arc<Bucket>>,
}
impl EndpointLimit {
fn matches(&self, path: &str, method: Option<&Method>) -> bool {
let path_matches = match self.match_mode {
MatchMode::Exact => path == self.path_prefix,
MatchMode::Prefix => {
match path.strip_prefix(self.path_prefix) {
Some(rest) => rest.is_empty() || rest.starts_with('/') || rest.starts_with('?'),
None => false,
}
}
};
if !path_matches {
return false;
}
match &self.method {
Some(expected) => method == Some(expected),
None => true,
}
}
}
#[derive(Clone)]
pub struct RateLimiter {
inner: Arc<RateLimiterInner>,
}
impl std::fmt::Debug for RateLimiter {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("RateLimiter")
.field("endpoints", &self.inner.limits.len())
.finish()
}
}
struct RateLimiterInner {
limits: Vec<EndpointLimit>,
default: DirectLimiter,
cooldown_until: Mutex<Option<Instant>>,
}
fn quota(count: u32, period: Duration) -> Quota {
Quota::with_period(period / sustained_slots(count)).expect("quota interval must be non-zero")
}
fn sustained_slots(count: u32) -> u32 {
let target = count.saturating_sub(count.div_ceil(RESERVED_FRACTION));
target.max(2) - 1
}
const RESERVED_FRACTION: u32 = 10;
#[cfg(test)]
mod quota_arithmetic {
use super::*;
fn admitted_in_one_window(q: &Quota, period: Duration) -> u128 {
let refilled = period.as_nanos() / q.replenish_interval().as_nanos();
u128::from(q.burst_size().get()) + refilled
}
const PUBLISHED_SHAPES: &[(u32, u64)] = &[
(9_000, 10), (4_000, 10), (1_000, 10), (25, 60), (150, 10), (200, 10), (300, 10), (350, 10), (500, 10), (100, 10), (50, 10), (5_000, 10), (120_000, 600), ];
#[test]
fn no_quota_admits_more_than_its_published_count_in_one_window() {
for &(count, secs) in PUBLISHED_SHAPES {
let period = Duration::from_secs(secs);
let q = quota(count, period);
let admitted = admitted_in_one_window(&q, period);
assert!(
admitted <= u128::from(count),
"{count}/{secs}s admits {admitted} in one window \
({} burst + {} refilled) — the published quota is spent twice",
q.burst_size(),
admitted - u128::from(q.burst_size().get()),
);
}
}
#[test]
fn every_quota_reserves_headroom_below_the_published_count() {
for &(count, secs) in PUBLISHED_SHAPES {
let period = Duration::from_secs(secs);
let admitted = admitted_in_one_window("a(count, period), period);
let ceiling = u128::from(count - count.div_ceil(RESERVED_FRACTION));
assert!(
admitted <= ceiling,
"{count}/{secs}s admits {admitted} in one window, above the {ceiling} \
the reserve allows — no headroom under the published cap"
);
}
}
#[test]
fn every_configured_bucket_satisfies_the_quota_it_publishes() {
for (surface, rl) in [
("clob", RateLimiter::clob_default()),
("gamma", RateLimiter::gamma_default()),
("data", RateLimiter::data_default()),
("relay", RateLimiter::relay_default()),
] {
for limit in &rl.inner.limits {
for bucket in &limit.buckets {
let RateSpec { count, period } = bucket.spec;
let admitted = admitted_in_one_window("a(count, period), period);
assert!(
admitted <= u128::from(count),
"{surface} {} is published as {count}/{period:?} but admits \
{admitted} in one window",
limit.path_prefix,
);
}
}
}
}
}
fn endpoint_limit(
path_prefix: &'static str,
method: Option<Method>,
buckets: Vec<Arc<Bucket>>,
) -> EndpointLimit {
EndpointLimit {
path_prefix,
method,
match_mode: MatchMode::Prefix,
buckets,
}
}
fn simple_limit(
path_prefix: &'static str,
method: Option<Method>,
count: u32,
period: Duration,
) -> EndpointLimit {
endpoint_limit(path_prefix, method, vec![Bucket::new(count, period)])
}
fn dual_limit(
path_prefix: &'static str,
method: Method,
burst: (u32, Duration),
sustained: (u32, Duration),
) -> EndpointLimit {
endpoint_limit(
path_prefix,
Some(method),
vec![
Bucket::new(burst.0, burst.1),
Bucket::new(sustained.0, sustained.1),
],
)
}
impl RateLimiter {
pub fn begin_cooldown(&self, delay: Duration) {
let until = Instant::now() + delay;
let mut slot = self.lock_cooldown();
if slot.is_none_or(|current| until > current) {
*slot = Some(until);
}
}
fn lock_cooldown(&self) -> std::sync::MutexGuard<'_, Option<Instant>> {
self.inner
.cooldown_until
.lock()
.unwrap_or_else(|poisoned| poisoned.into_inner())
}
async fn await_cooldown(&self) {
loop {
let deadline = *self.lock_cooldown();
let Some(deadline) = deadline else { return };
if deadline <= Instant::now() {
return;
}
tokio::time::sleep_until(deadline).await;
}
}
pub async fn acquire(&self, path: &str, method: Option<&Method>) {
self.await_cooldown().await;
self.inner.default.until_ready().await;
if let Some(limit) = self.inner.limits.iter().find(|l| l.matches(path, method)) {
for bucket in &limit.buckets {
bucket.limiter.until_ready().await;
}
}
}
#[cfg(test)]
fn resolve_specs(&self, path: &str, method: Option<&Method>) -> Vec<RateSpec> {
self.inner
.limits
.iter()
.find(|l| l.matches(path, method))
.map(|l| l.buckets.iter().map(|b| b.spec).collect())
.unwrap_or_default()
}
pub fn clob_default() -> Self {
let ten_sec = Duration::from_secs(10);
let ten_min = Duration::from_secs(600);
let get = Some(Method::GET);
let ledger_group = Bucket::new(900, ten_sec);
Self {
inner: Arc::new(RateLimiterInner {
default: DirectLimiter::direct(quota(9_000, ten_sec)),
cooldown_until: Mutex::new(None),
limits: vec![
simple_limit("/balance-allowance/update", None, 50, ten_sec),
simple_limit("/balance-allowance", None, 200, ten_sec),
dual_limit("/order", Method::POST, (5_000, ten_sec), (120_000, ten_min)),
dual_limit(
"/order",
Method::DELETE,
(5_000, ten_sec),
(120_000, ten_min),
),
dual_limit("/orders", Method::POST, (2_000, ten_sec), (21_000, ten_min)),
dual_limit(
"/orders",
Method::DELETE,
(2_000, ten_sec),
(15_000, ten_min),
),
dual_limit(
"/cancel-all",
Method::DELETE,
(250, ten_sec),
(6_000, ten_min),
),
dual_limit(
"/cancel-market-orders",
Method::DELETE,
(1_500, ten_sec),
(21_000, ten_min),
),
endpoint_limit(
"/notifications",
None,
vec![ledger_group.clone(), Bucket::new(125, ten_sec)],
),
endpoint_limit("/trades", get.clone(), vec![ledger_group.clone()]),
endpoint_limit("/orders", get.clone(), vec![ledger_group.clone()]),
endpoint_limit("/order", get.clone(), vec![ledger_group]),
simple_limit("/data/orders", None, 500, ten_sec),
simple_limit("/data/trades", None, 500, ten_sec),
simple_limit("/data", None, 500, ten_sec),
simple_limit("/auth", None, 100, ten_sec),
simple_limit("/prices-history", None, 1_000, ten_sec),
simple_limit("/book", None, 1_500, ten_sec),
simple_limit("/books", None, 500, ten_sec),
simple_limit("/price", None, 1_500, ten_sec),
simple_limit("/prices", None, 500, ten_sec),
simple_limit("/midpoint", None, 1_500, ten_sec),
simple_limit("/midpoints", None, 500, ten_sec),
simple_limit("/tick-size", None, 200, ten_sec),
simple_limit("/ok", None, 100, ten_sec),
simple_limit("/markets", None, 1_500, ten_sec),
simple_limit("/neg-risk", None, 1_500, ten_sec),
],
}),
}
}
pub fn gamma_default() -> Self {
let ten_sec = Duration::from_secs(10);
Self {
inner: Arc::new(RateLimiterInner {
default: DirectLimiter::direct(quota(4_000, ten_sec)),
cooldown_until: Mutex::new(None),
limits: vec![
simple_limit("/comments", None, 200, ten_sec),
simple_limit("/tags", None, 200, ten_sec),
simple_limit("/markets", None, 300, ten_sec),
simple_limit("/public-search", None, 350, ten_sec),
simple_limit("/events", None, 500, ten_sec),
simple_limit("/status", None, 100, ten_sec),
],
}),
}
}
pub fn data_default() -> Self {
let ten_sec = Duration::from_secs(10);
Self {
inner: Arc::new(RateLimiterInner {
default: DirectLimiter::direct(quota(1_000, ten_sec)),
cooldown_until: Mutex::new(None),
limits: vec![
simple_limit("/closed-positions", None, 150, ten_sec),
simple_limit("/positions", None, 150, ten_sec),
simple_limit("/trades", None, 200, ten_sec),
simple_limit("/user-pnl", None, 200, ten_sec),
simple_limit("/", None, 100, ten_sec),
],
}),
}
}
pub fn relay_default() -> Self {
Self {
inner: Arc::new(RateLimiterInner {
default: DirectLimiter::direct(quota(25, Duration::from_secs(60))),
cooldown_until: Mutex::new(None),
limits: vec![],
}),
}
}
}
#[derive(Debug, Clone)]
pub struct RetryConfig {
pub max_retries: u32,
pub initial_backoff_ms: u64,
pub max_backoff_ms: u64,
}
impl Default for RetryConfig {
fn default() -> Self {
Self {
max_retries: 3,
initial_backoff_ms: 500,
max_backoff_ms: 10_000,
}
}
}
impl RetryConfig {
pub fn backoff(&self, attempt: u32) -> Duration {
let base = self
.initial_backoff_ms
.saturating_mul(1u64 << attempt.min(10));
let capped = base.min(self.max_backoff_ms);
let jitter_factor = 0.75 + (fastrand::f64() * 0.5);
let ms = (capped as f64 * jitter_factor) as u64;
Duration::from_millis(ms.max(1))
}
}
#[cfg(test)]
mod agreement {
use super::*;
pub type DocumentedRule = (&'static str, Option<Method>, Vec<(u32, u64)>);
pub fn assert_matches_published(rl: &RateLimiter, rules: Vec<DocumentedRule>, general: u32) {
for (path, method, expected) in rules {
let resolved = rl.resolve_specs(path, method.as_ref());
assert!(
!resolved.is_empty(),
"{method:?} {path} matches no endpoint limit — it falls through to the \
general {general}/10s bucket, over-permitting by {}x",
general / expected[0].0.max(1),
);
let actual: Vec<(u32, u64)> = resolved
.iter()
.map(|s| (s.count, s.period.as_secs()))
.collect();
assert_eq!(
actual, expected,
"{method:?} {path} resolves to {actual:?}, published limit is {expected:?}"
);
}
}
pub fn assert_unconfigured(rl: &RateLimiter, path: &str) {
assert!(
rl.resolve_specs(path, Some(&Method::GET)).is_empty(),
"{path} has an endpoint limit configured, but the host answers 404 there — \
the entry is dead configuration and the real route is going unlimited"
);
}
pub async fn assert_paced_by_its_own_quota(
rl: &RateLimiter,
path: &str,
count: u32,
period: Duration,
) {
let interval = period / sustained_slots(count);
rl.acquire(path, Some(&Method::GET)).await;
let start = std::time::Instant::now();
rl.acquire(path, Some(&Method::GET)).await;
let waited = start.elapsed();
assert!(
waited >= interval.mul_f64(0.8),
"the 2nd request to {path} returned in {waited:?}; {count}/{period:?} should pace \
it at {interval:?} and the cap is not being enforced"
);
assert!(
waited <= interval * 3 + Duration::from_millis(25),
"the 2nd request to {path} waited {waited:?}, far longer than the {interval:?} its \
published {count}/{period:?} implies — it is resolving through a tighter rule"
);
}
}
#[cfg(test)]
mod documented_data_limits {
use super::agreement::*;
use super::*;
fn documented() -> Vec<DocumentedRule> {
vec![
("/trades", Some(Method::GET), vec![(200, 10)]),
("/positions", Some(Method::GET), vec![(150, 10)]),
("/closed-positions", Some(Method::GET), vec![(150, 10)]),
("/", Some(Method::GET), vec![(100, 10)]),
("/user-pnl", Some(Method::GET), vec![(200, 10)]),
]
}
#[test]
fn every_documented_endpoint_resolves_to_its_published_quota() {
assert_matches_published(&RateLimiter::data_default(), documented(), 1_000);
}
#[test]
fn the_health_cap_is_attached_to_the_route_the_host_answers_on() {
assert_unconfigured(&RateLimiter::data_default(), "/ok");
}
#[test]
fn the_root_health_rule_does_not_swallow_every_other_route() {
let rl = RateLimiter::data_default();
for (path, expected) in [
("/positions", 150),
("/closed-positions", 150),
("/trades", 200),
("/", 100),
] {
let specs = rl.resolve_specs(path, Some(&Method::GET));
assert_eq!(
specs[0].count, expected,
"{path} resolved through the wrong rule — the `/` entry is over-matching"
);
}
}
#[tokio::test]
async fn the_closed_positions_cap_actually_throttles() {
assert_paced_by_its_own_quota(
&RateLimiter::data_default(),
"/closed-positions",
150,
Duration::from_secs(10),
)
.await;
}
#[tokio::test]
async fn closed_positions_and_positions_do_not_share_an_allowance() {
let rl = RateLimiter::data_default();
rl.acquire("/closed-positions", Some(&Method::GET)).await;
let start = std::time::Instant::now();
rl.acquire("/positions", Some(&Method::GET)).await;
assert!(
start.elapsed() < Duration::from_millis(25),
"/positions was throttled by /closed-positions emptying its own bucket"
);
}
}
#[cfg(test)]
mod documented_gamma_limits {
use super::agreement::*;
use super::*;
fn documented() -> Vec<DocumentedRule> {
vec![
("/events", Some(Method::GET), vec![(500, 10)]),
("/public-search", Some(Method::GET), vec![(350, 10)]),
("/markets", Some(Method::GET), vec![(300, 10)]),
("/comments", Some(Method::GET), vec![(200, 10)]),
("/tags", Some(Method::GET), vec![(200, 10)]),
("/status", Some(Method::GET), vec![(100, 10)]),
]
}
#[test]
fn every_documented_endpoint_resolves_to_its_published_quota() {
assert_matches_published(&RateLimiter::gamma_default(), documented(), 4_000);
}
#[test]
fn the_health_cap_is_attached_to_the_route_the_host_answers_on() {
assert_unconfigured(&RateLimiter::gamma_default(), "/ok");
}
#[test]
fn the_markets_plus_events_group_cap_can_never_bind() {
let rl = RateLimiter::gamma_default();
let markets = rl.resolve_specs("/markets", Some(&Method::GET))[0].count;
let events = rl.resolve_specs("/events", Some(&Method::GET))[0].count;
assert!(
markets + events <= 900,
"/markets ({markets}) + /events ({events}) now exceeds the published 900/10s \
group cap, which is no longer unreachable and must be modelled"
);
}
#[tokio::test]
async fn the_markets_cap_actually_throttles() {
assert_paced_by_its_own_quota(
&RateLimiter::gamma_default(),
"/markets",
300,
Duration::from_secs(10),
)
.await;
}
}
#[cfg(test)]
mod documented_limits {
use super::agreement::{assert_paced_by_its_own_quota, DocumentedRule};
use super::*;
fn documented() -> Vec<DocumentedRule> {
vec![
("/balance-allowance", Some(Method::GET), vec![(200, 10)]),
(
"/balance-allowance/update",
Some(Method::GET),
vec![(50, 10)],
),
(
"/order",
Some(Method::POST),
vec![(5_000, 10), (120_000, 600)],
),
(
"/order",
Some(Method::DELETE),
vec![(5_000, 10), (120_000, 600)],
),
(
"/orders",
Some(Method::POST),
vec![(2_000, 10), (21_000, 600)],
),
(
"/orders",
Some(Method::DELETE),
vec![(2_000, 10), (15_000, 600)],
),
(
"/cancel-all",
Some(Method::DELETE),
vec![(250, 10), (6_000, 600)],
),
(
"/cancel-market-orders",
Some(Method::DELETE),
vec![(1_500, 10), (21_000, 600)],
),
("/trades", Some(Method::GET), vec![(900, 10)]),
("/orders", Some(Method::GET), vec![(900, 10)]),
("/order", Some(Method::GET), vec![(900, 10)]),
(
"/notifications",
Some(Method::GET),
vec![(900, 10), (125, 10)],
),
("/data/orders", Some(Method::GET), vec![(500, 10)]),
("/data/trades", Some(Method::GET), vec![(500, 10)]),
("/book", Some(Method::GET), vec![(1_500, 10)]),
("/books", Some(Method::POST), vec![(500, 10)]),
("/price", Some(Method::GET), vec![(1_500, 10)]),
("/prices", Some(Method::POST), vec![(500, 10)]),
("/midpoint", Some(Method::GET), vec![(1_500, 10)]),
("/midpoints", Some(Method::POST), vec![(500, 10)]),
("/prices-history", Some(Method::GET), vec![(1_000, 10)]),
("/tick-size", Some(Method::GET), vec![(200, 10)]),
("/auth/api-key", Some(Method::POST), vec![(100, 10)]),
("/ok", Some(Method::GET), vec![(100, 10)]),
]
}
#[test]
fn every_documented_endpoint_resolves_to_its_published_quota() {
let rl = RateLimiter::clob_default();
for (path, method, expected) in documented() {
let resolved = rl.resolve_specs(path, method.as_ref());
assert!(
!resolved.is_empty(),
"{method:?} {path} matches no endpoint limit — it falls through to the \
general {}/10s bucket, over-permitting by {}x",
9_000,
9_000 / expected[0].0.max(1),
);
let actual: Vec<(u32, u64)> = resolved
.iter()
.map(|s| (s.count, s.period.as_secs()))
.collect();
assert_eq!(
actual, expected,
"{method:?} {path} resolves to {actual:?}, published limit is {expected:?}"
);
}
}
#[test]
fn batch_endpoints_do_not_inherit_their_singular_sibling() {
let rl = RateLimiter::clob_default();
for (batch, singular) in [
("/books", "/book"),
("/prices", "/price"),
("/midpoints", "/midpoint"),
] {
let batch_specs = rl.resolve_specs(batch, Some(&Method::POST));
let singular_specs = rl.resolve_specs(singular, Some(&Method::GET));
assert_ne!(
batch_specs, singular_specs,
"{batch} is being limited as if it were {singular}"
);
assert_eq!(batch_specs[0].count, 500, "{batch} should allow 500/10s");
}
}
#[test]
fn the_ledger_group_cap_is_one_shared_bucket() {
let rl = RateLimiter::clob_default();
let group: Vec<_> = ["/trades", "/orders", "/order", "/notifications"]
.iter()
.map(|p| {
rl.inner
.limits
.iter()
.find(|l| l.matches(p, Some(&Method::GET)))
.unwrap_or_else(|| panic!("{p} should match a ledger entry"))
.buckets[0]
.clone()
})
.collect();
for other in &group[1..] {
assert!(
Arc::ptr_eq(&group[0], other),
"ledger endpoints must share one bucket, not hold copies"
);
}
}
#[test]
fn balance_allowance_update_is_not_shadowed_by_its_parent_path() {
let rl = RateLimiter::clob_default();
let update = rl.resolve_specs("/balance-allowance/update", Some(&Method::GET));
assert_eq!(
update[0].count, 50,
"the tighter /balance-allowance/update rule must be ordered first"
);
}
#[tokio::test]
async fn a_documented_cap_actually_throttles() {
assert_paced_by_its_own_quota(
&RateLimiter::clob_default(),
"/tick-size",
200,
Duration::from_secs(10),
)
.await;
}
#[tokio::test]
async fn the_ledger_group_allowance_is_consumed_jointly() {
let rl = RateLimiter::clob_default();
rl.acquire("/trades", Some(&Method::GET)).await;
let start = std::time::Instant::now();
rl.acquire("/orders", Some(&Method::GET)).await;
let waited = start.elapsed();
assert!(
waited >= Duration::from_millis(5),
"GET /orders returned in {waited:?} after /trades consumed from the shared 900/10s \
allowance — the group cap is not actually shared"
);
}
#[test]
fn post_order_is_not_throttled_by_the_ledger_group() {
let rl = RateLimiter::clob_default();
let specs = rl.resolve_specs("/order", Some(&Method::POST));
assert_eq!(specs[0].count, 5_000);
assert!(
!specs.iter().any(|s| s.count == 900),
"POST /order must not be caught by the ledger read cap"
);
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_retry_config_default() {
let cfg = RetryConfig::default();
assert_eq!(cfg.max_retries, 3);
assert_eq!(cfg.initial_backoff_ms, 500);
assert_eq!(cfg.max_backoff_ms, 10_000);
}
#[test]
fn test_backoff_attempt_zero() {
let cfg = RetryConfig::default();
let d = cfg.backoff(0);
let ms = d.as_millis() as u64;
assert!(
(375..=625).contains(&ms),
"attempt 0: {ms}ms not in [375, 625]"
);
}
#[test]
fn test_backoff_exponential_growth() {
let cfg = RetryConfig::default();
let d0 = cfg.backoff(0);
let d1 = cfg.backoff(1);
let d2 = cfg.backoff(2);
assert!(d0 < d1, "d0={d0:?} should be < d1={d1:?}");
assert!(d1 < d2, "d1={d1:?} should be < d2={d2:?}");
}
#[test]
fn test_backoff_jitter_bounds() {
let cfg = RetryConfig::default();
for attempt in 0..20 {
let d = cfg.backoff(attempt);
let base = cfg
.initial_backoff_ms
.saturating_mul(1u64 << attempt.min(10));
let capped = base.min(cfg.max_backoff_ms);
let lower = (capped as f64 * 0.75) as u64;
let upper = (capped as f64 * 1.25) as u64;
let ms = d.as_millis() as u64;
assert!(
ms >= lower.max(1) && ms <= upper,
"attempt {attempt}: {ms}ms not in [{lower}, {upper}]"
);
}
}
#[test]
fn test_backoff_max_capping() {
let cfg = RetryConfig::default();
for attempt in 5..=10 {
let d = cfg.backoff(attempt);
let ceiling = (cfg.max_backoff_ms as f64 * 1.25) as u64;
assert!(
d.as_millis() as u64 <= ceiling,
"attempt {attempt}: {:?} exceeded ceiling {ceiling}ms",
d
);
}
}
#[test]
fn test_backoff_very_high_attempt() {
let cfg = RetryConfig::default();
let d = cfg.backoff(100);
let ceiling = (cfg.max_backoff_ms as f64 * 1.25) as u64;
assert!(d.as_millis() as u64 <= ceiling);
assert!(d.as_millis() >= 1);
}
#[test]
fn test_backoff_jitter_distribution() {
let cfg = RetryConfig::default();
let midpoint = cfg.initial_backoff_ms; let (mut below, mut above) = (0u32, 0u32);
for _ in 0..200 {
let ms = cfg.backoff(0).as_millis() as u64;
if ms < midpoint {
below += 1;
} else {
above += 1;
}
}
assert!(
below >= 20 && above >= 20,
"jitter looks degenerate: {below} below midpoint, {above} above"
);
}
#[test]
fn test_quota_creation() {
let _ = quota(100, Duration::from_secs(10));
let _ = quota(1, Duration::from_secs(60));
let _ = quota(9_000, Duration::from_secs(10));
}
#[test]
fn test_quota_edge_zero_count() {
let _ = quota(0, Duration::from_secs(10));
let _ = quota(1, Duration::from_secs(10));
}
#[test]
fn test_clob_default_construction() {
let rl = RateLimiter::clob_default();
assert_eq!(rl.inner.limits.len(), 27);
assert!(format!("{:?}", rl).contains("endpoints"));
}
#[test]
fn test_gamma_default_construction() {
let rl = RateLimiter::gamma_default();
assert_eq!(rl.inner.limits.len(), 6);
}
#[test]
fn test_data_default_construction() {
let rl = RateLimiter::data_default();
assert_eq!(rl.inner.limits.len(), 5);
}
#[test]
fn test_relay_default_construction() {
let rl = RateLimiter::relay_default();
assert_eq!(rl.inner.limits.len(), 0);
}
#[test]
fn test_rate_limiter_debug_format() {
let rl = RateLimiter::clob_default();
let dbg = format!("{:?}", rl);
assert!(dbg.contains("RateLimiter"), "missing struct name: {dbg}");
assert!(dbg.contains("endpoints: 27"), "missing count: {dbg}");
}
#[test]
fn test_clob_tighter_rules_precede_the_prefixes_that_would_shadow_them() {
let rl = RateLimiter::clob_default();
let index_of = |path: &str| {
rl.inner
.limits
.iter()
.position(|l| l.path_prefix == path)
.unwrap_or_else(|| panic!("{path} should be configured"))
};
for (specific, general) in [
("/balance-allowance/update", "/balance-allowance"),
("/data/orders", "/data"),
("/data/trades", "/data"),
] {
assert!(
index_of(specific) < index_of(general),
"{specific} must be matched before {general} or it can never win"
);
}
}
#[tokio::test]
async fn test_acquire_single_completes_immediately() {
let rl = RateLimiter::clob_default();
let start = std::time::Instant::now();
rl.acquire("/order", Some(&Method::POST)).await;
assert!(start.elapsed() < Duration::from_millis(50));
}
#[tokio::test]
async fn test_acquire_matches_endpoint_by_prefix() {
let rl = RateLimiter::clob_default();
let start = std::time::Instant::now();
rl.acquire("/order/123", Some(&Method::POST)).await;
assert!(start.elapsed() < Duration::from_millis(50));
}
#[tokio::test]
async fn test_acquire_prefix_respects_segment_boundary() {
let rl = RateLimiter::clob_default();
let limits = &rl.inner.limits;
let price_idx = limits
.iter()
.position(|l| l.path_prefix == "/price")
.expect("/price endpoint exists");
let prices_history_idx = limits
.iter()
.position(|l| l.path_prefix == "/prices-history")
.expect("/prices-history endpoint exists");
assert!(
prices_history_idx < price_idx,
"/prices-history (idx {prices_history_idx}) should come before /price (idx {price_idx})"
);
}
#[test]
fn test_match_mode_prefix_segment_boundary() {
let pattern = "/price";
let check = |path: &str| -> bool {
match path.strip_prefix(pattern) {
Some(rest) => rest.is_empty() || rest.starts_with('/') || rest.starts_with('?'),
None => false,
}
};
assert!(check("/price"), "exact match");
assert!(check("/price/foo"), "sub-path");
assert!(check("/price?token=abc"), "query params");
assert!(!check("/prices-history"), "partial word /prices-history");
assert!(!check("/pricelist"), "partial word /pricelist");
assert!(!check("/pricing"), "partial word /pricing");
assert!(!check("/midpoint"), "different prefix");
}
#[test]
fn test_match_mode_exact() {
let pattern = "/trades";
let check = |path: &str| -> bool { path == pattern };
assert!(check("/trades"), "exact match");
assert!(!check("/trades/123"), "sub-path should not match");
assert!(!check("/trades?limit=10"), "query params should not match");
assert!(!check("/traded"), "different word should not match");
}
#[tokio::test]
async fn test_acquire_method_filtering() {
let rl = RateLimiter::clob_default();
let start = std::time::Instant::now();
rl.acquire("/order", Some(&Method::GET)).await;
assert!(start.elapsed() < Duration::from_millis(50));
}
#[tokio::test]
async fn test_acquire_no_endpoint_match_uses_default_only() {
let rl = RateLimiter::clob_default();
let start = std::time::Instant::now();
rl.acquire("/unknown/path", None).await;
assert!(start.elapsed() < Duration::from_millis(50));
}
#[tokio::test]
async fn test_acquire_method_none_matches_any_method() {
let rl = RateLimiter::gamma_default();
let start = std::time::Instant::now();
rl.acquire("/events", Some(&Method::GET)).await;
rl.acquire("/events", Some(&Method::POST)).await;
rl.acquire("/events", None).await;
assert!(start.elapsed() < Duration::from_millis(50));
}
#[test]
fn test_clob_price_and_prices_history_are_distinct() {
let rl = RateLimiter::clob_default();
let limits = &rl.inner.limits;
let price = limits.iter().find(|l| l.path_prefix == "/price").unwrap();
let prices_history = limits
.iter()
.find(|l| l.path_prefix == "/prices-history")
.unwrap();
assert_eq!(price.match_mode, MatchMode::Prefix);
assert_eq!(prices_history.match_mode, MatchMode::Prefix);
if let Some(rest) = "/prices-history".strip_prefix(price.path_prefix) {
assert!(
!rest.is_empty() && !rest.starts_with('/') && !rest.starts_with('?'),
"/prices-history must not match /price pattern, rest = '{rest}'"
);
}
}
#[test]
fn test_data_positions_and_closed_positions_are_distinct() {
let rl = RateLimiter::data_default();
let closed = rl.resolve_specs("/closed-positions", Some(&Method::GET));
let positions = rl.resolve_specs("/positions", Some(&Method::GET));
assert_eq!(closed, positions, "both are published at 150/10s");
let bucket_for = |path: &str| {
rl.inner
.limits
.iter()
.find(|l| l.matches(path, Some(&Method::GET)))
.unwrap_or_else(|| panic!("{path} should match a rule"))
.buckets[0]
.clone()
};
assert!(
!Arc::ptr_eq(&bucket_for("/closed-positions"), &bucket_for("/positions")),
"equal quotas must still be separate buckets — upstream publishes \
150/10s each, not 150/10s combined"
);
}
#[test]
fn test_all_clob_endpoints_have_match_mode() {
let rl = RateLimiter::clob_default();
for limit in &rl.inner.limits {
assert!(
limit.match_mode == MatchMode::Prefix || limit.match_mode == MatchMode::Exact,
"endpoint {} has no valid match mode",
limit.path_prefix
);
}
}
#[tokio::test]
async fn concurrent_acquires_are_paced_against_one_shared_allowance() {
const TASKS: u32 = 10;
let interval = Duration::from_secs(10) / (1_500 - 1);
let rl = std::sync::Arc::new(RateLimiter::clob_default());
let start = std::time::Instant::now();
let mut handles = Vec::new();
for _ in 0..TASKS {
let rl = rl.clone();
handles.push(tokio::spawn(async move {
rl.acquire("/markets", None).await;
}));
}
for handle in handles {
handle.await.unwrap();
}
let elapsed = start.elapsed();
assert!(
elapsed >= interval * (TASKS - 1) / 2,
"{TASKS} concurrent acquires completed in {elapsed:?}; pacing at {interval:?} each \
they cannot, so concurrent tasks are not sharing one allowance"
);
assert!(
elapsed < Duration::from_secs(1),
"{TASKS} concurrent acquires took {elapsed:?} — they are stalling, not pacing"
);
}
#[tokio::test]
async fn test_acquire_concurrent_different_endpoints() {
let rl = std::sync::Arc::new(RateLimiter::clob_default());
let rl1 = rl.clone();
let rl2 = rl.clone();
let rl3 = rl.clone();
let start = std::time::Instant::now();
let (r1, r2, r3) = tokio::join!(
tokio::spawn(async move { rl1.acquire("/markets", None).await }),
tokio::spawn(async move { rl2.acquire("/auth", None).await }),
tokio::spawn(async move { rl3.acquire("/order", Some(&Method::POST)).await }),
);
r1.unwrap();
r2.unwrap();
r3.unwrap();
assert!(
start.elapsed() < Duration::from_millis(50),
"different endpoints should not block: {:?}",
start.elapsed()
);
}
#[test]
fn test_clob_post_order_has_dual_window() {
let rl = RateLimiter::clob_default();
let post_order = rl
.inner
.limits
.iter()
.find(|l| l.path_prefix == "/order" && l.method == Some(Method::POST))
.expect("POST /order endpoint should exist");
assert_eq!(
post_order.buckets.len(),
2,
"POST /order should have a burst and a sustained window"
);
}
#[test]
fn test_clob_delete_order_has_a_sustained_window_too() {
let rl = RateLimiter::clob_default();
let delete_order = rl
.inner
.limits
.iter()
.find(|l| l.path_prefix == "/order" && l.method == Some(Method::DELETE))
.expect("DELETE /order endpoint should exist");
assert_eq!(
delete_order.buckets.len(),
2,
"DELETE /order should have both a burst and a sustained window"
);
}
#[tokio::test]
async fn test_dual_window_both_burst_and_sustained_are_awaited() {
let rl = RateLimiter::clob_default();
let start = std::time::Instant::now();
rl.acquire("/order", Some(&Method::POST)).await;
assert!(
start.elapsed() < Duration::from_millis(50),
"dual window single acquire should be fast: {:?}",
start.elapsed()
);
}
#[test]
fn test_should_retry_exhaustion() {
let client = crate::HttpClientBuilder::new("https://example.com")
.with_retry_config(RetryConfig {
max_retries: 3,
..RetryConfig::default()
})
.build()
.unwrap();
for attempt in 0..3 {
assert!(
client
.should_retry(reqwest::StatusCode::TOO_MANY_REQUESTS, attempt, None)
.is_some(),
"attempt {attempt} should allow retry"
);
}
assert!(
client
.should_retry(reqwest::StatusCode::TOO_MANY_REQUESTS, 3, None)
.is_none(),
"attempt 3 should exhaust retries"
);
}
#[test]
fn test_should_retry_zero_max_retries_never_retries() {
let client = crate::HttpClientBuilder::new("https://example.com")
.with_retry_config(RetryConfig {
max_retries: 0,
..RetryConfig::default()
})
.build()
.unwrap();
assert!(
client
.should_retry(reqwest::StatusCode::TOO_MANY_REQUESTS, 0, None)
.is_none(),
"max_retries=0 should never retry"
);
}
}
#[cfg(test)]
mod cooldown_tests {
use super::*;
#[tokio::test(start_paused = true)]
async fn acquire_is_immediate_without_a_cooldown() {
let rl = RateLimiter::data_default();
let t = tokio::time::Instant::now();
rl.acquire("/closed-positions", None).await;
assert!(
t.elapsed() < Duration::from_millis(1),
"an untripped limiter must not delay: waited {:?}",
t.elapsed()
);
}
#[tokio::test(start_paused = true)]
async fn a_cooldown_holds_back_a_path_that_never_saw_the_429() {
let rl = RateLimiter::data_default();
rl.begin_cooldown(Duration::from_secs(5));
let t = tokio::time::Instant::now();
rl.acquire("/trades", None).await;
assert!(
t.elapsed() >= Duration::from_secs(5),
"a sibling path resumed after {:?}, before the cooldown expired",
t.elapsed()
);
}
#[tokio::test(start_paused = true)]
async fn concurrent_requests_all_observe_one_cooldown() {
let rl = RateLimiter::data_default();
rl.begin_cooldown(Duration::from_secs(3));
let t = tokio::time::Instant::now();
tokio::join!(
rl.acquire("/closed-positions", None),
rl.acquire("/closed-positions", None),
rl.acquire("/closed-positions", None),
rl.acquire("/closed-positions", None),
);
assert!(
t.elapsed() >= Duration::from_secs(3),
"concurrent callers resumed after {:?}",
t.elapsed()
);
}
#[tokio::test(start_paused = true)]
async fn a_shorter_cooldown_never_cuts_a_longer_one_short() {
let rl = RateLimiter::data_default();
rl.begin_cooldown(Duration::from_secs(10));
rl.begin_cooldown(Duration::from_secs(1));
let t = tokio::time::Instant::now();
rl.acquire("/positions", None).await;
assert!(
t.elapsed() >= Duration::from_secs(10),
"the longer cooldown was truncated to {:?}",
t.elapsed()
);
}
#[tokio::test(start_paused = true)]
async fn a_cooldown_extended_mid_wait_is_honoured_in_full() {
let rl = RateLimiter::data_default();
rl.begin_cooldown(Duration::from_secs(2));
let extender = {
let rl = rl.clone();
tokio::spawn(async move {
tokio::time::sleep(Duration::from_secs(1)).await;
rl.begin_cooldown(Duration::from_secs(5));
})
};
let t = tokio::time::Instant::now();
rl.acquire("/closed-positions", None).await;
extender.await.unwrap();
assert!(
t.elapsed() >= Duration::from_secs(6),
"resumed at {:?}, ignoring the cooldown extension",
t.elapsed()
);
}
#[tokio::test(start_paused = true)]
async fn an_expired_cooldown_stops_delaying() {
let rl = RateLimiter::data_default();
rl.begin_cooldown(Duration::from_secs(2));
rl.acquire("/closed-positions", None).await;
let t = tokio::time::Instant::now();
rl.acquire("/closed-positions", None).await;
assert!(
t.elapsed() < Duration::from_millis(1),
"the limiter stayed blocked for {:?} after the cooldown expired",
t.elapsed()
);
}
}