Skip to main content

nautilus_derive/common/
rate_limit.rs

1// -------------------------------------------------------------------------------------------------
2//  Copyright (C) 2015-2026 Nautech Systems Pty Ltd. All rights reserved.
3//  https://nautechsystems.io
4//
5//  Licensed under the GNU Lesser General Public License Version 3.0 (the "License");
6//  You may not use this file except in compliance with the License.
7//  You may obtain a copy of the License at https://www.gnu.org/licenses/lgpl-3.0.en.html
8//
9//  Unless required by applicable law or agreed to in writing, software
10//  distributed under the License is distributed on an "AS IS" BASIS,
11//  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12//  See the License for the specific language governing permissions and
13//  limitations under the License.
14// -------------------------------------------------------------------------------------------------
15
16//! Fixed-window rate limiting for the Derive adapter.
17//!
18//! Derive refills every request allowance in discrete five-second windows, not
19//! one token at a time. A Trader can spend a full burst of `tps * 5` matching
20//! requests inside one window. The next request must then wait for the window
21//! boundary; nothing refills before it.
22//!
23//! The buckets and their allowances:
24//!
25//! - Matching writes draw on two independent allowances: account-wide
26//!   matching and per-instrument matching.
27//! - `private/cancel_all` and unscoped `private/cancel_by_label` have custom
28//!   quotas.
29//! - REST non-matching requests use a flat per-IP allowance; authenticated
30//!   WebSocket non-matching requests use a separate one.
31//!
32//! See <https://docs.derive.xyz/reference/rate-limits>.
33//!
34//! `FixedWindowLimiter` keeps one packed atomic word per bucket key holding
35//! the window index and the count consumed from it, so a check-and-consume is
36//! a single compare-and-swap. Windows align to limiter creation because the
37//! venue's own window phase cannot be observed from the client. A wait is
38//! therefore at most one full window, and the long-run average rate stays at
39//! the venue allowance. A burst can still straddle a venue window boundary;
40//! the venue then rejects the request outright. That rejection is definitive
41//! (surfaced as an `OrderRejected`), not ambiguous.
42//!
43//! The limiter is generic over the `nautilus_network` clocks: tests drive it
44//! deterministically with `FakeRelativeClock`, production uses
45//! [`MonotonicClock`].
46
47use std::{
48    num::NonZeroU32,
49    sync::atomic::{AtomicU64, Ordering},
50    time::Duration,
51};
52
53use dashmap::DashMap;
54#[cfg(test)]
55use nautilus_network::ratelimiter::clock::FakeRelativeClock;
56use nautilus_network::ratelimiter::clock::{Clock, MonotonicClock, Reference};
57use ustr::Ustr;
58
59/// Rate-limit bucket key for matching-engine requests (order create/cancel/replace).
60pub const DERIVE_MATCHING_RATE_KEY: &str = "derive:matching";
61
62/// Rate-limit bucket key for non-matching requests (reads, subscriptions, login).
63pub const DERIVE_NON_MATCHING_RATE_KEY: &str = "derive:non-matching";
64
65/// Rate-limit bucket key for `private/cancel_all` requests.
66pub const DERIVE_CANCEL_ALL_RATE_KEY: &str = "derive:cancel-all";
67
68/// Rate-limit bucket key for unscoped `private/cancel_by_label` requests.
69pub const DERIVE_CANCEL_BY_LABEL_RATE_KEY: &str = "derive:cancel-by-label";
70
71/// Prefix of the per-instrument matching bucket keys (`derive:matching:instrument:<name>`).
72const DERIVE_PER_INSTRUMENT_RATE_KEY_PREFIX: &str = "derive:matching:instrument:";
73
74/// Default matching-engine allowance for a Trader-tier account, in requests
75/// per second. Market Maker accounts negotiate higher limits via
76/// [`crate::config::DeriveExecutionClientConfig`]'s
77/// `max_matching_requests_per_second` field.
78pub const DERIVE_DEFAULT_MATCHING_TPS: u32 = 1;
79
80/// Default per-instrument matching allowance for a Trader-tier account, in
81/// requests per second. Market Maker accounts negotiate higher limits via
82/// [`crate::config::DeriveExecutionClientConfig`]'s
83/// `max_per_instrument_matching_requests_per_second` field. The account-wide
84/// override never inflates this bucket.
85pub const DERIVE_DEFAULT_PER_INSTRUMENT_MATCHING_TPS: u32 = 1;
86
87/// Flat REST non-matching allowance per IP (requests per second).
88pub const DERIVE_NON_MATCHING_TPS: u32 = 10;
89
90/// Default authenticated WebSocket non-matching allowance for a Trader account.
91pub const DERIVE_WEBSOCKET_NON_MATCHING_TPS: u32 = 5;
92
93/// Custom allowance for `private/cancel_all` (requests per second).
94pub const DERIVE_CANCEL_ALL_TPS: u32 = 1;
95
96/// Custom allowance for unscoped `private/cancel_by_label` (requests per second).
97pub const DERIVE_CANCEL_BY_LABEL_TPS: u32 = 10;
98
99/// Fixed-window length: Derive refills every allowance discretely at window
100/// boundaries spaced five seconds apart.
101pub const DERIVE_RATE_WINDOW_SECS: u64 = 5;
102
103/// Burst multiplier: each window admits five seconds' worth of requests.
104pub const DERIVE_RATE_BURST_MULTIPLIER: u32 = 5;
105
106const RATE_WINDOW_NANOS: u64 = DERIVE_RATE_WINDOW_SECS * 1_000_000_000;
107
108/// Venue rate classification of an RPC method.
109///
110/// Methods outside the venue's matching and custom lists are non-matching.
111///
112/// `private/trigger_order` and `private/cancel_trigger_order` are missing
113/// from the venue's matching list, but this adapter still paces them as
114/// matching writes. They reach the matching engine, so the stricter
115/// classification stays on the safe side of the documented contract.
116#[derive(Debug, Clone, Copy, PartialEq, Eq)]
117pub(crate) enum RateClass {
118    NonMatching,
119    Matching,
120    CancelAll,
121    CancelByLabel,
122}
123
124/// A single venue rate bucket a request draws from.
125///
126/// [`RateBucket::PerInstrument`] is keyed per instrument name so each
127/// instrument's matching allowance is enforced independently of the
128/// account-wide [`RateBucket::Matching`] allowance.
129#[derive(Debug, Clone, Copy, PartialEq, Eq)]
130pub(crate) enum RateBucket<'a> {
131    NonMatching,
132    Matching,
133    PerInstrument(&'a Ustr),
134    CancelAll,
135    CancelByLabel,
136}
137
138/// Returns the venue rate classification of an RPC method used by this adapter.
139#[must_use]
140pub(crate) fn rate_class_for_method(method: &str) -> RateClass {
141    match method.trim_start_matches('/') {
142        "private/order"
143        | "private/trigger_order"
144        | "private/replace"
145        | "private/cancel"
146        | "private/cancel_by_instrument"
147        | "private/cancel_trigger_order" => RateClass::Matching,
148        "private/cancel_all" => RateClass::CancelAll,
149        "private/cancel_by_label" => RateClass::CancelByLabel,
150        _ => RateClass::NonMatching,
151    }
152}
153
154/// Per-window allowance for each Derive rate bucket.
155///
156/// Each allowance is the bucket's documented requests-per-second rate times
157/// [`DERIVE_RATE_BURST_MULTIPLIER`], refilled discretely at each window
158/// boundary.
159///
160/// One limiter instance serves one transport, so a single `non_matching`
161/// allowance applies: the flat per-IP REST limit or the authenticated
162/// WebSocket limit, selected by the [`FixedWindowLimits::rest`] and
163/// [`FixedWindowLimits::websocket`] constructors.
164#[derive(Debug, Clone, Copy)]
165pub(crate) struct FixedWindowLimits {
166    /// Transport non-matching allowance per window (REST per-IP or WebSocket).
167    pub(crate) non_matching: NonZeroU32,
168    /// Account-wide matching allowance per window.
169    pub(crate) matching: NonZeroU32,
170    /// Per-instrument matching allowance per window.
171    pub(crate) per_instrument_matching: NonZeroU32,
172    /// `private/cancel_all` allowance per window.
173    pub(crate) cancel_all: NonZeroU32,
174    /// Unscoped `private/cancel_by_label` allowance per window.
175    pub(crate) cancel_by_label: NonZeroU32,
176}
177
178impl FixedWindowLimits {
179    /// Builds the REST limits: flat per-IP non-matching, with the configured
180    /// matching allowances (`None` or zero applies the Trader-tier defaults).
181    #[must_use]
182    pub(crate) fn rest(
183        matching_tps: Option<u32>,
184        per_instrument_matching_tps: Option<u32>,
185    ) -> Self {
186        Self {
187            non_matching: window_limit(DERIVE_NON_MATCHING_TPS),
188            matching: window_limit(resolve_tps(matching_tps, DERIVE_DEFAULT_MATCHING_TPS)),
189            per_instrument_matching: window_limit(resolve_tps(
190                per_instrument_matching_tps,
191                DERIVE_DEFAULT_PER_INSTRUMENT_MATCHING_TPS,
192            )),
193            cancel_all: window_limit(DERIVE_CANCEL_ALL_TPS),
194            cancel_by_label: window_limit(DERIVE_CANCEL_BY_LABEL_TPS),
195        }
196    }
197
198    /// Builds the authenticated WebSocket limits: Trader non-matching, with
199    /// the configured matching allowances (`None` or zero applies the
200    /// Trader-tier defaults).
201    #[must_use]
202    pub(crate) fn websocket(
203        matching_tps: Option<u32>,
204        per_instrument_matching_tps: Option<u32>,
205    ) -> Self {
206        Self {
207            non_matching: window_limit(DERIVE_WEBSOCKET_NON_MATCHING_TPS),
208            ..Self::rest(matching_tps, per_instrument_matching_tps)
209        }
210    }
211
212    /// Returns the window limit for a bucket.
213    #[must_use]
214    pub(crate) fn limit_for(&self, bucket: RateBucket<'_>) -> NonZeroU32 {
215        match bucket {
216            RateBucket::NonMatching => self.non_matching,
217            RateBucket::Matching => self.matching,
218            RateBucket::PerInstrument(_) => self.per_instrument_matching,
219            RateBucket::CancelAll => self.cancel_all,
220            RateBucket::CancelByLabel => self.cancel_by_label,
221        }
222    }
223}
224
225/// Fixed-window rate limiter for Derive request pacing.
226///
227/// Each bucket key maps to one packed atomic word holding the window index and
228/// the count consumed from that window. A successful check consumes one cell
229/// of the current window; a denied check reports the wait until the next
230/// window boundary, when the whole allowance refills at once.
231pub(crate) struct FixedWindowLimiter<C: Clock> {
232    limits: FixedWindowLimits,
233    cells: DashMap<Ustr, AtomicU64>,
234    clock: C,
235    start: C::Instant,
236}
237
238impl<C: Clock> FixedWindowLimiter<C> {
239    /// Creates a limiter whose windows are aligned to this call.
240    pub(crate) fn new(limits: FixedWindowLimits, clock: C) -> Self {
241        let start = clock.now();
242        Self {
243            limits,
244            cells: DashMap::new(),
245            clock,
246            start,
247        }
248    }
249
250    /// Attempts to consume one cell of the bucket's current window.
251    ///
252    /// # Errors
253    ///
254    /// Returns the wait until the next window boundary when the current
255    /// window's allowance is exhausted.
256    #[cfg(test)]
257    pub(crate) fn check_bucket(&self, bucket: RateBucket<'_>) -> Result<(), Duration> {
258        loop {
259            let elapsed = self.elapsed_nanos();
260            let window = window_index(elapsed);
261            let limit = self.limits.limit_for(bucket).get();
262            let key = bucket_key(bucket);
263            let cell = self.cells.entry(key).or_default();
264            match consume_cell_fixed_window(cell.value(), limit, window) {
265                CellOutcome::Consumed => return Ok(()),
266                CellOutcome::Exhausted => {
267                    let window_end_nanos = (u64::from(window) + 1) * RATE_WINDOW_NANOS;
268                    return Err(Duration::from_nanos(
269                        window_end_nanos.saturating_sub(elapsed),
270                    ));
271                }
272                // The window rolled over mid-attempt; retry on the fresh
273                // window rather than writing a stale index back.
274                CellOutcome::Advanced => {}
275            }
276        }
277    }
278
279    /// Waits until every bucket admits the request in one single window,
280    /// consuming one cell from each, and returns that window's index.
281    ///
282    /// All buckets share the venue's five-second window grid, so a denied
283    /// attempt sleeps to the same boundary whichever bucket denied. Cells
284    /// consumed earlier in a failed attempt are rolled back, so a departure
285    /// never mixes cells from two different windows.
286    pub(crate) async fn await_buckets_ready(&self, buckets: &[RateBucket<'_>]) -> u32 {
287        loop {
288            let elapsed = self.elapsed_nanos();
289            let window = window_index(elapsed);
290            let mut acquired: Vec<Ustr> = Vec::with_capacity(buckets.len());
291            let mut denial = None;
292
293            for bucket in buckets {
294                let limit = self.limits.limit_for(*bucket).get();
295                let key = bucket_key(*bucket);
296                let cell = self.cells.entry(key).or_default();
297                match consume_cell_fixed_window(cell.value(), limit, window) {
298                    CellOutcome::Consumed => acquired.push(key),
299                    CellOutcome::Exhausted => {
300                        let window_end_nanos = (u64::from(window) + 1) * RATE_WINDOW_NANOS;
301                        denial = Some(Duration::from_nanos(
302                            window_end_nanos.saturating_sub(elapsed),
303                        ));
304                        break;
305                    }
306                    // The window rolled over mid-attempt; discard this attempt
307                    // (rolling back partial consumption) and retry fresh.
308                    CellOutcome::Advanced => break,
309                }
310            }
311
312            match denial {
313                None if acquired.len() == buckets.len() => return window,
314                None => {
315                    self.rollback_window(acquired, window);
316                }
317                Some(wait) => {
318                    self.rollback_window(acquired, window);
319                    self.clock.sleep(wait).await;
320                }
321            }
322        }
323    }
324
325    /// Waits for the buckets a request class draws from, honouring the venue's
326    /// per-instrument matching allowance when the request carries an
327    /// instrument.
328    pub(crate) async fn await_class_ready(
329        &self,
330        class: RateClass,
331        instrument_name: Option<&Ustr>,
332    ) -> u32 {
333        match class {
334            RateClass::Matching if instrument_name.is_some() => {
335                let instrument = instrument_name.expect("checked above");
336                self.await_buckets_ready(&[
337                    RateBucket::Matching,
338                    RateBucket::PerInstrument(instrument),
339                ])
340                .await
341            }
342            RateClass::Matching => self.await_buckets_ready(&[RateBucket::Matching]).await,
343            RateClass::NonMatching => self.await_buckets_ready(&[RateBucket::NonMatching]).await,
344            RateClass::CancelAll => self.await_buckets_ready(&[RateBucket::CancelAll]).await,
345            RateClass::CancelByLabel => {
346                self.await_buckets_ready(&[RateBucket::CancelByLabel]).await
347            }
348        }
349    }
350
351    /// Returns one consumed cell per key, but only while its cell still holds
352    /// `window`. Once a cell moved to a later window, the stale consumption
353    /// was already superseded and there is nothing to undo.
354    fn rollback_window(&self, keys: Vec<Ustr>, window: u32) {
355        for key in keys {
356            if let Some(cell) = self.cells.get(&key) {
357                rollback_cell_fixed_window(cell.value(), window);
358            }
359        }
360    }
361
362    /// Re-paces a reserved request when the window it reserved in has ended.
363    ///
364    /// A matching reservation consumes its cells before the caller signs the
365    /// request. If the signing work crosses a window boundary, the departure
366    /// lands in a later window whose ledger never recorded it. Re-acquiring in
367    /// the current window keeps every departure's cells in its own window's
368    /// ledger; it is a no-op unless the window rolled.
369    ///
370    /// Callers pass the window index returned by the acquisition, so the
371    /// stamp can never disagree with the cells actually consumed.
372    pub(crate) async fn ensure_window_current(
373        &self,
374        class: RateClass,
375        instrument_name: Option<&Ustr>,
376        reserved_window: u32,
377    ) {
378        if window_index(self.elapsed_nanos()) != reserved_window {
379            self.await_class_ready(class, instrument_name).await;
380        }
381    }
382
383    fn elapsed_nanos(&self) -> u64 {
384        self.clock.now().duration_since(self.start).as_u64()
385    }
386}
387
388#[cfg(test)]
389impl FixedWindowLimiter<FakeRelativeClock> {
390    /// Advances the fake clock by the specified duration (tests only).
391    pub(crate) fn advance_clock(&self, by: Duration) {
392        self.clock.advance(by);
393    }
394}
395
396impl<C: Clock> std::fmt::Debug for FixedWindowLimiter<C> {
397    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
398        f.debug_struct(stringify!(FixedWindowLimiter)).finish()
399    }
400}
401
402/// Type alias for the production limiter used by both Derive transports.
403pub(crate) type DeriveRateLimiter = FixedWindowLimiter<MonotonicClock>;
404
405fn bucket_key(bucket: RateBucket<'_>) -> Ustr {
406    match bucket {
407        RateBucket::NonMatching => Ustr::from(DERIVE_NON_MATCHING_RATE_KEY),
408        RateBucket::Matching => Ustr::from(DERIVE_MATCHING_RATE_KEY),
409        RateBucket::PerInstrument(instrument_name) => Ustr::from(
410            format!(
411                "{DERIVE_PER_INSTRUMENT_RATE_KEY_PREFIX}{}",
412                instrument_name.as_str(),
413            )
414            .as_str(),
415        ),
416        RateBucket::CancelAll => Ustr::from(DERIVE_CANCEL_ALL_RATE_KEY),
417        RateBucket::CancelByLabel => Ustr::from(DERIVE_CANCEL_BY_LABEL_RATE_KEY),
418    }
419}
420
421fn resolve_tps(configured: Option<u32>, default_tps: u32) -> u32 {
422    configured.filter(|&v| v > 0).unwrap_or(default_tps)
423}
424
425fn window_limit(tps: u32) -> NonZeroU32 {
426    NonZeroU32::new(tps.saturating_mul(DERIVE_RATE_BURST_MULTIPLIER))
427        .expect("window limit must be non-zero")
428}
429
430fn window_index(elapsed_nanos: u64) -> u32 {
431    u32::try_from(elapsed_nanos / RATE_WINDOW_NANOS).expect("window index fits u32")
432}
433
434/// Packs `(window index, consumed)` into one atomic word; the window index in
435/// the high half so the default zero value reads as a stale window.
436fn pack(window: u32, consumed: u32) -> u64 {
437    (u64::from(window) << 32) | u64::from(consumed)
438}
439
440fn unpack(packed: u64) -> (u32, u32) {
441    (
442        u32::try_from(packed >> 32).expect("window index fits u32"),
443        packed as u32,
444    )
445}
446
447/// Outcome of a one-cell consumption attempt against a fixed `window`.
448enum CellOutcome {
449    Consumed,
450    Exhausted,
451    /// The cell already holds a later window than the attempt targeted.
452    Advanced,
453}
454
455/// Checks and consumes one cell of `window` under `limit` in a CAS loop.
456///
457/// A cell observed in a later window than `window` yields [`CellOutcome::Advanced`]
458/// instead of writing the stale index back, so a window cell never regresses.
459fn consume_cell_fixed_window(cell: &AtomicU64, limit: u32, window: u32) -> CellOutcome {
460    let mut prev = cell.load(Ordering::Acquire);
461    loop {
462        let (prev_window, prev_consumed) = unpack(prev);
463        if prev_window > window {
464            return CellOutcome::Advanced;
465        }
466        let next = if prev_window < window {
467            pack(window, 1)
468        } else if prev_consumed < limit {
469            pack(window, prev_consumed + 1)
470        } else {
471            return CellOutcome::Exhausted;
472        };
473
474        match cell.compare_exchange_weak(prev, next, Ordering::Release, Ordering::Relaxed) {
475            Ok(_) => return CellOutcome::Consumed,
476            Err(contended) => prev = contended,
477        }
478    }
479}
480
481/// Returns one consumed cell when it still holds `window`; a cell that moved
482/// to a later window has already superseded the stale consumption.
483fn rollback_cell_fixed_window(cell: &AtomicU64, window: u32) {
484    let mut prev = cell.load(Ordering::Acquire);
485    loop {
486        let (prev_window, prev_consumed) = unpack(prev);
487        if prev_window != window || prev_consumed == 0 {
488            return;
489        }
490        let next = pack(prev_window, prev_consumed - 1);
491        match cell.compare_exchange_weak(prev, next, Ordering::Release, Ordering::Relaxed) {
492            Ok(_) => return,
493            Err(contended) => prev = contended,
494        }
495    }
496}
497
498#[cfg(test)]
499mod tests {
500    use rstest::rstest;
501
502    use super::*;
503
504    fn instrument(name: &str) -> Ustr {
505        Ustr::from(name)
506    }
507
508    fn trader_limits() -> FixedWindowLimits {
509        FixedWindowLimits::websocket(None, None)
510    }
511
512    fn limiter() -> FixedWindowLimiter<FakeRelativeClock> {
513        FixedWindowLimiter::new(trader_limits(), FakeRelativeClock::default())
514    }
515
516    #[rstest]
517    fn test_rest_limits_match_documented_trader_contract() {
518        let limits = FixedWindowLimits::rest(None, None);
519        assert_eq!(limits.non_matching.get(), 50); // 10 TPS * 5x burst
520        assert_eq!(limits.matching.get(), 5); // 1 TPS * 5x burst
521        assert_eq!(limits.per_instrument_matching.get(), 5);
522        assert_eq!(limits.cancel_all.get(), 5); // 1 TPS * 5x burst
523        assert_eq!(limits.cancel_by_label.get(), 50); // 10 TPS * 5x burst
524    }
525
526    #[rstest]
527    fn test_websocket_limits_match_documented_trader_contract() {
528        let limits = FixedWindowLimits::websocket(None, None);
529        assert_eq!(limits.non_matching.get(), 25); // 5 TPS * 5x burst
530        assert_eq!(limits.matching.get(), 5);
531        assert_eq!(limits.per_instrument_matching.get(), 5);
532    }
533
534    #[rstest]
535    fn test_matching_overrides_do_not_leak_into_per_instrument_allowance() {
536        let limits = FixedWindowLimits::websocket(Some(500), None);
537        assert_eq!(limits.matching.get(), 2_500);
538        assert_eq!(limits.per_instrument_matching.get(), 5);
539
540        let limits = FixedWindowLimits::websocket(None, Some(10));
541        assert_eq!(limits.matching.get(), 5);
542        assert_eq!(limits.per_instrument_matching.get(), 50);
543    }
544
545    #[rstest]
546    fn test_matching_overrides_treat_zero_as_unset() {
547        let limits = FixedWindowLimits::websocket(Some(0), Some(0));
548        assert_eq!(limits.matching.get(), 5);
549        assert_eq!(limits.per_instrument_matching.get(), 5);
550    }
551
552    #[rstest]
553    #[case("private/order", RateClass::Matching)]
554    #[case("/private/order", RateClass::Matching)]
555    #[case("private/trigger_order", RateClass::Matching)]
556    #[case("private/replace", RateClass::Matching)]
557    #[case("private/cancel", RateClass::Matching)]
558    #[case("private/cancel_by_instrument", RateClass::Matching)]
559    #[case("private/cancel_trigger_order", RateClass::Matching)]
560    #[case("private/cancel_all", RateClass::CancelAll)]
561    #[case("private/cancel_by_label", RateClass::CancelByLabel)]
562    #[case("private/get_subaccount", RateClass::NonMatching)]
563    #[case("private/get_open_orders", RateClass::NonMatching)]
564    #[case("public/get_instruments", RateClass::NonMatching)]
565    #[case("public/login", RateClass::NonMatching)]
566    #[case("subscribe", RateClass::NonMatching)]
567    fn test_rate_class_for_method(#[case] method: &str, #[case] expected: RateClass) {
568        assert_eq!(rate_class_for_method(method), expected);
569    }
570
571    #[rstest]
572    fn test_full_matching_burst_denies_sixth_request_until_window_reset() {
573        let limiter = limiter();
574
575        for _ in 0..5 {
576            assert!(
577                limiter.check_bucket(RateBucket::Matching).is_ok(),
578                "Trader matching burst is five requests",
579            );
580        }
581        assert!(
582            limiter.check_bucket(RateBucket::Matching).is_err(),
583            "sixth matching request must wait for the window reset",
584        );
585    }
586
587    #[rstest]
588    fn test_allowance_refills_discretely_at_window_boundary() {
589        let limiter = limiter();
590        for _ in 0..5 {
591            limiter.check_bucket(RateBucket::Matching).expect("burst");
592        }
593
594        limiter.advance_clock(Duration::from_millis(4_999));
595        assert!(
596            limiter.check_bucket(RateBucket::Matching).is_err(),
597            "window has not rolled: nothing refills before the boundary",
598        );
599
600        limiter.advance_clock(Duration::from_millis(1));
601
602        for sequence in 0..5 {
603            assert!(
604                limiter.check_bucket(RateBucket::Matching).is_ok(),
605                "full allowance must refill at the boundary, request {sequence}",
606            );
607        }
608        assert!(
609            limiter.check_bucket(RateBucket::Matching).is_err(),
610            "only one window's allowance refills",
611        );
612    }
613
614    #[rstest]
615    fn test_window_reset_does_not_refill_one_token_at_a_time() {
616        // After a burst, sub-window advances that would refill a GCRA cell
617        // must not admit anything; only a full five-second crossing does.
618        let limiter = limiter();
619        for _ in 0..5 {
620            limiter.check_bucket(RateBucket::Matching).expect("burst");
621        }
622
623        for _ in 0..4 {
624            limiter.advance_clock(Duration::from_secs(1));
625            assert!(
626                limiter.check_bucket(RateBucket::Matching).is_err(),
627                "sustained-rate refill must not apply inside a window",
628            );
629        }
630
631        limiter.advance_clock(Duration::from_secs(1));
632        assert!(
633            limiter.check_bucket(RateBucket::Matching).is_ok(),
634            "full refill lands exactly at the five-second boundary",
635        );
636    }
637
638    #[rstest]
639    #[tokio::test]
640    async fn test_await_buckets_ready_waits_for_window_reset_and_consumes() {
641        let limiter = limiter();
642        for _ in 0..5 {
643            limiter.check_bucket(RateBucket::Matching).expect("burst");
644        }
645
646        // The fake clock's sleep advances time, so this await completes
647        // deterministically at the window boundary.
648        limiter.await_buckets_ready(&[RateBucket::Matching]).await;
649
650        // The fresh window holds five cells and the await consumed exactly
651        // one of them, leaving four.
652        for _ in 0..4 {
653            limiter
654                .check_bucket(RateBucket::Matching)
655                .expect("fresh window minus the awaited cell");
656        }
657        assert!(
658            limiter.check_bucket(RateBucket::Matching).is_err(),
659            "await must consume from the fresh window",
660        );
661    }
662
663    #[rstest]
664    fn test_per_instrument_buckets_are_independent() {
665        let limiter = FixedWindowLimiter::new(
666            FixedWindowLimits::websocket(Some(10), None),
667            FakeRelativeClock::default(),
668        );
669
670        for _ in 0..5 {
671            limiter
672                .check_bucket(RateBucket::PerInstrument(&instrument("ETH-PERP")))
673                .expect("ETH-PERP burst");
674        }
675        assert!(
676            limiter
677                .check_bucket(RateBucket::PerInstrument(&instrument("ETH-PERP")))
678                .is_err(),
679            "ETH-PERP allowance is exhausted",
680        );
681        assert!(
682            limiter
683                .check_bucket(RateBucket::PerInstrument(&instrument("BTC-PERP")))
684                .is_ok(),
685            "BTC-PERP has an independent allowance",
686        );
687        assert!(
688            limiter.check_bucket(RateBucket::Matching).is_ok(),
689            "account-wide matching still has headroom (10 TPS)",
690        );
691    }
692
693    #[rstest]
694    #[tokio::test]
695    async fn test_global_matching_bucket_enforced_alongside_per_instrument() {
696        let clock = FakeRelativeClock::default();
697        let limiter =
698            FixedWindowLimiter::new(FixedWindowLimits::websocket(None, Some(10)), clock.clone());
699
700        // Five ETH-PERP writes drain the account-wide Trader allowance while
701        // barely touching ETH-PERP's own 10 TPS allowance.
702        for _ in 0..5 {
703            limiter
704                .await_class_ready(RateClass::Matching, Some(&instrument("ETH-PERP")))
705                .await;
706        }
707
708        // A BTC-PERP write has per-instrument headroom but cannot depart in
709        // this window: the global bucket is exhausted, so the await must
710        // advance the clock to the window boundary.
711        limiter
712            .await_class_ready(RateClass::Matching, Some(&instrument("BTC-PERP")))
713            .await;
714        assert_eq!(
715            clock.now().as_u64(),
716            RATE_WINDOW_NANOS,
717            "BTC-PERP write must wait for the global window reset",
718        );
719    }
720
721    #[rstest]
722    #[tokio::test]
723    async fn test_matching_write_consumes_global_and_per_instrument_buckets() {
724        let limiter = FixedWindowLimiter::new(
725            FixedWindowLimits::websocket(Some(2), None),
726            FakeRelativeClock::default(),
727        );
728
729        // Five ETH-PERP writes exhaust ETH-PERP's Trader allowance (5 per
730        // window) but only consume five of the global override's 10 cells.
731        for _ in 0..5 {
732            limiter
733                .await_class_ready(RateClass::Matching, Some(&instrument("ETH-PERP")))
734                .await;
735        }
736
737        assert!(
738            limiter
739                .check_bucket(RateBucket::PerInstrument(&instrument("ETH-PERP")))
740                .is_err(),
741            "each write consumes the instrument bucket",
742        );
743        assert!(
744            limiter.check_bucket(RateBucket::Matching).is_ok(),
745            "five of the global override's ten window cells remain",
746        );
747        assert!(
748            limiter
749                .check_bucket(RateBucket::PerInstrument(&instrument("BTC-PERP")))
750                .is_ok(),
751            "other instruments are unaffected",
752        );
753    }
754
755    #[rstest]
756    #[tokio::test]
757    async fn test_multi_bucket_wait_consumes_both_buckets_from_one_window() {
758        let clock = FakeRelativeClock::default();
759        let limiter =
760            FixedWindowLimiter::new(FixedWindowLimits::websocket(Some(10), None), clock.clone());
761
762        // Five ETH-PERP writes exhaust ETH-PERP's Trader allowance while the
763        // global override (10 TPS) still has window-0 headroom.
764        for _ in 0..5 {
765            limiter
766                .await_class_ready(RateClass::Matching, Some(&instrument("ETH-PERP")))
767                .await;
768        }
769
770        // The sixth write must not burn the global cell in window 0 and then
771        // depart from window 1: both cells come from the boundary window.
772        limiter
773            .await_class_ready(RateClass::Matching, Some(&instrument("ETH-PERP")))
774            .await;
775        assert_eq!(
776            clock.now().as_u64(),
777            RATE_WINDOW_NANOS,
778            "the denied write must wait for the window boundary",
779        );
780
781        // Window 1 holds one consumed global cell of 50, so exactly 49 more
782        // admissions remain. The cross-window bug consumed the global cell in
783        // window 0 and would leave 50.
784        let mut remaining = 0;
785        while limiter.check_bucket(RateBucket::Matching).is_ok() {
786            remaining += 1;
787        }
788        assert_eq!(remaining, 49, "global cell must come from window 1");
789
790        // The instrument bucket also consumed its window-1 cell.
791        for _ in 0..4 {
792            limiter
793                .check_bucket(RateBucket::PerInstrument(&instrument("ETH-PERP")))
794                .expect("window 1 holds one consumed cell of five");
795        }
796        assert!(
797            limiter
798                .check_bucket(RateBucket::PerInstrument(&instrument("ETH-PERP")))
799                .is_err(),
800            "the awaited write consumed the fifth ETH-PERP cell of window 1",
801        );
802    }
803
804    #[rstest]
805    #[tokio::test]
806    async fn test_ensure_window_current_reacquires_only_after_rollover() {
807        let clock = FakeRelativeClock::default();
808        let limiter =
809            FixedWindowLimiter::new(FixedWindowLimits::websocket(None, None), clock.clone());
810
811        // Reserve one ETH-PERP write and record its window.
812        let reserved_window = limiter
813            .await_class_ready(RateClass::Matching, Some(&instrument("ETH-PERP")))
814            .await;
815        assert_eq!(reserved_window, 0);
816
817        // Same window: the refresh must not consume another cell pair.
818        limiter
819            .ensure_window_current(
820                RateClass::Matching,
821                Some(&instrument("ETH-PERP")),
822                reserved_window,
823            )
824            .await;
825        assert!(
826            limiter.check_bucket(RateBucket::Matching).is_ok(),
827            "same-window refresh consumes nothing",
828        );
829        limiter
830            .check_bucket(RateBucket::PerInstrument(&instrument("ETH-PERP")))
831            .expect("same-window refresh consumes nothing");
832
833        // Window rolled past the reservation: the refresh must consume a
834        // fresh pair in the new window, leaving 4 of 5 cells in each bucket.
835        clock.advance(Duration::from_secs(5));
836        limiter
837            .ensure_window_current(
838                RateClass::Matching,
839                Some(&instrument("ETH-PERP")),
840                reserved_window,
841            )
842            .await;
843
844        for _ in 0..4 {
845            limiter
846                .check_bucket(RateBucket::Matching)
847                .expect("window 1 global has 4 cells left of 5");
848        }
849        assert!(
850            limiter.check_bucket(RateBucket::Matching).is_err(),
851            "rolled-window refresh consumed a window-1 global cell",
852        );
853
854        for _ in 0..4 {
855            limiter
856                .check_bucket(RateBucket::PerInstrument(&instrument("ETH-PERP")))
857                .expect("window 1 instrument has 4 cells left of 5");
858        }
859        assert!(
860            limiter
861                .check_bucket(RateBucket::PerInstrument(&instrument("ETH-PERP")))
862                .is_err(),
863            "rolled-window refresh consumed a window-1 instrument cell",
864        );
865    }
866
867    #[rstest]
868    fn test_custom_cancel_all_quota_is_one_tps_burst() {
869        let limiter = limiter();
870        for _ in 0..5 {
871            limiter.check_bucket(RateBucket::CancelAll).expect("burst");
872        }
873        assert!(
874            limiter.check_bucket(RateBucket::CancelAll).is_err(),
875            "custom cancel_all allowance is 5 per window",
876        );
877        limiter.advance_clock(Duration::from_secs(5));
878        assert!(limiter.check_bucket(RateBucket::CancelAll).is_ok());
879    }
880
881    #[rstest]
882    fn test_custom_unscoped_cancel_by_label_quota_is_ten_tps_burst() {
883        let limiter = limiter();
884        for _ in 0..50 {
885            limiter
886                .check_bucket(RateBucket::CancelByLabel)
887                .expect("burst");
888        }
889        assert!(
890            limiter.check_bucket(RateBucket::CancelByLabel).is_err(),
891            "unscoped cancel_by_label allowance is 50 per window",
892        );
893        limiter.advance_clock(Duration::from_secs(5));
894        assert!(limiter.check_bucket(RateBucket::CancelByLabel).is_ok());
895    }
896
897    #[rstest]
898    fn test_rest_non_matching_quota_is_fifty_per_window() {
899        let limiter = FixedWindowLimiter::new(
900            FixedWindowLimits::rest(None, None),
901            FakeRelativeClock::default(),
902        );
903
904        for _ in 0..50 {
905            limiter
906                .check_bucket(RateBucket::NonMatching)
907                .expect("burst");
908        }
909        assert!(
910            limiter.check_bucket(RateBucket::NonMatching).is_err(),
911            "REST non-matching allowance is 50 per window",
912        );
913    }
914
915    #[rstest]
916    fn test_websocket_non_matching_quota_is_twenty_five_per_window() {
917        let limiter = limiter();
918        for _ in 0..25 {
919            limiter
920                .check_bucket(RateBucket::NonMatching)
921                .expect("burst");
922        }
923        assert!(
924            limiter.check_bucket(RateBucket::NonMatching).is_err(),
925            "WebSocket non-matching allowance is 25 per window",
926        );
927    }
928
929    #[rstest]
930    fn test_window_limit_and_index_arithmetic() {
931        assert_eq!(window_limit(1).get(), 5);
932        assert_eq!(window_index(0), 0);
933        assert_eq!(window_index(RATE_WINDOW_NANOS - 1), 0);
934        assert_eq!(window_index(RATE_WINDOW_NANOS), 1);
935        assert_eq!(unpack(pack(7, 3)), (7, 3));
936        assert_eq!(unpack(0), (0, 0));
937    }
938}