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 {
598 let ten_sec = Duration::from_secs(10);
599
600 Self {
601 inner: Arc::new(RateLimiterInner {
602 default: DirectLimiter::direct(quota(1_000, ten_sec)),
603 cooldown_until: Mutex::new(None),
604 limits: vec![
605 simple_limit("/closed-positions", None, 150, ten_sec),
606 simple_limit("/positions", None, 150, ten_sec),
607 simple_limit("/trades", None, 200, ten_sec),
608 simple_limit("/user-pnl", None, 200, ten_sec),
609 simple_limit("/", None, 100, ten_sec),
610 ],
611 }),
612 }
613 }
614
615 pub fn relay_default() -> Self {
619 Self {
620 inner: Arc::new(RateLimiterInner {
621 default: DirectLimiter::direct(quota(25, Duration::from_secs(60))),
622 cooldown_until: Mutex::new(None),
623 limits: vec![],
624 }),
625 }
626 }
627}
628
629#[derive(Debug, Clone)]
631pub struct RetryConfig {
632 pub max_retries: u32,
634 pub initial_backoff_ms: u64,
636 pub max_backoff_ms: u64,
638}
639
640impl Default for RetryConfig {
641 fn default() -> Self {
642 Self {
643 max_retries: 3,
644 initial_backoff_ms: 500,
645 max_backoff_ms: 10_000,
646 }
647 }
648}
649
650impl RetryConfig {
651 pub fn backoff(&self, attempt: u32) -> Duration {
656 let base = self
657 .initial_backoff_ms
658 .saturating_mul(1u64 << attempt.min(10));
659 let capped = base.min(self.max_backoff_ms);
660 let jitter_factor = 0.75 + (fastrand::f64() * 0.5);
662 let ms = (capped as f64 * jitter_factor) as u64;
663 Duration::from_millis(ms.max(1))
664 }
665}
666
667#[cfg(test)]
668mod agreement {
669 use super::*;
679
680 pub type DocumentedRule = (&'static str, Option<Method>, Vec<(u32, u64)>);
683
684 pub fn assert_matches_published(rl: &RateLimiter, rules: Vec<DocumentedRule>, general: u32) {
690 for (path, method, expected) in rules {
691 let resolved = rl.resolve_specs(path, method.as_ref());
692 assert!(
693 !resolved.is_empty(),
694 "{method:?} {path} matches no endpoint limit — it falls through to the \
695 general {general}/10s bucket, over-permitting by {}x",
696 general / expected[0].0.max(1),
697 );
698 let actual: Vec<(u32, u64)> = resolved
699 .iter()
700 .map(|s| (s.count, s.period.as_secs()))
701 .collect();
702 assert_eq!(
703 actual, expected,
704 "{method:?} {path} resolves to {actual:?}, published limit is {expected:?}"
705 );
706 }
707 }
708
709 pub fn assert_unconfigured(rl: &RateLimiter, path: &str) {
714 assert!(
715 rl.resolve_specs(path, Some(&Method::GET)).is_empty(),
716 "{path} has an endpoint limit configured, but the host answers 404 there — \
717 the entry is dead configuration and the real route is going unlimited"
718 );
719 }
720
721 pub async fn assert_paced_by_its_own_quota(
738 rl: &RateLimiter,
739 path: &str,
740 count: u32,
741 period: Duration,
742 ) {
743 let interval = period / sustained_slots(count);
744
745 rl.acquire(path, Some(&Method::GET)).await;
746
747 let start = std::time::Instant::now();
748 rl.acquire(path, Some(&Method::GET)).await;
749 let waited = start.elapsed();
750
751 assert!(
752 waited >= interval.mul_f64(0.8),
753 "the 2nd request to {path} returned in {waited:?}; {count}/{period:?} should pace \
754 it at {interval:?} and the cap is not being enforced"
755 );
756 assert!(
757 waited <= interval * 3 + Duration::from_millis(25),
758 "the 2nd request to {path} waited {waited:?}, far longer than the {interval:?} its \
759 published {count}/{period:?} implies — it is resolving through a tighter rule"
760 );
761 }
762}
763
764#[cfg(test)]
765mod documented_data_limits {
766 use super::agreement::*;
784 use super::*;
785
786 fn documented() -> Vec<DocumentedRule> {
788 vec![
789 ("/trades", Some(Method::GET), vec![(200, 10)]),
790 ("/positions", Some(Method::GET), vec![(150, 10)]),
791 ("/closed-positions", Some(Method::GET), vec![(150, 10)]),
792 ("/", Some(Method::GET), vec![(100, 10)]),
793 ("/user-pnl", Some(Method::GET), vec![(200, 10)]),
794 ]
795 }
796
797 #[test]
798 fn every_documented_endpoint_resolves_to_its_published_quota() {
799 assert_matches_published(&RateLimiter::data_default(), documented(), 1_000);
800 }
801
802 #[test]
803 fn the_health_cap_is_attached_to_the_route_the_host_answers_on() {
804 assert_unconfigured(&RateLimiter::data_default(), "/ok");
807 }
808
809 #[test]
810 fn the_root_health_rule_does_not_swallow_every_other_route() {
811 let rl = RateLimiter::data_default();
815 for (path, expected) in [
816 ("/positions", 150),
817 ("/closed-positions", 150),
818 ("/trades", 200),
819 ("/", 100),
820 ] {
821 let specs = rl.resolve_specs(path, Some(&Method::GET));
822 assert_eq!(
823 specs[0].count, expected,
824 "{path} resolved through the wrong rule — the `/` entry is over-matching"
825 );
826 }
827 }
828
829 #[tokio::test]
830 async fn the_closed_positions_cap_actually_throttles() {
831 assert_paced_by_its_own_quota(
833 &RateLimiter::data_default(),
834 "/closed-positions",
835 150,
836 Duration::from_secs(10),
837 )
838 .await;
839 }
840
841 #[tokio::test]
842 async fn closed_positions_and_positions_do_not_share_an_allowance() {
843 let rl = RateLimiter::data_default();
852 rl.acquire("/closed-positions", Some(&Method::GET)).await;
853
854 let start = std::time::Instant::now();
855 rl.acquire("/positions", Some(&Method::GET)).await;
856 assert!(
857 start.elapsed() < Duration::from_millis(25),
858 "/positions was throttled by /closed-positions emptying its own bucket"
859 );
860 }
861}
862
863#[cfg(test)]
864mod documented_gamma_limits {
865 use super::agreement::*;
873 use super::*;
874
875 fn documented() -> Vec<DocumentedRule> {
877 vec![
878 ("/events", Some(Method::GET), vec![(500, 10)]),
879 ("/public-search", Some(Method::GET), vec![(350, 10)]),
880 ("/markets", Some(Method::GET), vec![(300, 10)]),
881 ("/comments", Some(Method::GET), vec![(200, 10)]),
882 ("/tags", Some(Method::GET), vec![(200, 10)]),
883 ("/status", Some(Method::GET), vec![(100, 10)]),
884 ]
885 }
886
887 #[test]
888 fn every_documented_endpoint_resolves_to_its_published_quota() {
889 assert_matches_published(&RateLimiter::gamma_default(), documented(), 4_000);
890 }
891
892 #[test]
893 fn the_health_cap_is_attached_to_the_route_the_host_answers_on() {
894 assert_unconfigured(&RateLimiter::gamma_default(), "/ok");
895 }
896
897 #[test]
898 fn the_markets_plus_events_group_cap_can_never_bind() {
899 let rl = RateLimiter::gamma_default();
904 let markets = rl.resolve_specs("/markets", Some(&Method::GET))[0].count;
905 let events = rl.resolve_specs("/events", Some(&Method::GET))[0].count;
906 assert!(
907 markets + events <= 900,
908 "/markets ({markets}) + /events ({events}) now exceeds the published 900/10s \
909 group cap, which is no longer unreachable and must be modelled"
910 );
911 }
912
913 #[tokio::test]
914 async fn the_markets_cap_actually_throttles() {
915 assert_paced_by_its_own_quota(
916 &RateLimiter::gamma_default(),
917 "/markets",
918 300,
919 Duration::from_secs(10),
920 )
921 .await;
922 }
923}
924
925#[cfg(test)]
926mod documented_limits {
927 use super::agreement::{assert_paced_by_its_own_quota, DocumentedRule};
937 use super::*;
938
939 fn documented() -> Vec<DocumentedRule> {
941 vec![
942 ("/balance-allowance", Some(Method::GET), vec![(200, 10)]),
944 (
945 "/balance-allowance/update",
946 Some(Method::GET),
947 vec![(50, 10)],
948 ),
949 (
951 "/order",
952 Some(Method::POST),
953 vec![(5_000, 10), (120_000, 600)],
954 ),
955 (
956 "/order",
957 Some(Method::DELETE),
958 vec![(5_000, 10), (120_000, 600)],
959 ),
960 (
961 "/orders",
962 Some(Method::POST),
963 vec![(2_000, 10), (21_000, 600)],
964 ),
965 (
966 "/orders",
967 Some(Method::DELETE),
968 vec![(2_000, 10), (15_000, 600)],
969 ),
970 (
971 "/cancel-all",
972 Some(Method::DELETE),
973 vec![(250, 10), (6_000, 600)],
974 ),
975 (
976 "/cancel-market-orders",
977 Some(Method::DELETE),
978 vec![(1_500, 10), (21_000, 600)],
979 ),
980 ("/trades", Some(Method::GET), vec![(900, 10)]),
982 ("/orders", Some(Method::GET), vec![(900, 10)]),
983 ("/order", Some(Method::GET), vec![(900, 10)]),
984 (
985 "/notifications",
986 Some(Method::GET),
987 vec![(900, 10), (125, 10)],
988 ),
989 ("/data/orders", Some(Method::GET), vec![(500, 10)]),
990 ("/data/trades", Some(Method::GET), vec![(500, 10)]),
991 ("/book", Some(Method::GET), vec![(1_500, 10)]),
993 ("/books", Some(Method::POST), vec![(500, 10)]),
994 ("/price", Some(Method::GET), vec![(1_500, 10)]),
995 ("/prices", Some(Method::POST), vec![(500, 10)]),
996 ("/midpoint", Some(Method::GET), vec![(1_500, 10)]),
997 ("/midpoints", Some(Method::POST), vec![(500, 10)]),
998 ("/prices-history", Some(Method::GET), vec![(1_000, 10)]),
999 ("/tick-size", Some(Method::GET), vec![(200, 10)]),
1000 ("/auth/api-key", Some(Method::POST), vec![(100, 10)]),
1002 ("/ok", Some(Method::GET), vec![(100, 10)]),
1003 ]
1004 }
1005
1006 #[test]
1007 fn every_documented_endpoint_resolves_to_its_published_quota() {
1008 let rl = RateLimiter::clob_default();
1009
1010 for (path, method, expected) in documented() {
1011 let resolved = rl.resolve_specs(path, method.as_ref());
1012 assert!(
1013 !resolved.is_empty(),
1014 "{method:?} {path} matches no endpoint limit — it falls through to the \
1015 general {}/10s bucket, over-permitting by {}x",
1016 9_000,
1017 9_000 / expected[0].0.max(1),
1018 );
1019 let actual: Vec<(u32, u64)> = resolved
1020 .iter()
1021 .map(|s| (s.count, s.period.as_secs()))
1022 .collect();
1023 assert_eq!(
1024 actual, expected,
1025 "{method:?} {path} resolves to {actual:?}, published limit is {expected:?}"
1026 );
1027 }
1028 }
1029
1030 #[test]
1031 fn batch_endpoints_do_not_inherit_their_singular_sibling() {
1032 let rl = RateLimiter::clob_default();
1035 for (batch, singular) in [
1036 ("/books", "/book"),
1037 ("/prices", "/price"),
1038 ("/midpoints", "/midpoint"),
1039 ] {
1040 let batch_specs = rl.resolve_specs(batch, Some(&Method::POST));
1041 let singular_specs = rl.resolve_specs(singular, Some(&Method::GET));
1042 assert_ne!(
1043 batch_specs, singular_specs,
1044 "{batch} is being limited as if it were {singular}"
1045 );
1046 assert_eq!(batch_specs[0].count, 500, "{batch} should allow 500/10s");
1047 }
1048 }
1049
1050 #[test]
1051 fn the_ledger_group_cap_is_one_shared_bucket() {
1052 let rl = RateLimiter::clob_default();
1056 let group: Vec<_> = ["/trades", "/orders", "/order", "/notifications"]
1057 .iter()
1058 .map(|p| {
1059 rl.inner
1060 .limits
1061 .iter()
1062 .find(|l| l.matches(p, Some(&Method::GET)))
1063 .unwrap_or_else(|| panic!("{p} should match a ledger entry"))
1064 .buckets[0]
1065 .clone()
1066 })
1067 .collect();
1068
1069 for other in &group[1..] {
1070 assert!(
1071 Arc::ptr_eq(&group[0], other),
1072 "ledger endpoints must share one bucket, not hold copies"
1073 );
1074 }
1075 }
1076
1077 #[test]
1078 fn balance_allowance_update_is_not_shadowed_by_its_parent_path() {
1079 let rl = RateLimiter::clob_default();
1083 let update = rl.resolve_specs("/balance-allowance/update", Some(&Method::GET));
1084 assert_eq!(
1085 update[0].count, 50,
1086 "the tighter /balance-allowance/update rule must be ordered first"
1087 );
1088 }
1089
1090 #[tokio::test]
1091 async fn a_documented_cap_actually_throttles() {
1092 assert_paced_by_its_own_quota(
1095 &RateLimiter::clob_default(),
1096 "/tick-size",
1097 200,
1098 Duration::from_secs(10),
1099 )
1100 .await;
1101 }
1102
1103 #[tokio::test]
1104 async fn the_ledger_group_allowance_is_consumed_jointly() {
1105 let rl = RateLimiter::clob_default();
1110 rl.acquire("/trades", Some(&Method::GET)).await;
1111
1112 let start = std::time::Instant::now();
1113 rl.acquire("/orders", Some(&Method::GET)).await;
1114 let waited = start.elapsed();
1115
1116 assert!(
1117 waited >= Duration::from_millis(5),
1118 "GET /orders returned in {waited:?} after /trades consumed from the shared 900/10s \
1119 allowance — the group cap is not actually shared"
1120 );
1121 }
1122
1123 #[test]
1124 fn post_order_is_not_throttled_by_the_ledger_group() {
1125 let rl = RateLimiter::clob_default();
1130 let specs = rl.resolve_specs("/order", Some(&Method::POST));
1131 assert_eq!(specs[0].count, 5_000);
1132 assert!(
1133 !specs.iter().any(|s| s.count == 900),
1134 "POST /order must not be caught by the ledger read cap"
1135 );
1136 }
1137}
1138
1139#[cfg(test)]
1140mod tests {
1141 use super::*;
1142
1143 #[test]
1146 fn test_retry_config_default() {
1147 let cfg = RetryConfig::default();
1148 assert_eq!(cfg.max_retries, 3);
1149 assert_eq!(cfg.initial_backoff_ms, 500);
1150 assert_eq!(cfg.max_backoff_ms, 10_000);
1151 }
1152
1153 #[test]
1154 fn test_backoff_attempt_zero() {
1155 let cfg = RetryConfig::default();
1156 let d = cfg.backoff(0);
1157 let ms = d.as_millis() as u64;
1160 assert!(
1161 (375..=625).contains(&ms),
1162 "attempt 0: {ms}ms not in [375, 625]"
1163 );
1164 }
1165
1166 #[test]
1167 fn test_backoff_exponential_growth() {
1168 let cfg = RetryConfig::default();
1169 let d0 = cfg.backoff(0);
1170 let d1 = cfg.backoff(1);
1171 let d2 = cfg.backoff(2);
1172 assert!(d0 < d1, "d0={d0:?} should be < d1={d1:?}");
1173 assert!(d1 < d2, "d1={d1:?} should be < d2={d2:?}");
1174 }
1175
1176 #[test]
1177 fn test_backoff_jitter_bounds() {
1178 let cfg = RetryConfig::default();
1179 for attempt in 0..20 {
1180 let d = cfg.backoff(attempt);
1181 let base = cfg
1182 .initial_backoff_ms
1183 .saturating_mul(1u64 << attempt.min(10));
1184 let capped = base.min(cfg.max_backoff_ms);
1185 let lower = (capped as f64 * 0.75) as u64;
1186 let upper = (capped as f64 * 1.25) as u64;
1187 let ms = d.as_millis() as u64;
1188 assert!(
1189 ms >= lower.max(1) && ms <= upper,
1190 "attempt {attempt}: {ms}ms not in [{lower}, {upper}]"
1191 );
1192 }
1193 }
1194
1195 #[test]
1196 fn test_backoff_max_capping() {
1197 let cfg = RetryConfig::default();
1198 for attempt in 5..=10 {
1199 let d = cfg.backoff(attempt);
1200 let ceiling = (cfg.max_backoff_ms as f64 * 1.25) as u64;
1201 assert!(
1202 d.as_millis() as u64 <= ceiling,
1203 "attempt {attempt}: {:?} exceeded ceiling {ceiling}ms",
1204 d
1205 );
1206 }
1207 }
1208
1209 #[test]
1210 fn test_backoff_very_high_attempt() {
1211 let cfg = RetryConfig::default();
1212 let d = cfg.backoff(100);
1213 let ceiling = (cfg.max_backoff_ms as f64 * 1.25) as u64;
1214 assert!(d.as_millis() as u64 <= ceiling);
1215 assert!(d.as_millis() >= 1);
1216 }
1217
1218 #[test]
1219 fn test_backoff_jitter_distribution() {
1220 let cfg = RetryConfig::default();
1223 let midpoint = cfg.initial_backoff_ms; let (mut below, mut above) = (0u32, 0u32);
1225 for _ in 0..200 {
1226 let ms = cfg.backoff(0).as_millis() as u64;
1227 if ms < midpoint {
1228 below += 1;
1229 } else {
1230 above += 1;
1231 }
1232 }
1233 assert!(
1234 below >= 20 && above >= 20,
1235 "jitter looks degenerate: {below} below midpoint, {above} above"
1236 );
1237 }
1238
1239 #[test]
1242 fn test_quota_creation() {
1243 let _ = quota(100, Duration::from_secs(10));
1245 let _ = quota(1, Duration::from_secs(60));
1246 let _ = quota(9_000, Duration::from_secs(10));
1247 }
1248
1249 #[test]
1250 fn test_quota_edge_zero_count() {
1251 let _ = quota(0, Duration::from_secs(10));
1254 let _ = quota(1, Duration::from_secs(10));
1255 }
1256
1257 #[test]
1260 fn test_clob_default_construction() {
1261 let rl = RateLimiter::clob_default();
1262 assert_eq!(rl.inner.limits.len(), 27);
1263 assert!(format!("{:?}", rl).contains("endpoints"));
1264 }
1265
1266 #[test]
1267 fn test_gamma_default_construction() {
1268 let rl = RateLimiter::gamma_default();
1269 assert_eq!(rl.inner.limits.len(), 6);
1270 }
1271
1272 #[test]
1273 fn test_data_default_construction() {
1274 let rl = RateLimiter::data_default();
1275 assert_eq!(rl.inner.limits.len(), 5);
1276 }
1277
1278 #[test]
1279 fn test_relay_default_construction() {
1280 let rl = RateLimiter::relay_default();
1281 assert_eq!(rl.inner.limits.len(), 0);
1282 }
1283
1284 #[test]
1285 fn test_rate_limiter_debug_format() {
1286 let rl = RateLimiter::clob_default();
1287 let dbg = format!("{:?}", rl);
1288 assert!(dbg.contains("RateLimiter"), "missing struct name: {dbg}");
1289 assert!(dbg.contains("endpoints: 27"), "missing count: {dbg}");
1290 }
1291
1292 #[test]
1295 fn test_clob_tighter_rules_precede_the_prefixes_that_would_shadow_them() {
1296 let rl = RateLimiter::clob_default();
1300 let index_of = |path: &str| {
1301 rl.inner
1302 .limits
1303 .iter()
1304 .position(|l| l.path_prefix == path)
1305 .unwrap_or_else(|| panic!("{path} should be configured"))
1306 };
1307
1308 for (specific, general) in [
1309 ("/balance-allowance/update", "/balance-allowance"),
1310 ("/data/orders", "/data"),
1311 ("/data/trades", "/data"),
1312 ] {
1313 assert!(
1314 index_of(specific) < index_of(general),
1315 "{specific} must be matched before {general} or it can never win"
1316 );
1317 }
1318 }
1319
1320 #[tokio::test]
1323 async fn test_acquire_single_completes_immediately() {
1324 let rl = RateLimiter::clob_default();
1325 let start = std::time::Instant::now();
1326 rl.acquire("/order", Some(&Method::POST)).await;
1327 assert!(start.elapsed() < Duration::from_millis(50));
1328 }
1329
1330 #[tokio::test]
1331 async fn test_acquire_matches_endpoint_by_prefix() {
1332 let rl = RateLimiter::clob_default();
1333 let start = std::time::Instant::now();
1334 rl.acquire("/order/123", Some(&Method::POST)).await;
1336 assert!(start.elapsed() < Duration::from_millis(50));
1337 }
1338
1339 #[tokio::test]
1340 async fn test_acquire_prefix_respects_segment_boundary() {
1341 let rl = RateLimiter::clob_default();
1342 let limits = &rl.inner.limits;
1343
1344 let price_idx = limits
1346 .iter()
1347 .position(|l| l.path_prefix == "/price")
1348 .expect("/price endpoint exists");
1349
1350 let prices_history_idx = limits
1352 .iter()
1353 .position(|l| l.path_prefix == "/prices-history")
1354 .expect("/prices-history endpoint exists");
1355
1356 assert!(
1358 prices_history_idx < price_idx,
1359 "/prices-history (idx {prices_history_idx}) should come before /price (idx {price_idx})"
1360 );
1361 }
1362
1363 #[test]
1364 fn test_match_mode_prefix_segment_boundary() {
1365 let pattern = "/price";
1367
1368 let check = |path: &str| -> bool {
1369 match path.strip_prefix(pattern) {
1370 Some(rest) => rest.is_empty() || rest.starts_with('/') || rest.starts_with('?'),
1371 None => false,
1372 }
1373 };
1374
1375 assert!(check("/price"), "exact match");
1377 assert!(check("/price/foo"), "sub-path");
1378 assert!(check("/price?token=abc"), "query params");
1379
1380 assert!(!check("/prices-history"), "partial word /prices-history");
1382 assert!(!check("/pricelist"), "partial word /pricelist");
1383 assert!(!check("/pricing"), "partial word /pricing");
1384
1385 assert!(!check("/midpoint"), "different prefix");
1387 }
1388
1389 #[test]
1390 fn test_match_mode_exact() {
1391 let pattern = "/trades";
1393
1394 let check = |path: &str| -> bool { path == pattern };
1395
1396 assert!(check("/trades"), "exact match");
1397 assert!(!check("/trades/123"), "sub-path should not match");
1398 assert!(!check("/trades?limit=10"), "query params should not match");
1399 assert!(!check("/traded"), "different word should not match");
1400 }
1401
1402 #[tokio::test]
1403 async fn test_acquire_method_filtering() {
1404 let rl = RateLimiter::clob_default();
1405 let start = std::time::Instant::now();
1406 rl.acquire("/order", Some(&Method::GET)).await;
1408 assert!(start.elapsed() < Duration::from_millis(50));
1409 }
1410
1411 #[tokio::test]
1412 async fn test_acquire_no_endpoint_match_uses_default_only() {
1413 let rl = RateLimiter::clob_default();
1414 let start = std::time::Instant::now();
1415 rl.acquire("/unknown/path", None).await;
1416 assert!(start.elapsed() < Duration::from_millis(50));
1417 }
1418
1419 #[tokio::test]
1420 async fn test_acquire_method_none_matches_any_method() {
1421 let rl = RateLimiter::gamma_default();
1422 let start = std::time::Instant::now();
1423 rl.acquire("/events", Some(&Method::GET)).await;
1425 rl.acquire("/events", Some(&Method::POST)).await;
1426 rl.acquire("/events", None).await;
1427 assert!(start.elapsed() < Duration::from_millis(50));
1428 }
1429
1430 #[test]
1433 fn test_clob_price_and_prices_history_are_distinct() {
1434 let rl = RateLimiter::clob_default();
1435 let limits = &rl.inner.limits;
1436
1437 let price = limits.iter().find(|l| l.path_prefix == "/price").unwrap();
1438 let prices_history = limits
1439 .iter()
1440 .find(|l| l.path_prefix == "/prices-history")
1441 .unwrap();
1442
1443 assert_eq!(price.match_mode, MatchMode::Prefix);
1445 assert_eq!(prices_history.match_mode, MatchMode::Prefix);
1446
1447 if let Some(rest) = "/prices-history".strip_prefix(price.path_prefix) {
1449 assert!(
1450 !rest.is_empty() && !rest.starts_with('/') && !rest.starts_with('?'),
1451 "/prices-history must not match /price pattern, rest = '{rest}'"
1452 );
1453 }
1454 }
1455
1456 #[test]
1457 fn test_data_positions_and_closed_positions_are_distinct() {
1458 let rl = RateLimiter::data_default();
1463
1464 let closed = rl.resolve_specs("/closed-positions", Some(&Method::GET));
1465 let positions = rl.resolve_specs("/positions", Some(&Method::GET));
1466 assert_eq!(closed, positions, "both are published at 150/10s");
1467
1468 let bucket_for = |path: &str| {
1469 rl.inner
1470 .limits
1471 .iter()
1472 .find(|l| l.matches(path, Some(&Method::GET)))
1473 .unwrap_or_else(|| panic!("{path} should match a rule"))
1474 .buckets[0]
1475 .clone()
1476 };
1477 assert!(
1478 !Arc::ptr_eq(&bucket_for("/closed-positions"), &bucket_for("/positions")),
1479 "equal quotas must still be separate buckets — upstream publishes \
1480 150/10s each, not 150/10s combined"
1481 );
1482 }
1483
1484 #[test]
1485 fn test_all_clob_endpoints_have_match_mode() {
1486 let rl = RateLimiter::clob_default();
1487 for limit in &rl.inner.limits {
1488 assert!(
1490 limit.match_mode == MatchMode::Prefix || limit.match_mode == MatchMode::Exact,
1491 "endpoint {} has no valid match mode",
1492 limit.path_prefix
1493 );
1494 }
1495 }
1496
1497 #[tokio::test]
1500 async fn concurrent_acquires_are_paced_against_one_shared_allowance() {
1501 const TASKS: u32 = 10;
1510 let interval = Duration::from_secs(10) / (1_500 - 1);
1511
1512 let rl = std::sync::Arc::new(RateLimiter::clob_default());
1513
1514 let start = std::time::Instant::now();
1515 let mut handles = Vec::new();
1516 for _ in 0..TASKS {
1517 let rl = rl.clone();
1518 handles.push(tokio::spawn(async move {
1519 rl.acquire("/markets", None).await;
1520 }));
1521 }
1522 for handle in handles {
1523 handle.await.unwrap();
1524 }
1525 let elapsed = start.elapsed();
1526
1527 assert!(
1528 elapsed >= interval * (TASKS - 1) / 2,
1529 "{TASKS} concurrent acquires completed in {elapsed:?}; pacing at {interval:?} each \
1530 they cannot, so concurrent tasks are not sharing one allowance"
1531 );
1532 assert!(
1533 elapsed < Duration::from_secs(1),
1534 "{TASKS} concurrent acquires took {elapsed:?} — they are stalling, not pacing"
1535 );
1536 }
1537
1538 #[tokio::test]
1539 async fn test_acquire_concurrent_different_endpoints() {
1540 let rl = std::sync::Arc::new(RateLimiter::clob_default());
1542
1543 let rl1 = rl.clone();
1544 let rl2 = rl.clone();
1545 let rl3 = rl.clone();
1546
1547 let start = std::time::Instant::now();
1548 let (r1, r2, r3) = tokio::join!(
1549 tokio::spawn(async move { rl1.acquire("/markets", None).await }),
1550 tokio::spawn(async move { rl2.acquire("/auth", None).await }),
1551 tokio::spawn(async move { rl3.acquire("/order", Some(&Method::POST)).await }),
1552 );
1553 r1.unwrap();
1554 r2.unwrap();
1555 r3.unwrap();
1556
1557 assert!(
1558 start.elapsed() < Duration::from_millis(50),
1559 "different endpoints should not block: {:?}",
1560 start.elapsed()
1561 );
1562 }
1563
1564 #[test]
1567 fn test_clob_post_order_has_dual_window() {
1568 let rl = RateLimiter::clob_default();
1569 let post_order = rl
1570 .inner
1571 .limits
1572 .iter()
1573 .find(|l| l.path_prefix == "/order" && l.method == Some(Method::POST))
1574 .expect("POST /order endpoint should exist");
1575
1576 assert_eq!(
1577 post_order.buckets.len(),
1578 2,
1579 "POST /order should have a burst and a sustained window"
1580 );
1581 }
1582
1583 #[test]
1584 fn test_clob_delete_order_has_a_sustained_window_too() {
1585 let rl = RateLimiter::clob_default();
1589 let delete_order = rl
1590 .inner
1591 .limits
1592 .iter()
1593 .find(|l| l.path_prefix == "/order" && l.method == Some(Method::DELETE))
1594 .expect("DELETE /order endpoint should exist");
1595
1596 assert_eq!(
1597 delete_order.buckets.len(),
1598 2,
1599 "DELETE /order should have both a burst and a sustained window"
1600 );
1601 }
1602
1603 #[tokio::test]
1604 async fn test_dual_window_both_burst_and_sustained_are_awaited() {
1605 let rl = RateLimiter::clob_default();
1608 let start = std::time::Instant::now();
1609 rl.acquire("/order", Some(&Method::POST)).await;
1610 assert!(
1611 start.elapsed() < Duration::from_millis(50),
1612 "dual window single acquire should be fast: {:?}",
1613 start.elapsed()
1614 );
1615 }
1616
1617 #[test]
1620 fn test_should_retry_exhaustion() {
1621 let client = crate::HttpClientBuilder::new("https://example.com")
1623 .with_retry_config(RetryConfig {
1624 max_retries: 3,
1625 ..RetryConfig::default()
1626 })
1627 .build()
1628 .unwrap();
1629
1630 for attempt in 0..3 {
1632 assert!(
1633 client
1634 .should_retry(reqwest::StatusCode::TOO_MANY_REQUESTS, attempt, None)
1635 .is_some(),
1636 "attempt {attempt} should allow retry"
1637 );
1638 }
1639 assert!(
1641 client
1642 .should_retry(reqwest::StatusCode::TOO_MANY_REQUESTS, 3, None)
1643 .is_none(),
1644 "attempt 3 should exhaust retries"
1645 );
1646 }
1647
1648 #[test]
1649 fn test_should_retry_zero_max_retries_never_retries() {
1650 let client = crate::HttpClientBuilder::new("https://example.com")
1651 .with_retry_config(RetryConfig {
1652 max_retries: 0,
1653 ..RetryConfig::default()
1654 })
1655 .build()
1656 .unwrap();
1657
1658 assert!(
1659 client
1660 .should_retry(reqwest::StatusCode::TOO_MANY_REQUESTS, 0, None)
1661 .is_none(),
1662 "max_retries=0 should never retry"
1663 );
1664 }
1665}
1666
1667#[cfg(test)]
1668mod cooldown_tests {
1669 use super::*;
1679
1680 #[tokio::test(start_paused = true)]
1681 async fn acquire_is_immediate_without_a_cooldown() {
1682 let rl = RateLimiter::data_default();
1683 let t = tokio::time::Instant::now();
1684 rl.acquire("/closed-positions", None).await;
1685 assert!(
1686 t.elapsed() < Duration::from_millis(1),
1687 "an untripped limiter must not delay: waited {:?}",
1688 t.elapsed()
1689 );
1690 }
1691
1692 #[tokio::test(start_paused = true)]
1693 async fn a_cooldown_holds_back_a_path_that_never_saw_the_429() {
1694 let rl = RateLimiter::data_default();
1695 rl.begin_cooldown(Duration::from_secs(5));
1696
1697 let t = tokio::time::Instant::now();
1700 rl.acquire("/trades", None).await;
1701 assert!(
1702 t.elapsed() >= Duration::from_secs(5),
1703 "a sibling path resumed after {:?}, before the cooldown expired",
1704 t.elapsed()
1705 );
1706 }
1707
1708 #[tokio::test(start_paused = true)]
1709 async fn concurrent_requests_all_observe_one_cooldown() {
1710 let rl = RateLimiter::data_default();
1711 rl.begin_cooldown(Duration::from_secs(3));
1712
1713 let t = tokio::time::Instant::now();
1716 tokio::join!(
1717 rl.acquire("/closed-positions", None),
1718 rl.acquire("/closed-positions", None),
1719 rl.acquire("/closed-positions", None),
1720 rl.acquire("/closed-positions", None),
1721 );
1722 assert!(
1723 t.elapsed() >= Duration::from_secs(3),
1724 "concurrent callers resumed after {:?}",
1725 t.elapsed()
1726 );
1727 }
1728
1729 #[tokio::test(start_paused = true)]
1730 async fn a_shorter_cooldown_never_cuts_a_longer_one_short() {
1731 let rl = RateLimiter::data_default();
1732 rl.begin_cooldown(Duration::from_secs(10));
1733 rl.begin_cooldown(Duration::from_secs(1));
1737
1738 let t = tokio::time::Instant::now();
1739 rl.acquire("/positions", None).await;
1740 assert!(
1741 t.elapsed() >= Duration::from_secs(10),
1742 "the longer cooldown was truncated to {:?}",
1743 t.elapsed()
1744 );
1745 }
1746
1747 #[tokio::test(start_paused = true)]
1748 async fn a_cooldown_extended_mid_wait_is_honoured_in_full() {
1749 let rl = RateLimiter::data_default();
1750 rl.begin_cooldown(Duration::from_secs(2));
1751
1752 let extender = {
1753 let rl = rl.clone();
1754 tokio::spawn(async move {
1755 tokio::time::sleep(Duration::from_secs(1)).await;
1756 rl.begin_cooldown(Duration::from_secs(5));
1757 })
1758 };
1759
1760 let t = tokio::time::Instant::now();
1761 rl.acquire("/closed-positions", None).await;
1762 extender.await.unwrap();
1763 assert!(
1766 t.elapsed() >= Duration::from_secs(6),
1767 "resumed at {:?}, ignoring the cooldown extension",
1768 t.elapsed()
1769 );
1770 }
1771
1772 #[tokio::test(start_paused = true)]
1773 async fn an_expired_cooldown_stops_delaying() {
1774 let rl = RateLimiter::data_default();
1775 rl.begin_cooldown(Duration::from_secs(2));
1776 rl.acquire("/closed-positions", None).await;
1777
1778 let t = tokio::time::Instant::now();
1779 rl.acquire("/closed-positions", None).await;
1780 assert!(
1781 t.elapsed() < Duration::from_millis(1),
1782 "the limiter stayed blocked for {:?} after the cooldown expired",
1783 t.elapsed()
1784 );
1785 }
1786}