1use 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
59pub const DERIVE_MATCHING_RATE_KEY: &str = "derive:matching";
61
62pub const DERIVE_NON_MATCHING_RATE_KEY: &str = "derive:non-matching";
64
65pub const DERIVE_CANCEL_ALL_RATE_KEY: &str = "derive:cancel-all";
67
68pub const DERIVE_CANCEL_BY_LABEL_RATE_KEY: &str = "derive:cancel-by-label";
70
71const DERIVE_PER_INSTRUMENT_RATE_KEY_PREFIX: &str = "derive:matching:instrument:";
73
74pub const DERIVE_DEFAULT_MATCHING_TPS: u32 = 1;
79
80pub const DERIVE_DEFAULT_PER_INSTRUMENT_MATCHING_TPS: u32 = 1;
86
87pub const DERIVE_NON_MATCHING_TPS: u32 = 10;
89
90pub const DERIVE_WEBSOCKET_NON_MATCHING_TPS: u32 = 5;
92
93pub const DERIVE_CANCEL_ALL_TPS: u32 = 1;
95
96pub const DERIVE_CANCEL_BY_LABEL_TPS: u32 = 10;
98
99pub const DERIVE_RATE_WINDOW_SECS: u64 = 5;
102
103pub const DERIVE_RATE_BURST_MULTIPLIER: u32 = 5;
105
106const RATE_WINDOW_NANOS: u64 = DERIVE_RATE_WINDOW_SECS * 1_000_000_000;
107
108#[derive(Debug, Clone, Copy, PartialEq, Eq)]
117pub(crate) enum RateClass {
118 NonMatching,
119 Matching,
120 CancelAll,
121 CancelByLabel,
122}
123
124#[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#[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#[derive(Debug, Clone, Copy)]
165pub(crate) struct FixedWindowLimits {
166 pub(crate) non_matching: NonZeroU32,
168 pub(crate) matching: NonZeroU32,
170 pub(crate) per_instrument_matching: NonZeroU32,
172 pub(crate) cancel_all: NonZeroU32,
174 pub(crate) cancel_by_label: NonZeroU32,
176}
177
178impl FixedWindowLimits {
179 #[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 #[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 #[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
225pub(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 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 #[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 CellOutcome::Advanced => {}
275 }
276 }
277 }
278
279 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 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 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 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 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 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
402pub(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
434fn 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
447enum CellOutcome {
449 Consumed,
450 Exhausted,
451 Advanced,
453}
454
455fn 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
481fn 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); assert_eq!(limits.matching.get(), 5); assert_eq!(limits.per_instrument_matching.get(), 5);
522 assert_eq!(limits.cancel_all.get(), 5); assert_eq!(limits.cancel_by_label.get(), 50); }
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); 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 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 limiter.await_buckets_ready(&[RateBucket::Matching]).await;
649
650 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 for _ in 0..5 {
703 limiter
704 .await_class_ready(RateClass::Matching, Some(&instrument("ETH-PERP")))
705 .await;
706 }
707
708 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 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 for _ in 0..5 {
765 limiter
766 .await_class_ready(RateClass::Matching, Some(&instrument("ETH-PERP")))
767 .await;
768 }
769
770 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 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 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 let reserved_window = limiter
813 .await_class_ready(RateClass::Matching, Some(&instrument("ETH-PERP")))
814 .await;
815 assert_eq!(reserved_window, 0);
816
817 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 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}