1use std::sync::{Arc, Mutex};
2use std::time::Duration;
3
4use governor::Quota;
5use reqwest::Method;
6use tokio::time::Instant;
7
8type DirectLimiter = governor::RateLimiter<
9 governor::state::NotKeyed,
10 governor::state::InMemoryState,
11 governor::clock::DefaultClock,
12>;
13
14#[derive(Debug, Clone, Copy, PartialEq, Eq)]
16#[allow(dead_code)]
17enum MatchMode {
18 Prefix,
22 Exact,
24}
25
26#[derive(Debug, Clone, Copy, PartialEq, Eq)]
31struct RateSpec {
32 count: u32,
33 period: Duration,
34}
35
36struct Bucket {
42 #[cfg_attr(not(test), allow(dead_code))]
45 spec: RateSpec,
46 limiter: DirectLimiter,
47}
48
49impl Bucket {
50 fn new(count: u32, period: Duration) -> Arc<Self> {
51 Arc::new(Self {
52 spec: RateSpec { count, period },
53 limiter: DirectLimiter::direct(quota(count, period)),
54 })
55 }
56}
57
58struct EndpointLimit {
60 path_prefix: &'static str,
61 method: Option<Method>,
62 match_mode: MatchMode,
63 buckets: Vec<Arc<Bucket>>,
65}
66
67impl EndpointLimit {
68 fn matches(&self, path: &str, method: Option<&Method>) -> bool {
73 let path_matches = match self.match_mode {
74 MatchMode::Exact => path == self.path_prefix,
75 MatchMode::Prefix => {
76 match path.strip_prefix(self.path_prefix) {
79 Some(rest) => rest.is_empty() || rest.starts_with('/') || rest.starts_with('?'),
80 None => false,
81 }
82 }
83 };
84 if !path_matches {
85 return false;
86 }
87 match &self.method {
88 Some(expected) => method == Some(expected),
89 None => true,
90 }
91 }
92}
93
94#[derive(Clone)]
99pub struct RateLimiter {
100 inner: Arc<RateLimiterInner>,
101}
102
103impl std::fmt::Debug for RateLimiter {
104 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
105 f.debug_struct("RateLimiter")
106 .field("endpoints", &self.inner.limits.len())
107 .finish()
108 }
109}
110
111struct RateLimiterInner {
112 limits: Vec<EndpointLimit>,
113 default: DirectLimiter,
114 cooldown_until: Mutex<Option<Instant>>,
122}
123
124fn quota(count: u32, period: Duration) -> Quota {
166 Quota::with_period(period / sustained_slots(count)).expect("quota interval must be non-zero")
167}
168
169fn sustained_slots(count: u32) -> u32 {
178 let target = count.saturating_sub(count.div_ceil(RESERVED_FRACTION));
179 target.max(2) - 1
180}
181
182const RESERVED_FRACTION: u32 = 10;
187
188#[cfg(test)]
189mod quota_arithmetic {
190 use super::*;
208
209 fn admitted_in_one_window(q: &Quota, period: Duration) -> u128 {
216 let refilled = period.as_nanos() / q.replenish_interval().as_nanos();
217 u128::from(q.burst_size().get()) + refilled
218 }
219
220 const PUBLISHED_SHAPES: &[(u32, u64)] = &[
228 (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), ];
242
243 #[test]
244 fn no_quota_admits_more_than_its_published_count_in_one_window() {
245 for &(count, secs) in PUBLISHED_SHAPES {
246 let period = Duration::from_secs(secs);
247 let q = quota(count, period);
248 let admitted = admitted_in_one_window(&q, period);
249
250 assert!(
251 admitted <= u128::from(count),
252 "{count}/{secs}s admits {admitted} in one window \
253 ({} burst + {} refilled) — the published quota is spent twice",
254 q.burst_size(),
255 admitted - u128::from(q.burst_size().get()),
256 );
257 }
258 }
259
260 #[test]
261 fn every_quota_reserves_headroom_below_the_published_count() {
262 for &(count, secs) in PUBLISHED_SHAPES {
271 let period = Duration::from_secs(secs);
272 let admitted = admitted_in_one_window("a(count, period), period);
273 let ceiling = u128::from(count - count.div_ceil(RESERVED_FRACTION));
274
275 assert!(
276 admitted <= ceiling,
277 "{count}/{secs}s admits {admitted} in one window, above the {ceiling} \
278 the reserve allows — no headroom under the published cap"
279 );
280 }
281 }
282
283 #[test]
284 fn every_configured_bucket_satisfies_the_quota_it_publishes() {
285 for (surface, rl) in [
286 ("clob", RateLimiter::clob_default()),
287 ("gamma", RateLimiter::gamma_default()),
288 ("data", RateLimiter::data_default()),
289 ("relay", RateLimiter::relay_default()),
290 ] {
291 for limit in &rl.inner.limits {
292 for bucket in &limit.buckets {
293 let RateSpec { count, period } = bucket.spec;
294 let admitted = admitted_in_one_window("a(count, period), period);
295
296 assert!(
297 admitted <= u128::from(count),
298 "{surface} {} is published as {count}/{period:?} but admits \
299 {admitted} in one window",
300 limit.path_prefix,
301 );
302 }
303 }
304 }
305 }
306}
307
308fn endpoint_limit(
310 path_prefix: &'static str,
311 method: Option<Method>,
312 buckets: Vec<Arc<Bucket>>,
313) -> EndpointLimit {
314 EndpointLimit {
315 path_prefix,
316 method,
317 match_mode: MatchMode::Prefix,
318 buckets,
319 }
320}
321
322fn simple_limit(
324 path_prefix: &'static str,
325 method: Option<Method>,
326 count: u32,
327 period: Duration,
328) -> EndpointLimit {
329 endpoint_limit(path_prefix, method, vec![Bucket::new(count, period)])
330}
331
332fn dual_limit(
334 path_prefix: &'static str,
335 method: Method,
336 burst: (u32, Duration),
337 sustained: (u32, Duration),
338) -> EndpointLimit {
339 endpoint_limit(
340 path_prefix,
341 Some(method),
342 vec![
343 Bucket::new(burst.0, burst.1),
344 Bucket::new(sustained.0, sustained.1),
345 ],
346 )
347}
348
349impl RateLimiter {
350 pub fn begin_cooldown(&self, delay: Duration) {
361 let until = Instant::now() + delay;
362 let mut slot = self.lock_cooldown();
363 if slot.is_none_or(|current| until > current) {
364 *slot = Some(until);
365 }
366 }
367
368 fn lock_cooldown(&self) -> std::sync::MutexGuard<'_, Option<Instant>> {
374 self.inner
375 .cooldown_until
376 .lock()
377 .unwrap_or_else(|poisoned| poisoned.into_inner())
378 }
379
380 async fn await_cooldown(&self) {
382 loop {
383 let deadline = *self.lock_cooldown();
387 let Some(deadline) = deadline else { return };
388 if deadline <= Instant::now() {
389 return;
390 }
391 tokio::time::sleep_until(deadline).await;
395 }
396 }
397
398 pub async fn acquire(&self, path: &str, method: Option<&Method>) {
404 self.await_cooldown().await;
405 self.inner.default.until_ready().await;
406
407 if let Some(limit) = self.inner.limits.iter().find(|l| l.matches(path, method)) {
408 for bucket in &limit.buckets {
409 bucket.limiter.until_ready().await;
410 }
411 }
412 }
413
414 #[cfg(test)]
420 fn resolve_specs(&self, path: &str, method: Option<&Method>) -> Vec<RateSpec> {
421 self.inner
422 .limits
423 .iter()
424 .find(|l| l.matches(path, method))
425 .map(|l| l.buckets.iter().map(|b| b.spec).collect())
426 .unwrap_or_default()
427 }
428
429 pub fn clob_default() -> Self {
449 let ten_sec = Duration::from_secs(10);
450 let ten_min = Duration::from_secs(600);
451 let get = Some(Method::GET);
452
453 let ledger_group = Bucket::new(900, ten_sec);
455
456 Self {
457 inner: Arc::new(RateLimiterInner {
458 default: DirectLimiter::direct(quota(9_000, ten_sec)),
459 cooldown_until: Mutex::new(None),
460 limits: vec![
461 simple_limit("/balance-allowance/update", None, 50, ten_sec),
464 simple_limit("/balance-allowance", None, 200, ten_sec),
465 dual_limit("/order", Method::POST, (5_000, ten_sec), (120_000, ten_min)),
467 dual_limit(
468 "/order",
469 Method::DELETE,
470 (5_000, ten_sec),
471 (120_000, ten_min),
472 ),
473 dual_limit("/orders", Method::POST, (2_000, ten_sec), (21_000, ten_min)),
474 dual_limit(
475 "/orders",
476 Method::DELETE,
477 (2_000, ten_sec),
478 (15_000, ten_min),
479 ),
480 dual_limit(
481 "/cancel-all",
482 Method::DELETE,
483 (250, ten_sec),
484 (6_000, ten_min),
485 ),
486 dual_limit(
487 "/cancel-market-orders",
488 Method::DELETE,
489 (1_500, ten_sec),
490 (21_000, ten_min),
491 ),
492 endpoint_limit(
495 "/notifications",
496 None,
497 vec![ledger_group.clone(), Bucket::new(125, ten_sec)],
498 ),
499 endpoint_limit("/trades", get.clone(), vec![ledger_group.clone()]),
500 endpoint_limit("/orders", get.clone(), vec![ledger_group.clone()]),
501 endpoint_limit("/order", get.clone(), vec![ledger_group]),
502 simple_limit("/data/orders", None, 500, ten_sec),
506 simple_limit("/data/trades", None, 500, ten_sec),
507 simple_limit("/data", None, 500, ten_sec),
508 simple_limit("/auth", None, 100, ten_sec),
510 simple_limit("/prices-history", None, 1_000, ten_sec),
514 simple_limit("/book", None, 1_500, ten_sec),
515 simple_limit("/books", None, 500, ten_sec),
516 simple_limit("/price", None, 1_500, ten_sec),
517 simple_limit("/prices", None, 500, ten_sec),
518 simple_limit("/midpoint", None, 1_500, ten_sec),
519 simple_limit("/midpoints", None, 500, ten_sec),
520 simple_limit("/tick-size", None, 200, ten_sec),
521 simple_limit("/ok", None, 100, ten_sec),
523 simple_limit("/markets", None, 1_500, ten_sec),
528 simple_limit("/neg-risk", None, 1_500, ten_sec),
529 ],
530 }),
531 }
532 }
533
534 pub fn gamma_default() -> Self {
554 let ten_sec = Duration::from_secs(10);
555
556 Self {
557 inner: Arc::new(RateLimiterInner {
558 default: DirectLimiter::direct(quota(4_000, ten_sec)),
559 cooldown_until: Mutex::new(None),
560 limits: vec![
561 simple_limit("/comments", None, 200, ten_sec),
562 simple_limit("/tags", None, 200, ten_sec),
563 simple_limit("/markets", None, 300, ten_sec),
564 simple_limit("/public-search", None, 350, ten_sec),
565 simple_limit("/events", None, 500, ten_sec),
566 simple_limit("/status", None, 100, ten_sec),
567 ],
568 }),
569 }
570 }
571
572 pub fn data_default() -> Self {
600 let ten_sec = Duration::from_secs(10);
601
602 Self {
603 inner: Arc::new(RateLimiterInner {
604 default: DirectLimiter::direct(quota(1_000, ten_sec)),
605 cooldown_until: Mutex::new(None),
606 limits: vec![
607 simple_limit("/closed-positions", None, 150, ten_sec),
608 simple_limit("/positions", None, 150, ten_sec),
609 simple_limit("/trades", None, 200, ten_sec),
610 simple_limit("/user-pnl", None, 200, ten_sec),
611 simple_limit("/v2/positions/combos", None, 400, ten_sec),
617 simple_limit("/v2/positions", None, 200, ten_sec),
618 simple_limit("/v2/trades", None, 200, ten_sec),
619 simple_limit("/v2/activity", None, 400, ten_sec),
620 simple_limit("/v2/user-pnl", None, 400, ten_sec),
621 simple_limit("/v2/holders", None, 400, ten_sec),
622 simple_limit("/", None, 100, ten_sec),
623 ],
624 }),
625 }
626 }
627
628 pub fn relay_default() -> Self {
632 Self {
633 inner: Arc::new(RateLimiterInner {
634 default: DirectLimiter::direct(quota(25, Duration::from_secs(60))),
635 cooldown_until: Mutex::new(None),
636 limits: vec![],
637 }),
638 }
639 }
640}
641
642#[derive(Debug, Clone)]
644pub struct RetryConfig {
645 pub max_retries: u32,
647 pub initial_backoff_ms: u64,
649 pub max_backoff_ms: u64,
651}
652
653impl Default for RetryConfig {
654 fn default() -> Self {
655 Self {
656 max_retries: 3,
657 initial_backoff_ms: 500,
658 max_backoff_ms: 10_000,
659 }
660 }
661}
662
663impl RetryConfig {
664 pub fn backoff(&self, attempt: u32) -> Duration {
669 let base = self
670 .initial_backoff_ms
671 .saturating_mul(1u64 << attempt.min(10));
672 let capped = base.min(self.max_backoff_ms);
673 let jitter_factor = 0.75 + (fastrand::f64() * 0.5);
675 let ms = (capped as f64 * jitter_factor) as u64;
676 Duration::from_millis(ms.max(1))
677 }
678}
679
680#[cfg(test)]
681mod agreement {
682 use super::*;
692
693 pub type DocumentedRule = (&'static str, Option<Method>, Vec<(u32, u64)>);
696
697 pub fn assert_matches_published(rl: &RateLimiter, rules: Vec<DocumentedRule>, general: u32) {
703 for (path, method, expected) in rules {
704 let resolved = rl.resolve_specs(path, method.as_ref());
705 assert!(
706 !resolved.is_empty(),
707 "{method:?} {path} matches no endpoint limit — it falls through to the \
708 general {general}/10s bucket, over-permitting by {}x",
709 general / expected[0].0.max(1),
710 );
711 let actual: Vec<(u32, u64)> = resolved
712 .iter()
713 .map(|s| (s.count, s.period.as_secs()))
714 .collect();
715 assert_eq!(
716 actual, expected,
717 "{method:?} {path} resolves to {actual:?}, published limit is {expected:?}"
718 );
719 }
720 }
721
722 pub fn assert_unconfigured(rl: &RateLimiter, path: &str) {
727 assert!(
728 rl.resolve_specs(path, Some(&Method::GET)).is_empty(),
729 "{path} has an endpoint limit configured, but the host answers 404 there — \
730 the entry is dead configuration and the real route is going unlimited"
731 );
732 }
733
734 pub async fn assert_paced_by_its_own_quota(
751 rl: &RateLimiter,
752 path: &str,
753 count: u32,
754 period: Duration,
755 ) {
756 let interval = period / sustained_slots(count);
757
758 rl.acquire(path, Some(&Method::GET)).await;
759
760 let start = std::time::Instant::now();
761 rl.acquire(path, Some(&Method::GET)).await;
762 let waited = start.elapsed();
763
764 assert!(
765 waited >= interval.mul_f64(0.8),
766 "the 2nd request to {path} returned in {waited:?}; {count}/{period:?} should pace \
767 it at {interval:?} and the cap is not being enforced"
768 );
769 assert!(
770 waited <= interval * 3 + Duration::from_millis(25),
771 "the 2nd request to {path} waited {waited:?}, far longer than the {interval:?} its \
772 published {count}/{period:?} implies — it is resolving through a tighter rule"
773 );
774 }
775}
776
777#[cfg(test)]
778mod documented_data_limits {
779 use super::agreement::*;
797 use super::*;
798
799 fn documented() -> Vec<DocumentedRule> {
801 vec![
802 ("/trades", Some(Method::GET), vec![(200, 10)]),
803 ("/positions", Some(Method::GET), vec![(150, 10)]),
804 ("/closed-positions", Some(Method::GET), vec![(150, 10)]),
805 ("/", Some(Method::GET), vec![(100, 10)]),
806 ("/user-pnl", Some(Method::GET), vec![(200, 10)]),
807 ]
808 }
809
810 #[test]
811 fn every_documented_endpoint_resolves_to_its_published_quota() {
812 assert_matches_published(&RateLimiter::data_default(), documented(), 1_000);
813 }
814
815 fn measured_v2() -> Vec<DocumentedRule> {
818 vec![
819 ("/v2/positions", Some(Method::GET), vec![(200, 10)]),
820 ("/v2/positions/combos", Some(Method::GET), vec![(400, 10)]),
821 ("/v2/trades", Some(Method::GET), vec![(200, 10)]),
822 ("/v2/activity", Some(Method::GET), vec![(400, 10)]),
823 ("/v2/user-pnl", Some(Method::GET), vec![(400, 10)]),
824 ("/v2/holders", Some(Method::GET), vec![(400, 10)]),
825 ]
826 }
827
828 #[test]
829 fn every_v2_route_resolves_to_its_measured_quota() {
830 assert_matches_published(&RateLimiter::data_default(), measured_v2(), 1_000);
831 }
832
833 #[test]
834 fn the_health_cap_is_attached_to_the_route_the_host_answers_on() {
835 assert_unconfigured(&RateLimiter::data_default(), "/ok");
838 }
839
840 #[test]
841 fn the_root_health_rule_does_not_swallow_every_other_route() {
842 let rl = RateLimiter::data_default();
846 for (path, expected) in [
847 ("/positions", 150),
848 ("/closed-positions", 150),
849 ("/trades", 200),
850 ("/", 100),
851 ] {
852 let specs = rl.resolve_specs(path, Some(&Method::GET));
853 assert_eq!(
854 specs[0].count, expected,
855 "{path} resolved through the wrong rule — the `/` entry is over-matching"
856 );
857 }
858 }
859
860 #[tokio::test]
861 async fn the_closed_positions_cap_actually_throttles() {
862 assert_paced_by_its_own_quota(
864 &RateLimiter::data_default(),
865 "/closed-positions",
866 150,
867 Duration::from_secs(10),
868 )
869 .await;
870 }
871
872 #[tokio::test]
873 async fn closed_positions_and_positions_do_not_share_an_allowance() {
874 let rl = RateLimiter::data_default();
883 rl.acquire("/closed-positions", Some(&Method::GET)).await;
884
885 let start = std::time::Instant::now();
886 rl.acquire("/positions", Some(&Method::GET)).await;
887 assert!(
888 start.elapsed() < Duration::from_millis(25),
889 "/positions was throttled by /closed-positions emptying its own bucket"
890 );
891 }
892}
893
894#[cfg(test)]
895mod documented_gamma_limits {
896 use super::agreement::*;
904 use super::*;
905
906 fn documented() -> Vec<DocumentedRule> {
908 vec![
909 ("/events", Some(Method::GET), vec![(500, 10)]),
910 ("/public-search", Some(Method::GET), vec![(350, 10)]),
911 ("/markets", Some(Method::GET), vec![(300, 10)]),
912 ("/comments", Some(Method::GET), vec![(200, 10)]),
913 ("/tags", Some(Method::GET), vec![(200, 10)]),
914 ("/status", Some(Method::GET), vec![(100, 10)]),
915 ]
916 }
917
918 #[test]
919 fn every_documented_endpoint_resolves_to_its_published_quota() {
920 assert_matches_published(&RateLimiter::gamma_default(), documented(), 4_000);
921 }
922
923 #[test]
924 fn the_health_cap_is_attached_to_the_route_the_host_answers_on() {
925 assert_unconfigured(&RateLimiter::gamma_default(), "/ok");
926 }
927
928 #[test]
929 fn the_markets_plus_events_group_cap_can_never_bind() {
930 let rl = RateLimiter::gamma_default();
935 let markets = rl.resolve_specs("/markets", Some(&Method::GET))[0].count;
936 let events = rl.resolve_specs("/events", Some(&Method::GET))[0].count;
937 assert!(
938 markets + events <= 900,
939 "/markets ({markets}) + /events ({events}) now exceeds the published 900/10s \
940 group cap, which is no longer unreachable and must be modelled"
941 );
942 }
943
944 #[tokio::test]
945 async fn the_markets_cap_actually_throttles() {
946 assert_paced_by_its_own_quota(
947 &RateLimiter::gamma_default(),
948 "/markets",
949 300,
950 Duration::from_secs(10),
951 )
952 .await;
953 }
954}
955
956#[cfg(test)]
957mod documented_limits {
958 use super::agreement::{assert_paced_by_its_own_quota, DocumentedRule};
968 use super::*;
969
970 fn documented() -> Vec<DocumentedRule> {
972 vec![
973 ("/balance-allowance", Some(Method::GET), vec![(200, 10)]),
975 (
976 "/balance-allowance/update",
977 Some(Method::GET),
978 vec![(50, 10)],
979 ),
980 (
982 "/order",
983 Some(Method::POST),
984 vec![(5_000, 10), (120_000, 600)],
985 ),
986 (
987 "/order",
988 Some(Method::DELETE),
989 vec![(5_000, 10), (120_000, 600)],
990 ),
991 (
992 "/orders",
993 Some(Method::POST),
994 vec![(2_000, 10), (21_000, 600)],
995 ),
996 (
997 "/orders",
998 Some(Method::DELETE),
999 vec![(2_000, 10), (15_000, 600)],
1000 ),
1001 (
1002 "/cancel-all",
1003 Some(Method::DELETE),
1004 vec![(250, 10), (6_000, 600)],
1005 ),
1006 (
1007 "/cancel-market-orders",
1008 Some(Method::DELETE),
1009 vec![(1_500, 10), (21_000, 600)],
1010 ),
1011 ("/trades", Some(Method::GET), vec![(900, 10)]),
1013 ("/orders", Some(Method::GET), vec![(900, 10)]),
1014 ("/order", Some(Method::GET), vec![(900, 10)]),
1015 (
1016 "/notifications",
1017 Some(Method::GET),
1018 vec![(900, 10), (125, 10)],
1019 ),
1020 ("/data/orders", Some(Method::GET), vec![(500, 10)]),
1021 ("/data/trades", Some(Method::GET), vec![(500, 10)]),
1022 ("/book", Some(Method::GET), vec![(1_500, 10)]),
1024 ("/books", Some(Method::POST), vec![(500, 10)]),
1025 ("/price", Some(Method::GET), vec![(1_500, 10)]),
1026 ("/prices", Some(Method::POST), vec![(500, 10)]),
1027 ("/midpoint", Some(Method::GET), vec![(1_500, 10)]),
1028 ("/midpoints", Some(Method::POST), vec![(500, 10)]),
1029 ("/prices-history", Some(Method::GET), vec![(1_000, 10)]),
1030 ("/tick-size", Some(Method::GET), vec![(200, 10)]),
1031 ("/auth/api-key", Some(Method::POST), vec![(100, 10)]),
1033 ("/ok", Some(Method::GET), vec![(100, 10)]),
1034 ]
1035 }
1036
1037 #[test]
1038 fn every_documented_endpoint_resolves_to_its_published_quota() {
1039 let rl = RateLimiter::clob_default();
1040
1041 for (path, method, expected) in documented() {
1042 let resolved = rl.resolve_specs(path, method.as_ref());
1043 assert!(
1044 !resolved.is_empty(),
1045 "{method:?} {path} matches no endpoint limit — it falls through to the \
1046 general {}/10s bucket, over-permitting by {}x",
1047 9_000,
1048 9_000 / expected[0].0.max(1),
1049 );
1050 let actual: Vec<(u32, u64)> = resolved
1051 .iter()
1052 .map(|s| (s.count, s.period.as_secs()))
1053 .collect();
1054 assert_eq!(
1055 actual, expected,
1056 "{method:?} {path} resolves to {actual:?}, published limit is {expected:?}"
1057 );
1058 }
1059 }
1060
1061 #[test]
1062 fn batch_endpoints_do_not_inherit_their_singular_sibling() {
1063 let rl = RateLimiter::clob_default();
1066 for (batch, singular) in [
1067 ("/books", "/book"),
1068 ("/prices", "/price"),
1069 ("/midpoints", "/midpoint"),
1070 ] {
1071 let batch_specs = rl.resolve_specs(batch, Some(&Method::POST));
1072 let singular_specs = rl.resolve_specs(singular, Some(&Method::GET));
1073 assert_ne!(
1074 batch_specs, singular_specs,
1075 "{batch} is being limited as if it were {singular}"
1076 );
1077 assert_eq!(batch_specs[0].count, 500, "{batch} should allow 500/10s");
1078 }
1079 }
1080
1081 #[test]
1082 fn the_ledger_group_cap_is_one_shared_bucket() {
1083 let rl = RateLimiter::clob_default();
1087 let group: Vec<_> = ["/trades", "/orders", "/order", "/notifications"]
1088 .iter()
1089 .map(|p| {
1090 rl.inner
1091 .limits
1092 .iter()
1093 .find(|l| l.matches(p, Some(&Method::GET)))
1094 .unwrap_or_else(|| panic!("{p} should match a ledger entry"))
1095 .buckets[0]
1096 .clone()
1097 })
1098 .collect();
1099
1100 for other in &group[1..] {
1101 assert!(
1102 Arc::ptr_eq(&group[0], other),
1103 "ledger endpoints must share one bucket, not hold copies"
1104 );
1105 }
1106 }
1107
1108 #[test]
1109 fn balance_allowance_update_is_not_shadowed_by_its_parent_path() {
1110 let rl = RateLimiter::clob_default();
1114 let update = rl.resolve_specs("/balance-allowance/update", Some(&Method::GET));
1115 assert_eq!(
1116 update[0].count, 50,
1117 "the tighter /balance-allowance/update rule must be ordered first"
1118 );
1119 }
1120
1121 #[tokio::test]
1122 async fn a_documented_cap_actually_throttles() {
1123 assert_paced_by_its_own_quota(
1126 &RateLimiter::clob_default(),
1127 "/tick-size",
1128 200,
1129 Duration::from_secs(10),
1130 )
1131 .await;
1132 }
1133
1134 #[tokio::test]
1135 async fn the_ledger_group_allowance_is_consumed_jointly() {
1136 let rl = RateLimiter::clob_default();
1141 rl.acquire("/trades", Some(&Method::GET)).await;
1142
1143 let start = std::time::Instant::now();
1144 rl.acquire("/orders", Some(&Method::GET)).await;
1145 let waited = start.elapsed();
1146
1147 assert!(
1148 waited >= Duration::from_millis(5),
1149 "GET /orders returned in {waited:?} after /trades consumed from the shared 900/10s \
1150 allowance — the group cap is not actually shared"
1151 );
1152 }
1153
1154 #[test]
1155 fn post_order_is_not_throttled_by_the_ledger_group() {
1156 let rl = RateLimiter::clob_default();
1161 let specs = rl.resolve_specs("/order", Some(&Method::POST));
1162 assert_eq!(specs[0].count, 5_000);
1163 assert!(
1164 !specs.iter().any(|s| s.count == 900),
1165 "POST /order must not be caught by the ledger read cap"
1166 );
1167 }
1168}
1169
1170#[cfg(test)]
1171mod tests {
1172 use super::*;
1173
1174 #[test]
1177 fn test_retry_config_default() {
1178 let cfg = RetryConfig::default();
1179 assert_eq!(cfg.max_retries, 3);
1180 assert_eq!(cfg.initial_backoff_ms, 500);
1181 assert_eq!(cfg.max_backoff_ms, 10_000);
1182 }
1183
1184 #[test]
1185 fn test_backoff_attempt_zero() {
1186 let cfg = RetryConfig::default();
1187 let d = cfg.backoff(0);
1188 let ms = d.as_millis() as u64;
1191 assert!(
1192 (375..=625).contains(&ms),
1193 "attempt 0: {ms}ms not in [375, 625]"
1194 );
1195 }
1196
1197 #[test]
1198 fn test_backoff_exponential_growth() {
1199 let cfg = RetryConfig::default();
1200 let d0 = cfg.backoff(0);
1201 let d1 = cfg.backoff(1);
1202 let d2 = cfg.backoff(2);
1203 assert!(d0 < d1, "d0={d0:?} should be < d1={d1:?}");
1204 assert!(d1 < d2, "d1={d1:?} should be < d2={d2:?}");
1205 }
1206
1207 #[test]
1208 fn test_backoff_jitter_bounds() {
1209 let cfg = RetryConfig::default();
1210 for attempt in 0..20 {
1211 let d = cfg.backoff(attempt);
1212 let base = cfg
1213 .initial_backoff_ms
1214 .saturating_mul(1u64 << attempt.min(10));
1215 let capped = base.min(cfg.max_backoff_ms);
1216 let lower = (capped as f64 * 0.75) as u64;
1217 let upper = (capped as f64 * 1.25) as u64;
1218 let ms = d.as_millis() as u64;
1219 assert!(
1220 ms >= lower.max(1) && ms <= upper,
1221 "attempt {attempt}: {ms}ms not in [{lower}, {upper}]"
1222 );
1223 }
1224 }
1225
1226 #[test]
1227 fn test_backoff_max_capping() {
1228 let cfg = RetryConfig::default();
1229 for attempt in 5..=10 {
1230 let d = cfg.backoff(attempt);
1231 let ceiling = (cfg.max_backoff_ms as f64 * 1.25) as u64;
1232 assert!(
1233 d.as_millis() as u64 <= ceiling,
1234 "attempt {attempt}: {:?} exceeded ceiling {ceiling}ms",
1235 d
1236 );
1237 }
1238 }
1239
1240 #[test]
1241 fn test_backoff_very_high_attempt() {
1242 let cfg = RetryConfig::default();
1243 let d = cfg.backoff(100);
1244 let ceiling = (cfg.max_backoff_ms as f64 * 1.25) as u64;
1245 assert!(d.as_millis() as u64 <= ceiling);
1246 assert!(d.as_millis() >= 1);
1247 }
1248
1249 #[test]
1250 fn test_backoff_jitter_distribution() {
1251 let cfg = RetryConfig::default();
1254 let midpoint = cfg.initial_backoff_ms; let (mut below, mut above) = (0u32, 0u32);
1256 for _ in 0..200 {
1257 let ms = cfg.backoff(0).as_millis() as u64;
1258 if ms < midpoint {
1259 below += 1;
1260 } else {
1261 above += 1;
1262 }
1263 }
1264 assert!(
1265 below >= 20 && above >= 20,
1266 "jitter looks degenerate: {below} below midpoint, {above} above"
1267 );
1268 }
1269
1270 #[test]
1273 fn test_quota_creation() {
1274 let _ = quota(100, Duration::from_secs(10));
1276 let _ = quota(1, Duration::from_secs(60));
1277 let _ = quota(9_000, Duration::from_secs(10));
1278 }
1279
1280 #[test]
1281 fn test_quota_edge_zero_count() {
1282 let _ = quota(0, Duration::from_secs(10));
1285 let _ = quota(1, Duration::from_secs(10));
1286 }
1287
1288 #[test]
1291 fn test_clob_default_construction() {
1292 let rl = RateLimiter::clob_default();
1293 assert_eq!(rl.inner.limits.len(), 27);
1294 assert!(format!("{:?}", rl).contains("endpoints"));
1295 }
1296
1297 #[test]
1298 fn test_gamma_default_construction() {
1299 let rl = RateLimiter::gamma_default();
1300 assert_eq!(rl.inner.limits.len(), 6);
1301 }
1302
1303 #[test]
1304 fn test_data_default_construction() {
1305 let rl = RateLimiter::data_default();
1306 assert_eq!(rl.inner.limits.len(), 11);
1307 }
1308
1309 #[test]
1310 fn test_relay_default_construction() {
1311 let rl = RateLimiter::relay_default();
1312 assert_eq!(rl.inner.limits.len(), 0);
1313 }
1314
1315 #[test]
1316 fn test_rate_limiter_debug_format() {
1317 let rl = RateLimiter::clob_default();
1318 let dbg = format!("{:?}", rl);
1319 assert!(dbg.contains("RateLimiter"), "missing struct name: {dbg}");
1320 assert!(dbg.contains("endpoints: 27"), "missing count: {dbg}");
1321 }
1322
1323 #[test]
1326 fn test_clob_tighter_rules_precede_the_prefixes_that_would_shadow_them() {
1327 let rl = RateLimiter::clob_default();
1331 let index_of = |path: &str| {
1332 rl.inner
1333 .limits
1334 .iter()
1335 .position(|l| l.path_prefix == path)
1336 .unwrap_or_else(|| panic!("{path} should be configured"))
1337 };
1338
1339 for (specific, general) in [
1340 ("/balance-allowance/update", "/balance-allowance"),
1341 ("/data/orders", "/data"),
1342 ("/data/trades", "/data"),
1343 ] {
1344 assert!(
1345 index_of(specific) < index_of(general),
1346 "{specific} must be matched before {general} or it can never win"
1347 );
1348 }
1349 }
1350
1351 #[tokio::test]
1354 async fn test_acquire_single_completes_immediately() {
1355 let rl = RateLimiter::clob_default();
1356 let start = std::time::Instant::now();
1357 rl.acquire("/order", Some(&Method::POST)).await;
1358 assert!(start.elapsed() < Duration::from_millis(50));
1359 }
1360
1361 #[tokio::test]
1362 async fn test_acquire_matches_endpoint_by_prefix() {
1363 let rl = RateLimiter::clob_default();
1364 let start = std::time::Instant::now();
1365 rl.acquire("/order/123", Some(&Method::POST)).await;
1367 assert!(start.elapsed() < Duration::from_millis(50));
1368 }
1369
1370 #[tokio::test]
1371 async fn test_acquire_prefix_respects_segment_boundary() {
1372 let rl = RateLimiter::clob_default();
1373 let limits = &rl.inner.limits;
1374
1375 let price_idx = limits
1377 .iter()
1378 .position(|l| l.path_prefix == "/price")
1379 .expect("/price endpoint exists");
1380
1381 let prices_history_idx = limits
1383 .iter()
1384 .position(|l| l.path_prefix == "/prices-history")
1385 .expect("/prices-history endpoint exists");
1386
1387 assert!(
1389 prices_history_idx < price_idx,
1390 "/prices-history (idx {prices_history_idx}) should come before /price (idx {price_idx})"
1391 );
1392 }
1393
1394 #[test]
1395 fn test_match_mode_prefix_segment_boundary() {
1396 let pattern = "/price";
1398
1399 let check = |path: &str| -> bool {
1400 match path.strip_prefix(pattern) {
1401 Some(rest) => rest.is_empty() || rest.starts_with('/') || rest.starts_with('?'),
1402 None => false,
1403 }
1404 };
1405
1406 assert!(check("/price"), "exact match");
1408 assert!(check("/price/foo"), "sub-path");
1409 assert!(check("/price?token=abc"), "query params");
1410
1411 assert!(!check("/prices-history"), "partial word /prices-history");
1413 assert!(!check("/pricelist"), "partial word /pricelist");
1414 assert!(!check("/pricing"), "partial word /pricing");
1415
1416 assert!(!check("/midpoint"), "different prefix");
1418 }
1419
1420 #[test]
1421 fn test_match_mode_exact() {
1422 let pattern = "/trades";
1424
1425 let check = |path: &str| -> bool { path == pattern };
1426
1427 assert!(check("/trades"), "exact match");
1428 assert!(!check("/trades/123"), "sub-path should not match");
1429 assert!(!check("/trades?limit=10"), "query params should not match");
1430 assert!(!check("/traded"), "different word should not match");
1431 }
1432
1433 #[tokio::test]
1434 async fn test_acquire_method_filtering() {
1435 let rl = RateLimiter::clob_default();
1436 let start = std::time::Instant::now();
1437 rl.acquire("/order", Some(&Method::GET)).await;
1439 assert!(start.elapsed() < Duration::from_millis(50));
1440 }
1441
1442 #[tokio::test]
1443 async fn test_acquire_no_endpoint_match_uses_default_only() {
1444 let rl = RateLimiter::clob_default();
1445 let start = std::time::Instant::now();
1446 rl.acquire("/unknown/path", None).await;
1447 assert!(start.elapsed() < Duration::from_millis(50));
1448 }
1449
1450 #[tokio::test]
1451 async fn test_acquire_method_none_matches_any_method() {
1452 let rl = RateLimiter::gamma_default();
1453 let start = std::time::Instant::now();
1454 rl.acquire("/events", Some(&Method::GET)).await;
1456 rl.acquire("/events", Some(&Method::POST)).await;
1457 rl.acquire("/events", None).await;
1458 assert!(start.elapsed() < Duration::from_millis(50));
1459 }
1460
1461 #[test]
1464 fn test_clob_price_and_prices_history_are_distinct() {
1465 let rl = RateLimiter::clob_default();
1466 let limits = &rl.inner.limits;
1467
1468 let price = limits.iter().find(|l| l.path_prefix == "/price").unwrap();
1469 let prices_history = limits
1470 .iter()
1471 .find(|l| l.path_prefix == "/prices-history")
1472 .unwrap();
1473
1474 assert_eq!(price.match_mode, MatchMode::Prefix);
1476 assert_eq!(prices_history.match_mode, MatchMode::Prefix);
1477
1478 if let Some(rest) = "/prices-history".strip_prefix(price.path_prefix) {
1480 assert!(
1481 !rest.is_empty() && !rest.starts_with('/') && !rest.starts_with('?'),
1482 "/prices-history must not match /price pattern, rest = '{rest}'"
1483 );
1484 }
1485 }
1486
1487 #[test]
1488 fn test_data_positions_and_closed_positions_are_distinct() {
1489 let rl = RateLimiter::data_default();
1494
1495 let closed = rl.resolve_specs("/closed-positions", Some(&Method::GET));
1496 let positions = rl.resolve_specs("/positions", Some(&Method::GET));
1497 assert_eq!(closed, positions, "both are published at 150/10s");
1498
1499 let bucket_for = |path: &str| {
1500 rl.inner
1501 .limits
1502 .iter()
1503 .find(|l| l.matches(path, Some(&Method::GET)))
1504 .unwrap_or_else(|| panic!("{path} should match a rule"))
1505 .buckets[0]
1506 .clone()
1507 };
1508 assert!(
1509 !Arc::ptr_eq(&bucket_for("/closed-positions"), &bucket_for("/positions")),
1510 "equal quotas must still be separate buckets — upstream publishes \
1511 150/10s each, not 150/10s combined"
1512 );
1513 }
1514
1515 #[test]
1516 fn test_all_clob_endpoints_have_match_mode() {
1517 let rl = RateLimiter::clob_default();
1518 for limit in &rl.inner.limits {
1519 assert!(
1521 limit.match_mode == MatchMode::Prefix || limit.match_mode == MatchMode::Exact,
1522 "endpoint {} has no valid match mode",
1523 limit.path_prefix
1524 );
1525 }
1526 }
1527
1528 #[tokio::test]
1531 async fn concurrent_acquires_are_paced_against_one_shared_allowance() {
1532 const TASKS: u32 = 10;
1541 let interval = Duration::from_secs(10) / (1_500 - 1);
1542
1543 let rl = std::sync::Arc::new(RateLimiter::clob_default());
1544
1545 let start = std::time::Instant::now();
1546 let mut handles = Vec::new();
1547 for _ in 0..TASKS {
1548 let rl = rl.clone();
1549 handles.push(tokio::spawn(async move {
1550 rl.acquire("/markets", None).await;
1551 }));
1552 }
1553 for handle in handles {
1554 handle.await.unwrap();
1555 }
1556 let elapsed = start.elapsed();
1557
1558 assert!(
1559 elapsed >= interval * (TASKS - 1) / 2,
1560 "{TASKS} concurrent acquires completed in {elapsed:?}; pacing at {interval:?} each \
1561 they cannot, so concurrent tasks are not sharing one allowance"
1562 );
1563 assert!(
1564 elapsed < Duration::from_secs(1),
1565 "{TASKS} concurrent acquires took {elapsed:?} — they are stalling, not pacing"
1566 );
1567 }
1568
1569 #[tokio::test]
1570 async fn test_acquire_concurrent_different_endpoints() {
1571 let rl = std::sync::Arc::new(RateLimiter::clob_default());
1573
1574 let rl1 = rl.clone();
1575 let rl2 = rl.clone();
1576 let rl3 = rl.clone();
1577
1578 let start = std::time::Instant::now();
1579 let (r1, r2, r3) = tokio::join!(
1580 tokio::spawn(async move { rl1.acquire("/markets", None).await }),
1581 tokio::spawn(async move { rl2.acquire("/auth", None).await }),
1582 tokio::spawn(async move { rl3.acquire("/order", Some(&Method::POST)).await }),
1583 );
1584 r1.unwrap();
1585 r2.unwrap();
1586 r3.unwrap();
1587
1588 assert!(
1589 start.elapsed() < Duration::from_millis(50),
1590 "different endpoints should not block: {:?}",
1591 start.elapsed()
1592 );
1593 }
1594
1595 #[test]
1598 fn test_clob_post_order_has_dual_window() {
1599 let rl = RateLimiter::clob_default();
1600 let post_order = rl
1601 .inner
1602 .limits
1603 .iter()
1604 .find(|l| l.path_prefix == "/order" && l.method == Some(Method::POST))
1605 .expect("POST /order endpoint should exist");
1606
1607 assert_eq!(
1608 post_order.buckets.len(),
1609 2,
1610 "POST /order should have a burst and a sustained window"
1611 );
1612 }
1613
1614 #[test]
1615 fn test_clob_delete_order_has_a_sustained_window_too() {
1616 let rl = RateLimiter::clob_default();
1620 let delete_order = rl
1621 .inner
1622 .limits
1623 .iter()
1624 .find(|l| l.path_prefix == "/order" && l.method == Some(Method::DELETE))
1625 .expect("DELETE /order endpoint should exist");
1626
1627 assert_eq!(
1628 delete_order.buckets.len(),
1629 2,
1630 "DELETE /order should have both a burst and a sustained window"
1631 );
1632 }
1633
1634 #[tokio::test]
1635 async fn test_dual_window_both_burst_and_sustained_are_awaited() {
1636 let rl = RateLimiter::clob_default();
1639 let start = std::time::Instant::now();
1640 rl.acquire("/order", Some(&Method::POST)).await;
1641 assert!(
1642 start.elapsed() < Duration::from_millis(50),
1643 "dual window single acquire should be fast: {:?}",
1644 start.elapsed()
1645 );
1646 }
1647
1648 #[test]
1651 fn test_should_retry_exhaustion() {
1652 let client = crate::HttpClientBuilder::new("https://example.com")
1654 .with_retry_config(RetryConfig {
1655 max_retries: 3,
1656 ..RetryConfig::default()
1657 })
1658 .build()
1659 .unwrap();
1660
1661 for attempt in 0..3 {
1663 assert!(
1664 client
1665 .should_retry(reqwest::StatusCode::TOO_MANY_REQUESTS, attempt, None)
1666 .is_some(),
1667 "attempt {attempt} should allow retry"
1668 );
1669 }
1670 assert!(
1672 client
1673 .should_retry(reqwest::StatusCode::TOO_MANY_REQUESTS, 3, None)
1674 .is_none(),
1675 "attempt 3 should exhaust retries"
1676 );
1677 }
1678
1679 #[test]
1680 fn test_should_retry_zero_max_retries_never_retries() {
1681 let client = crate::HttpClientBuilder::new("https://example.com")
1682 .with_retry_config(RetryConfig {
1683 max_retries: 0,
1684 ..RetryConfig::default()
1685 })
1686 .build()
1687 .unwrap();
1688
1689 assert!(
1690 client
1691 .should_retry(reqwest::StatusCode::TOO_MANY_REQUESTS, 0, None)
1692 .is_none(),
1693 "max_retries=0 should never retry"
1694 );
1695 }
1696}
1697
1698#[cfg(test)]
1699mod cooldown_tests {
1700 use super::*;
1710
1711 #[tokio::test(start_paused = true)]
1712 async fn acquire_is_immediate_without_a_cooldown() {
1713 let rl = RateLimiter::data_default();
1714 let t = tokio::time::Instant::now();
1715 rl.acquire("/closed-positions", None).await;
1716 assert!(
1717 t.elapsed() < Duration::from_millis(1),
1718 "an untripped limiter must not delay: waited {:?}",
1719 t.elapsed()
1720 );
1721 }
1722
1723 #[tokio::test(start_paused = true)]
1724 async fn a_cooldown_holds_back_a_path_that_never_saw_the_429() {
1725 let rl = RateLimiter::data_default();
1726 rl.begin_cooldown(Duration::from_secs(5));
1727
1728 let t = tokio::time::Instant::now();
1731 rl.acquire("/trades", None).await;
1732 assert!(
1733 t.elapsed() >= Duration::from_secs(5),
1734 "a sibling path resumed after {:?}, before the cooldown expired",
1735 t.elapsed()
1736 );
1737 }
1738
1739 #[tokio::test(start_paused = true)]
1740 async fn concurrent_requests_all_observe_one_cooldown() {
1741 let rl = RateLimiter::data_default();
1742 rl.begin_cooldown(Duration::from_secs(3));
1743
1744 let t = tokio::time::Instant::now();
1747 tokio::join!(
1748 rl.acquire("/closed-positions", None),
1749 rl.acquire("/closed-positions", None),
1750 rl.acquire("/closed-positions", None),
1751 rl.acquire("/closed-positions", None),
1752 );
1753 assert!(
1754 t.elapsed() >= Duration::from_secs(3),
1755 "concurrent callers resumed after {:?}",
1756 t.elapsed()
1757 );
1758 }
1759
1760 #[tokio::test(start_paused = true)]
1761 async fn a_shorter_cooldown_never_cuts_a_longer_one_short() {
1762 let rl = RateLimiter::data_default();
1763 rl.begin_cooldown(Duration::from_secs(10));
1764 rl.begin_cooldown(Duration::from_secs(1));
1768
1769 let t = tokio::time::Instant::now();
1770 rl.acquire("/positions", None).await;
1771 assert!(
1772 t.elapsed() >= Duration::from_secs(10),
1773 "the longer cooldown was truncated to {:?}",
1774 t.elapsed()
1775 );
1776 }
1777
1778 #[tokio::test(start_paused = true)]
1779 async fn a_cooldown_extended_mid_wait_is_honoured_in_full() {
1780 let rl = RateLimiter::data_default();
1781 rl.begin_cooldown(Duration::from_secs(2));
1782
1783 let extender = {
1784 let rl = rl.clone();
1785 tokio::spawn(async move {
1786 tokio::time::sleep(Duration::from_secs(1)).await;
1787 rl.begin_cooldown(Duration::from_secs(5));
1788 })
1789 };
1790
1791 let t = tokio::time::Instant::now();
1792 rl.acquire("/closed-positions", None).await;
1793 extender.await.unwrap();
1794 assert!(
1797 t.elapsed() >= Duration::from_secs(6),
1798 "resumed at {:?}, ignoring the cooldown extension",
1799 t.elapsed()
1800 );
1801 }
1802
1803 #[tokio::test(start_paused = true)]
1804 async fn an_expired_cooldown_stops_delaying() {
1805 let rl = RateLimiter::data_default();
1806 rl.begin_cooldown(Duration::from_secs(2));
1807 rl.acquire("/closed-positions", None).await;
1808
1809 let t = tokio::time::Instant::now();
1810 rl.acquire("/closed-positions", None).await;
1811 assert!(
1812 t.elapsed() < Duration::from_millis(1),
1813 "the limiter stayed blocked for {:?} after the cooldown expired",
1814 t.elapsed()
1815 );
1816 }
1817}