1use std::num::NonZeroU32;
2use std::sync::{Arc, Mutex};
3use std::time::Duration;
4
5use governor::Quota;
6use reqwest::Method;
7use tokio::time::Instant;
8
9type DirectLimiter = governor::RateLimiter<
10 governor::state::NotKeyed,
11 governor::state::InMemoryState,
12 governor::clock::DefaultClock,
13>;
14
15#[derive(Debug, Clone, Copy, PartialEq, Eq)]
17#[allow(dead_code)]
18enum MatchMode {
19 Prefix,
23 Exact,
25}
26
27#[derive(Debug, Clone, Copy, PartialEq, Eq)]
32struct RateSpec {
33 count: u32,
34 period: Duration,
35}
36
37struct Bucket {
43 #[cfg_attr(not(test), allow(dead_code))]
46 spec: RateSpec,
47 limiter: DirectLimiter,
48}
49
50impl Bucket {
51 fn new(count: u32, period: Duration) -> Arc<Self> {
52 Arc::new(Self {
53 spec: RateSpec { count, period },
54 limiter: DirectLimiter::direct(quota(count, period)),
55 })
56 }
57}
58
59struct EndpointLimit {
61 path_prefix: &'static str,
62 method: Option<Method>,
63 match_mode: MatchMode,
64 buckets: Vec<Arc<Bucket>>,
66}
67
68impl EndpointLimit {
69 fn matches(&self, path: &str, method: Option<&Method>) -> bool {
74 let path_matches = match self.match_mode {
75 MatchMode::Exact => path == self.path_prefix,
76 MatchMode::Prefix => {
77 match path.strip_prefix(self.path_prefix) {
80 Some(rest) => rest.is_empty() || rest.starts_with('/') || rest.starts_with('?'),
81 None => false,
82 }
83 }
84 };
85 if !path_matches {
86 return false;
87 }
88 match &self.method {
89 Some(expected) => method == Some(expected),
90 None => true,
91 }
92 }
93}
94
95#[derive(Clone)]
100pub struct RateLimiter {
101 inner: Arc<RateLimiterInner>,
102}
103
104impl std::fmt::Debug for RateLimiter {
105 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
106 f.debug_struct("RateLimiter")
107 .field("endpoints", &self.inner.limits.len())
108 .finish()
109 }
110}
111
112struct RateLimiterInner {
113 limits: Vec<EndpointLimit>,
114 default: DirectLimiter,
115 cooldown_until: Mutex<Option<Instant>>,
123}
124
125fn quota(count: u32, period: Duration) -> Quota {
130 let count = count.max(1);
131 let interval = period / count;
132 Quota::with_period(interval)
133 .expect("quota interval must be non-zero")
134 .allow_burst(NonZeroU32::new(count).unwrap())
135}
136
137fn endpoint_limit(
139 path_prefix: &'static str,
140 method: Option<Method>,
141 buckets: Vec<Arc<Bucket>>,
142) -> EndpointLimit {
143 EndpointLimit {
144 path_prefix,
145 method,
146 match_mode: MatchMode::Prefix,
147 buckets,
148 }
149}
150
151fn simple_limit(
153 path_prefix: &'static str,
154 method: Option<Method>,
155 count: u32,
156 period: Duration,
157) -> EndpointLimit {
158 endpoint_limit(path_prefix, method, vec![Bucket::new(count, period)])
159}
160
161fn dual_limit(
163 path_prefix: &'static str,
164 method: Method,
165 burst: (u32, Duration),
166 sustained: (u32, Duration),
167) -> EndpointLimit {
168 endpoint_limit(
169 path_prefix,
170 Some(method),
171 vec![
172 Bucket::new(burst.0, burst.1),
173 Bucket::new(sustained.0, sustained.1),
174 ],
175 )
176}
177
178impl RateLimiter {
179 pub fn begin_cooldown(&self, delay: Duration) {
190 let until = Instant::now() + delay;
191 let mut slot = self.lock_cooldown();
192 if slot.is_none_or(|current| until > current) {
193 *slot = Some(until);
194 }
195 }
196
197 fn lock_cooldown(&self) -> std::sync::MutexGuard<'_, Option<Instant>> {
203 self.inner
204 .cooldown_until
205 .lock()
206 .unwrap_or_else(|poisoned| poisoned.into_inner())
207 }
208
209 async fn await_cooldown(&self) {
211 loop {
212 let deadline = *self.lock_cooldown();
216 let Some(deadline) = deadline else { return };
217 if deadline <= Instant::now() {
218 return;
219 }
220 tokio::time::sleep_until(deadline).await;
224 }
225 }
226
227 pub async fn acquire(&self, path: &str, method: Option<&Method>) {
233 self.await_cooldown().await;
234 self.inner.default.until_ready().await;
235
236 if let Some(limit) = self.inner.limits.iter().find(|l| l.matches(path, method)) {
237 for bucket in &limit.buckets {
238 bucket.limiter.until_ready().await;
239 }
240 }
241 }
242
243 #[cfg(test)]
249 fn resolve_specs(&self, path: &str, method: Option<&Method>) -> Vec<RateSpec> {
250 self.inner
251 .limits
252 .iter()
253 .find(|l| l.matches(path, method))
254 .map(|l| l.buckets.iter().map(|b| b.spec).collect())
255 .unwrap_or_default()
256 }
257
258 pub fn clob_default() -> Self {
278 let ten_sec = Duration::from_secs(10);
279 let ten_min = Duration::from_secs(600);
280 let get = Some(Method::GET);
281
282 let ledger_group = Bucket::new(900, ten_sec);
284
285 Self {
286 inner: Arc::new(RateLimiterInner {
287 default: DirectLimiter::direct(quota(9_000, ten_sec)),
288 cooldown_until: Mutex::new(None),
289 limits: vec![
290 simple_limit("/balance-allowance/update", None, 50, ten_sec),
293 simple_limit("/balance-allowance", None, 200, ten_sec),
294 dual_limit("/order", Method::POST, (5_000, ten_sec), (120_000, ten_min)),
296 dual_limit(
297 "/order",
298 Method::DELETE,
299 (5_000, ten_sec),
300 (120_000, ten_min),
301 ),
302 dual_limit("/orders", Method::POST, (2_000, ten_sec), (21_000, ten_min)),
303 dual_limit(
304 "/orders",
305 Method::DELETE,
306 (2_000, ten_sec),
307 (15_000, ten_min),
308 ),
309 dual_limit(
310 "/cancel-all",
311 Method::DELETE,
312 (250, ten_sec),
313 (6_000, ten_min),
314 ),
315 dual_limit(
316 "/cancel-market-orders",
317 Method::DELETE,
318 (1_500, ten_sec),
319 (21_000, ten_min),
320 ),
321 endpoint_limit(
324 "/notifications",
325 None,
326 vec![ledger_group.clone(), Bucket::new(125, ten_sec)],
327 ),
328 endpoint_limit("/trades", get.clone(), vec![ledger_group.clone()]),
329 endpoint_limit("/orders", get.clone(), vec![ledger_group.clone()]),
330 endpoint_limit("/order", get.clone(), vec![ledger_group]),
331 simple_limit("/data/orders", None, 500, ten_sec),
335 simple_limit("/data/trades", None, 500, ten_sec),
336 simple_limit("/data", None, 500, ten_sec),
337 simple_limit("/auth", None, 100, ten_sec),
339 simple_limit("/prices-history", None, 1_000, ten_sec),
343 simple_limit("/book", None, 1_500, ten_sec),
344 simple_limit("/books", None, 500, ten_sec),
345 simple_limit("/price", None, 1_500, ten_sec),
346 simple_limit("/prices", None, 500, ten_sec),
347 simple_limit("/midpoint", None, 1_500, ten_sec),
348 simple_limit("/midpoints", None, 500, ten_sec),
349 simple_limit("/tick-size", None, 200, ten_sec),
350 simple_limit("/ok", None, 100, ten_sec),
352 simple_limit("/markets", None, 1_500, ten_sec),
357 simple_limit("/neg-risk", None, 1_500, ten_sec),
358 ],
359 }),
360 }
361 }
362
363 pub fn gamma_default() -> Self {
383 let ten_sec = Duration::from_secs(10);
384
385 Self {
386 inner: Arc::new(RateLimiterInner {
387 default: DirectLimiter::direct(quota(4_000, ten_sec)),
388 cooldown_until: Mutex::new(None),
389 limits: vec![
390 simple_limit("/comments", None, 200, ten_sec),
391 simple_limit("/tags", None, 200, ten_sec),
392 simple_limit("/markets", None, 300, ten_sec),
393 simple_limit("/public-search", None, 350, ten_sec),
394 simple_limit("/events", None, 500, ten_sec),
395 simple_limit("/status", None, 100, ten_sec),
396 ],
397 }),
398 }
399 }
400
401 pub fn data_default() -> Self {
427 let ten_sec = Duration::from_secs(10);
428
429 Self {
430 inner: Arc::new(RateLimiterInner {
431 default: DirectLimiter::direct(quota(1_000, ten_sec)),
432 cooldown_until: Mutex::new(None),
433 limits: vec![
434 simple_limit("/closed-positions", None, 150, ten_sec),
435 simple_limit("/positions", None, 150, ten_sec),
436 simple_limit("/trades", None, 200, ten_sec),
437 simple_limit("/user-pnl", None, 200, ten_sec),
438 simple_limit("/", None, 100, ten_sec),
439 ],
440 }),
441 }
442 }
443
444 pub fn relay_default() -> Self {
448 Self {
449 inner: Arc::new(RateLimiterInner {
450 default: DirectLimiter::direct(quota(25, Duration::from_secs(60))),
451 cooldown_until: Mutex::new(None),
452 limits: vec![],
453 }),
454 }
455 }
456}
457
458#[derive(Debug, Clone)]
460pub struct RetryConfig {
461 pub max_retries: u32,
463 pub initial_backoff_ms: u64,
465 pub max_backoff_ms: u64,
467}
468
469impl Default for RetryConfig {
470 fn default() -> Self {
471 Self {
472 max_retries: 3,
473 initial_backoff_ms: 500,
474 max_backoff_ms: 10_000,
475 }
476 }
477}
478
479impl RetryConfig {
480 pub fn backoff(&self, attempt: u32) -> Duration {
485 let base = self
486 .initial_backoff_ms
487 .saturating_mul(1u64 << attempt.min(10));
488 let capped = base.min(self.max_backoff_ms);
489 let jitter_factor = 0.75 + (fastrand::f64() * 0.5);
491 let ms = (capped as f64 * jitter_factor) as u64;
492 Duration::from_millis(ms.max(1))
493 }
494}
495
496#[cfg(test)]
497mod agreement {
498 use super::*;
508
509 pub type DocumentedRule = (&'static str, Option<Method>, Vec<(u32, u64)>);
512
513 pub fn assert_matches_published(rl: &RateLimiter, rules: Vec<DocumentedRule>, general: u32) {
519 for (path, method, expected) in rules {
520 let resolved = rl.resolve_specs(path, method.as_ref());
521 assert!(
522 !resolved.is_empty(),
523 "{method:?} {path} matches no endpoint limit — it falls through to the \
524 general {general}/10s bucket, over-permitting by {}x",
525 general / expected[0].0.max(1),
526 );
527 let actual: Vec<(u32, u64)> = resolved
528 .iter()
529 .map(|s| (s.count, s.period.as_secs()))
530 .collect();
531 assert_eq!(
532 actual, expected,
533 "{method:?} {path} resolves to {actual:?}, published limit is {expected:?}"
534 );
535 }
536 }
537
538 pub fn assert_unconfigured(rl: &RateLimiter, path: &str) {
543 assert!(
544 rl.resolve_specs(path, Some(&Method::GET)).is_empty(),
545 "{path} has an endpoint limit configured, but the host answers 404 there — \
546 the entry is dead configuration and the real route is going unlimited"
547 );
548 }
549
550 pub async fn assert_throttles_after(rl: &RateLimiter, path: &str, count: u32) {
555 for _ in 0..count {
556 rl.acquire(path, Some(&Method::GET)).await;
557 }
558
559 let start = std::time::Instant::now();
560 rl.acquire(path, Some(&Method::GET)).await;
561 let waited = start.elapsed();
562
563 assert!(
564 waited >= Duration::from_millis(25),
565 "request {} to {path} returned in {waited:?}; the cap is not being enforced",
566 count + 1,
567 );
568 }
569}
570
571#[cfg(test)]
572mod documented_data_limits {
573 use super::agreement::*;
591 use super::*;
592
593 fn documented() -> Vec<DocumentedRule> {
595 vec![
596 ("/trades", Some(Method::GET), vec![(200, 10)]),
597 ("/positions", Some(Method::GET), vec![(150, 10)]),
598 ("/closed-positions", Some(Method::GET), vec![(150, 10)]),
599 ("/", Some(Method::GET), vec![(100, 10)]),
600 ("/user-pnl", Some(Method::GET), vec![(200, 10)]),
601 ]
602 }
603
604 #[test]
605 fn every_documented_endpoint_resolves_to_its_published_quota() {
606 assert_matches_published(&RateLimiter::data_default(), documented(), 1_000);
607 }
608
609 #[test]
610 fn the_health_cap_is_attached_to_the_route_the_host_answers_on() {
611 assert_unconfigured(&RateLimiter::data_default(), "/ok");
614 }
615
616 #[test]
617 fn the_root_health_rule_does_not_swallow_every_other_route() {
618 let rl = RateLimiter::data_default();
622 for (path, expected) in [
623 ("/positions", 150),
624 ("/closed-positions", 150),
625 ("/trades", 200),
626 ("/", 100),
627 ] {
628 let specs = rl.resolve_specs(path, Some(&Method::GET));
629 assert_eq!(
630 specs[0].count, expected,
631 "{path} resolved through the wrong rule — the `/` entry is over-matching"
632 );
633 }
634 }
635
636 #[tokio::test]
637 async fn the_closed_positions_cap_actually_throttles() {
638 assert_throttles_after(&RateLimiter::data_default(), "/closed-positions", 150).await;
641 }
642
643 #[tokio::test]
644 async fn closed_positions_and_positions_do_not_share_an_allowance() {
645 let rl = RateLimiter::data_default();
649 for _ in 0..150 {
650 rl.acquire("/closed-positions", Some(&Method::GET)).await;
651 }
652
653 let start = std::time::Instant::now();
654 rl.acquire("/positions", Some(&Method::GET)).await;
655 assert!(
656 start.elapsed() < Duration::from_millis(25),
657 "/positions was throttled by /closed-positions draining its own bucket"
658 );
659 }
660}
661
662#[cfg(test)]
663mod documented_gamma_limits {
664 use super::agreement::*;
672 use super::*;
673
674 fn documented() -> Vec<DocumentedRule> {
676 vec![
677 ("/events", Some(Method::GET), vec![(500, 10)]),
678 ("/public-search", Some(Method::GET), vec![(350, 10)]),
679 ("/markets", Some(Method::GET), vec![(300, 10)]),
680 ("/comments", Some(Method::GET), vec![(200, 10)]),
681 ("/tags", Some(Method::GET), vec![(200, 10)]),
682 ("/status", Some(Method::GET), vec![(100, 10)]),
683 ]
684 }
685
686 #[test]
687 fn every_documented_endpoint_resolves_to_its_published_quota() {
688 assert_matches_published(&RateLimiter::gamma_default(), documented(), 4_000);
689 }
690
691 #[test]
692 fn the_health_cap_is_attached_to_the_route_the_host_answers_on() {
693 assert_unconfigured(&RateLimiter::gamma_default(), "/ok");
694 }
695
696 #[test]
697 fn the_markets_plus_events_group_cap_can_never_bind() {
698 let rl = RateLimiter::gamma_default();
703 let markets = rl.resolve_specs("/markets", Some(&Method::GET))[0].count;
704 let events = rl.resolve_specs("/events", Some(&Method::GET))[0].count;
705 assert!(
706 markets + events <= 900,
707 "/markets ({markets}) + /events ({events}) now exceeds the published 900/10s \
708 group cap, which is no longer unreachable and must be modelled"
709 );
710 }
711
712 #[tokio::test]
713 async fn the_markets_cap_actually_throttles() {
714 assert_throttles_after(&RateLimiter::gamma_default(), "/markets", 300).await;
715 }
716}
717
718#[cfg(test)]
719mod documented_limits {
720 use super::agreement::DocumentedRule;
730 use super::*;
731
732 fn documented() -> Vec<DocumentedRule> {
734 vec![
735 ("/balance-allowance", Some(Method::GET), vec![(200, 10)]),
737 (
738 "/balance-allowance/update",
739 Some(Method::GET),
740 vec![(50, 10)],
741 ),
742 (
744 "/order",
745 Some(Method::POST),
746 vec![(5_000, 10), (120_000, 600)],
747 ),
748 (
749 "/order",
750 Some(Method::DELETE),
751 vec![(5_000, 10), (120_000, 600)],
752 ),
753 (
754 "/orders",
755 Some(Method::POST),
756 vec![(2_000, 10), (21_000, 600)],
757 ),
758 (
759 "/orders",
760 Some(Method::DELETE),
761 vec![(2_000, 10), (15_000, 600)],
762 ),
763 (
764 "/cancel-all",
765 Some(Method::DELETE),
766 vec![(250, 10), (6_000, 600)],
767 ),
768 (
769 "/cancel-market-orders",
770 Some(Method::DELETE),
771 vec![(1_500, 10), (21_000, 600)],
772 ),
773 ("/trades", Some(Method::GET), vec![(900, 10)]),
775 ("/orders", Some(Method::GET), vec![(900, 10)]),
776 ("/order", Some(Method::GET), vec![(900, 10)]),
777 (
778 "/notifications",
779 Some(Method::GET),
780 vec![(900, 10), (125, 10)],
781 ),
782 ("/data/orders", Some(Method::GET), vec![(500, 10)]),
783 ("/data/trades", Some(Method::GET), vec![(500, 10)]),
784 ("/book", Some(Method::GET), vec![(1_500, 10)]),
786 ("/books", Some(Method::POST), vec![(500, 10)]),
787 ("/price", Some(Method::GET), vec![(1_500, 10)]),
788 ("/prices", Some(Method::POST), vec![(500, 10)]),
789 ("/midpoint", Some(Method::GET), vec![(1_500, 10)]),
790 ("/midpoints", Some(Method::POST), vec![(500, 10)]),
791 ("/prices-history", Some(Method::GET), vec![(1_000, 10)]),
792 ("/tick-size", Some(Method::GET), vec![(200, 10)]),
793 ("/auth/api-key", Some(Method::POST), vec![(100, 10)]),
795 ("/ok", Some(Method::GET), vec![(100, 10)]),
796 ]
797 }
798
799 #[test]
800 fn every_documented_endpoint_resolves_to_its_published_quota() {
801 let rl = RateLimiter::clob_default();
802
803 for (path, method, expected) in documented() {
804 let resolved = rl.resolve_specs(path, method.as_ref());
805 assert!(
806 !resolved.is_empty(),
807 "{method:?} {path} matches no endpoint limit — it falls through to the \
808 general {}/10s bucket, over-permitting by {}x",
809 9_000,
810 9_000 / expected[0].0.max(1),
811 );
812 let actual: Vec<(u32, u64)> = resolved
813 .iter()
814 .map(|s| (s.count, s.period.as_secs()))
815 .collect();
816 assert_eq!(
817 actual, expected,
818 "{method:?} {path} resolves to {actual:?}, published limit is {expected:?}"
819 );
820 }
821 }
822
823 #[test]
824 fn batch_endpoints_do_not_inherit_their_singular_sibling() {
825 let rl = RateLimiter::clob_default();
828 for (batch, singular) in [
829 ("/books", "/book"),
830 ("/prices", "/price"),
831 ("/midpoints", "/midpoint"),
832 ] {
833 let batch_specs = rl.resolve_specs(batch, Some(&Method::POST));
834 let singular_specs = rl.resolve_specs(singular, Some(&Method::GET));
835 assert_ne!(
836 batch_specs, singular_specs,
837 "{batch} is being limited as if it were {singular}"
838 );
839 assert_eq!(batch_specs[0].count, 500, "{batch} should allow 500/10s");
840 }
841 }
842
843 #[test]
844 fn the_ledger_group_cap_is_one_shared_bucket() {
845 let rl = RateLimiter::clob_default();
849 let group: Vec<_> = ["/trades", "/orders", "/order", "/notifications"]
850 .iter()
851 .map(|p| {
852 rl.inner
853 .limits
854 .iter()
855 .find(|l| l.matches(p, Some(&Method::GET)))
856 .unwrap_or_else(|| panic!("{p} should match a ledger entry"))
857 .buckets[0]
858 .clone()
859 })
860 .collect();
861
862 for other in &group[1..] {
863 assert!(
864 Arc::ptr_eq(&group[0], other),
865 "ledger endpoints must share one bucket, not hold copies"
866 );
867 }
868 }
869
870 #[test]
871 fn balance_allowance_update_is_not_shadowed_by_its_parent_path() {
872 let rl = RateLimiter::clob_default();
876 let update = rl.resolve_specs("/balance-allowance/update", Some(&Method::GET));
877 assert_eq!(
878 update[0].count, 50,
879 "the tighter /balance-allowance/update rule must be ordered first"
880 );
881 }
882
883 #[tokio::test]
884 async fn a_documented_cap_actually_throttles() {
885 let rl = RateLimiter::clob_default();
889 for _ in 0..200 {
890 rl.acquire("/tick-size", Some(&Method::GET)).await;
891 }
892
893 let start = std::time::Instant::now();
894 rl.acquire("/tick-size", Some(&Method::GET)).await;
895 let waited = start.elapsed();
896
897 assert!(
898 waited >= Duration::from_millis(25),
899 "201st /tick-size request returned in {waited:?}; the cap is not being enforced"
900 );
901 }
902
903 #[tokio::test]
904 async fn the_ledger_group_allowance_is_consumed_jointly() {
905 let rl = RateLimiter::clob_default();
909 for _ in 0..900 {
910 rl.acquire("/trades", Some(&Method::GET)).await;
911 }
912
913 let start = std::time::Instant::now();
914 rl.acquire("/orders", Some(&Method::GET)).await;
915 let waited = start.elapsed();
916
917 assert!(
918 waited >= Duration::from_millis(5),
919 "GET /orders returned in {waited:?} after /trades drained the shared 900/10s \
920 allowance — the group cap is not actually shared"
921 );
922 }
923
924 #[test]
925 fn post_order_is_not_throttled_by_the_ledger_group() {
926 let rl = RateLimiter::clob_default();
931 let specs = rl.resolve_specs("/order", Some(&Method::POST));
932 assert_eq!(specs[0].count, 5_000);
933 assert!(
934 !specs.iter().any(|s| s.count == 900),
935 "POST /order must not be caught by the ledger read cap"
936 );
937 }
938}
939
940#[cfg(test)]
941mod tests {
942 use super::*;
943
944 #[test]
947 fn test_retry_config_default() {
948 let cfg = RetryConfig::default();
949 assert_eq!(cfg.max_retries, 3);
950 assert_eq!(cfg.initial_backoff_ms, 500);
951 assert_eq!(cfg.max_backoff_ms, 10_000);
952 }
953
954 #[test]
955 fn test_backoff_attempt_zero() {
956 let cfg = RetryConfig::default();
957 let d = cfg.backoff(0);
958 let ms = d.as_millis() as u64;
961 assert!(
962 (375..=625).contains(&ms),
963 "attempt 0: {ms}ms not in [375, 625]"
964 );
965 }
966
967 #[test]
968 fn test_backoff_exponential_growth() {
969 let cfg = RetryConfig::default();
970 let d0 = cfg.backoff(0);
971 let d1 = cfg.backoff(1);
972 let d2 = cfg.backoff(2);
973 assert!(d0 < d1, "d0={d0:?} should be < d1={d1:?}");
974 assert!(d1 < d2, "d1={d1:?} should be < d2={d2:?}");
975 }
976
977 #[test]
978 fn test_backoff_jitter_bounds() {
979 let cfg = RetryConfig::default();
980 for attempt in 0..20 {
981 let d = cfg.backoff(attempt);
982 let base = cfg
983 .initial_backoff_ms
984 .saturating_mul(1u64 << attempt.min(10));
985 let capped = base.min(cfg.max_backoff_ms);
986 let lower = (capped as f64 * 0.75) as u64;
987 let upper = (capped as f64 * 1.25) as u64;
988 let ms = d.as_millis() as u64;
989 assert!(
990 ms >= lower.max(1) && ms <= upper,
991 "attempt {attempt}: {ms}ms not in [{lower}, {upper}]"
992 );
993 }
994 }
995
996 #[test]
997 fn test_backoff_max_capping() {
998 let cfg = RetryConfig::default();
999 for attempt in 5..=10 {
1000 let d = cfg.backoff(attempt);
1001 let ceiling = (cfg.max_backoff_ms as f64 * 1.25) as u64;
1002 assert!(
1003 d.as_millis() as u64 <= ceiling,
1004 "attempt {attempt}: {:?} exceeded ceiling {ceiling}ms",
1005 d
1006 );
1007 }
1008 }
1009
1010 #[test]
1011 fn test_backoff_very_high_attempt() {
1012 let cfg = RetryConfig::default();
1013 let d = cfg.backoff(100);
1014 let ceiling = (cfg.max_backoff_ms as f64 * 1.25) as u64;
1015 assert!(d.as_millis() as u64 <= ceiling);
1016 assert!(d.as_millis() >= 1);
1017 }
1018
1019 #[test]
1020 fn test_backoff_jitter_distribution() {
1021 let cfg = RetryConfig::default();
1024 let midpoint = cfg.initial_backoff_ms; let (mut below, mut above) = (0u32, 0u32);
1026 for _ in 0..200 {
1027 let ms = cfg.backoff(0).as_millis() as u64;
1028 if ms < midpoint {
1029 below += 1;
1030 } else {
1031 above += 1;
1032 }
1033 }
1034 assert!(
1035 below >= 20 && above >= 20,
1036 "jitter looks degenerate: {below} below midpoint, {above} above"
1037 );
1038 }
1039
1040 #[test]
1043 fn test_quota_creation() {
1044 let _ = quota(100, Duration::from_secs(10));
1046 let _ = quota(1, Duration::from_secs(60));
1047 let _ = quota(9_000, Duration::from_secs(10));
1048 }
1049
1050 #[test]
1051 fn test_quota_edge_zero_count() {
1052 let _ = quota(0, Duration::from_secs(10));
1054 }
1055
1056 #[test]
1059 fn test_clob_default_construction() {
1060 let rl = RateLimiter::clob_default();
1061 assert_eq!(rl.inner.limits.len(), 27);
1062 assert!(format!("{:?}", rl).contains("endpoints"));
1063 }
1064
1065 #[test]
1066 fn test_gamma_default_construction() {
1067 let rl = RateLimiter::gamma_default();
1068 assert_eq!(rl.inner.limits.len(), 6);
1069 }
1070
1071 #[test]
1072 fn test_data_default_construction() {
1073 let rl = RateLimiter::data_default();
1074 assert_eq!(rl.inner.limits.len(), 5);
1075 }
1076
1077 #[test]
1078 fn test_relay_default_construction() {
1079 let rl = RateLimiter::relay_default();
1080 assert_eq!(rl.inner.limits.len(), 0);
1081 }
1082
1083 #[test]
1084 fn test_rate_limiter_debug_format() {
1085 let rl = RateLimiter::clob_default();
1086 let dbg = format!("{:?}", rl);
1087 assert!(dbg.contains("RateLimiter"), "missing struct name: {dbg}");
1088 assert!(dbg.contains("endpoints: 27"), "missing count: {dbg}");
1089 }
1090
1091 #[test]
1094 fn test_clob_tighter_rules_precede_the_prefixes_that_would_shadow_them() {
1095 let rl = RateLimiter::clob_default();
1099 let index_of = |path: &str| {
1100 rl.inner
1101 .limits
1102 .iter()
1103 .position(|l| l.path_prefix == path)
1104 .unwrap_or_else(|| panic!("{path} should be configured"))
1105 };
1106
1107 for (specific, general) in [
1108 ("/balance-allowance/update", "/balance-allowance"),
1109 ("/data/orders", "/data"),
1110 ("/data/trades", "/data"),
1111 ] {
1112 assert!(
1113 index_of(specific) < index_of(general),
1114 "{specific} must be matched before {general} or it can never win"
1115 );
1116 }
1117 }
1118
1119 #[tokio::test]
1122 async fn test_acquire_single_completes_immediately() {
1123 let rl = RateLimiter::clob_default();
1124 let start = std::time::Instant::now();
1125 rl.acquire("/order", Some(&Method::POST)).await;
1126 assert!(start.elapsed() < Duration::from_millis(50));
1127 }
1128
1129 #[tokio::test]
1130 async fn test_acquire_matches_endpoint_by_prefix() {
1131 let rl = RateLimiter::clob_default();
1132 let start = std::time::Instant::now();
1133 rl.acquire("/order/123", Some(&Method::POST)).await;
1135 assert!(start.elapsed() < Duration::from_millis(50));
1136 }
1137
1138 #[tokio::test]
1139 async fn test_acquire_prefix_respects_segment_boundary() {
1140 let rl = RateLimiter::clob_default();
1141 let limits = &rl.inner.limits;
1142
1143 let price_idx = limits
1145 .iter()
1146 .position(|l| l.path_prefix == "/price")
1147 .expect("/price endpoint exists");
1148
1149 let prices_history_idx = limits
1151 .iter()
1152 .position(|l| l.path_prefix == "/prices-history")
1153 .expect("/prices-history endpoint exists");
1154
1155 assert!(
1157 prices_history_idx < price_idx,
1158 "/prices-history (idx {prices_history_idx}) should come before /price (idx {price_idx})"
1159 );
1160 }
1161
1162 #[test]
1163 fn test_match_mode_prefix_segment_boundary() {
1164 let pattern = "/price";
1166
1167 let check = |path: &str| -> bool {
1168 match path.strip_prefix(pattern) {
1169 Some(rest) => rest.is_empty() || rest.starts_with('/') || rest.starts_with('?'),
1170 None => false,
1171 }
1172 };
1173
1174 assert!(check("/price"), "exact match");
1176 assert!(check("/price/foo"), "sub-path");
1177 assert!(check("/price?token=abc"), "query params");
1178
1179 assert!(!check("/prices-history"), "partial word /prices-history");
1181 assert!(!check("/pricelist"), "partial word /pricelist");
1182 assert!(!check("/pricing"), "partial word /pricing");
1183
1184 assert!(!check("/midpoint"), "different prefix");
1186 }
1187
1188 #[test]
1189 fn test_match_mode_exact() {
1190 let pattern = "/trades";
1192
1193 let check = |path: &str| -> bool { path == pattern };
1194
1195 assert!(check("/trades"), "exact match");
1196 assert!(!check("/trades/123"), "sub-path should not match");
1197 assert!(!check("/trades?limit=10"), "query params should not match");
1198 assert!(!check("/traded"), "different word should not match");
1199 }
1200
1201 #[tokio::test]
1202 async fn test_acquire_method_filtering() {
1203 let rl = RateLimiter::clob_default();
1204 let start = std::time::Instant::now();
1205 rl.acquire("/order", Some(&Method::GET)).await;
1207 assert!(start.elapsed() < Duration::from_millis(50));
1208 }
1209
1210 #[tokio::test]
1211 async fn test_acquire_no_endpoint_match_uses_default_only() {
1212 let rl = RateLimiter::clob_default();
1213 let start = std::time::Instant::now();
1214 rl.acquire("/unknown/path", None).await;
1215 assert!(start.elapsed() < Duration::from_millis(50));
1216 }
1217
1218 #[tokio::test]
1219 async fn test_acquire_method_none_matches_any_method() {
1220 let rl = RateLimiter::gamma_default();
1221 let start = std::time::Instant::now();
1222 rl.acquire("/events", Some(&Method::GET)).await;
1224 rl.acquire("/events", Some(&Method::POST)).await;
1225 rl.acquire("/events", None).await;
1226 assert!(start.elapsed() < Duration::from_millis(50));
1227 }
1228
1229 #[test]
1232 fn test_clob_price_and_prices_history_are_distinct() {
1233 let rl = RateLimiter::clob_default();
1234 let limits = &rl.inner.limits;
1235
1236 let price = limits.iter().find(|l| l.path_prefix == "/price").unwrap();
1237 let prices_history = limits
1238 .iter()
1239 .find(|l| l.path_prefix == "/prices-history")
1240 .unwrap();
1241
1242 assert_eq!(price.match_mode, MatchMode::Prefix);
1244 assert_eq!(prices_history.match_mode, MatchMode::Prefix);
1245
1246 if let Some(rest) = "/prices-history".strip_prefix(price.path_prefix) {
1248 assert!(
1249 !rest.is_empty() && !rest.starts_with('/') && !rest.starts_with('?'),
1250 "/prices-history must not match /price pattern, rest = '{rest}'"
1251 );
1252 }
1253 }
1254
1255 #[test]
1256 fn test_data_positions_and_closed_positions_are_distinct() {
1257 let rl = RateLimiter::data_default();
1262
1263 let closed = rl.resolve_specs("/closed-positions", Some(&Method::GET));
1264 let positions = rl.resolve_specs("/positions", Some(&Method::GET));
1265 assert_eq!(closed, positions, "both are published at 150/10s");
1266
1267 let bucket_for = |path: &str| {
1268 rl.inner
1269 .limits
1270 .iter()
1271 .find(|l| l.matches(path, Some(&Method::GET)))
1272 .unwrap_or_else(|| panic!("{path} should match a rule"))
1273 .buckets[0]
1274 .clone()
1275 };
1276 assert!(
1277 !Arc::ptr_eq(&bucket_for("/closed-positions"), &bucket_for("/positions")),
1278 "equal quotas must still be separate buckets — upstream publishes \
1279 150/10s each, not 150/10s combined"
1280 );
1281 }
1282
1283 #[test]
1284 fn test_all_clob_endpoints_have_match_mode() {
1285 let rl = RateLimiter::clob_default();
1286 for limit in &rl.inner.limits {
1287 assert!(
1289 limit.match_mode == MatchMode::Prefix || limit.match_mode == MatchMode::Exact,
1290 "endpoint {} has no valid match mode",
1291 limit.path_prefix
1292 );
1293 }
1294 }
1295
1296 #[tokio::test]
1299 async fn test_acquire_concurrent_tasks_all_complete() {
1300 let rl = RateLimiter::clob_default(); let rl = std::sync::Arc::new(rl);
1303
1304 let mut handles = Vec::new();
1305 for _ in 0..10 {
1306 let rl = rl.clone();
1307 handles.push(tokio::spawn(async move {
1308 rl.acquire("/markets", None).await;
1309 }));
1310 }
1311
1312 let start = std::time::Instant::now();
1313 for handle in handles {
1314 handle.await.unwrap();
1315 }
1316 assert!(
1318 start.elapsed() < Duration::from_millis(100),
1319 "concurrent acquires took too long: {:?}",
1320 start.elapsed()
1321 );
1322 }
1323
1324 #[tokio::test]
1325 async fn test_acquire_concurrent_different_endpoints() {
1326 let rl = std::sync::Arc::new(RateLimiter::clob_default());
1328
1329 let rl1 = rl.clone();
1330 let rl2 = rl.clone();
1331 let rl3 = rl.clone();
1332
1333 let start = std::time::Instant::now();
1334 let (r1, r2, r3) = tokio::join!(
1335 tokio::spawn(async move { rl1.acquire("/markets", None).await }),
1336 tokio::spawn(async move { rl2.acquire("/auth", None).await }),
1337 tokio::spawn(async move { rl3.acquire("/order", Some(&Method::POST)).await }),
1338 );
1339 r1.unwrap();
1340 r2.unwrap();
1341 r3.unwrap();
1342
1343 assert!(
1344 start.elapsed() < Duration::from_millis(50),
1345 "different endpoints should not block: {:?}",
1346 start.elapsed()
1347 );
1348 }
1349
1350 #[test]
1353 fn test_clob_post_order_has_dual_window() {
1354 let rl = RateLimiter::clob_default();
1355 let post_order = rl
1356 .inner
1357 .limits
1358 .iter()
1359 .find(|l| l.path_prefix == "/order" && l.method == Some(Method::POST))
1360 .expect("POST /order endpoint should exist");
1361
1362 assert_eq!(
1363 post_order.buckets.len(),
1364 2,
1365 "POST /order should have a burst and a sustained window"
1366 );
1367 }
1368
1369 #[test]
1370 fn test_clob_delete_order_has_a_sustained_window_too() {
1371 let rl = RateLimiter::clob_default();
1375 let delete_order = rl
1376 .inner
1377 .limits
1378 .iter()
1379 .find(|l| l.path_prefix == "/order" && l.method == Some(Method::DELETE))
1380 .expect("DELETE /order endpoint should exist");
1381
1382 assert_eq!(
1383 delete_order.buckets.len(),
1384 2,
1385 "DELETE /order should have both a burst and a sustained window"
1386 );
1387 }
1388
1389 #[tokio::test]
1390 async fn test_dual_window_both_burst_and_sustained_are_awaited() {
1391 let rl = RateLimiter::clob_default();
1394 let start = std::time::Instant::now();
1395 rl.acquire("/order", Some(&Method::POST)).await;
1396 assert!(
1397 start.elapsed() < Duration::from_millis(50),
1398 "dual window single acquire should be fast: {:?}",
1399 start.elapsed()
1400 );
1401 }
1402
1403 #[test]
1406 fn test_should_retry_exhaustion() {
1407 let client = crate::HttpClientBuilder::new("https://example.com")
1409 .with_retry_config(RetryConfig {
1410 max_retries: 3,
1411 ..RetryConfig::default()
1412 })
1413 .build()
1414 .unwrap();
1415
1416 for attempt in 0..3 {
1418 assert!(
1419 client
1420 .should_retry(reqwest::StatusCode::TOO_MANY_REQUESTS, attempt, None)
1421 .is_some(),
1422 "attempt {attempt} should allow retry"
1423 );
1424 }
1425 assert!(
1427 client
1428 .should_retry(reqwest::StatusCode::TOO_MANY_REQUESTS, 3, None)
1429 .is_none(),
1430 "attempt 3 should exhaust retries"
1431 );
1432 }
1433
1434 #[test]
1435 fn test_should_retry_zero_max_retries_never_retries() {
1436 let client = crate::HttpClientBuilder::new("https://example.com")
1437 .with_retry_config(RetryConfig {
1438 max_retries: 0,
1439 ..RetryConfig::default()
1440 })
1441 .build()
1442 .unwrap();
1443
1444 assert!(
1445 client
1446 .should_retry(reqwest::StatusCode::TOO_MANY_REQUESTS, 0, None)
1447 .is_none(),
1448 "max_retries=0 should never retry"
1449 );
1450 }
1451}
1452
1453#[cfg(test)]
1454mod cooldown_tests {
1455 use super::*;
1465
1466 #[tokio::test(start_paused = true)]
1467 async fn acquire_is_immediate_without_a_cooldown() {
1468 let rl = RateLimiter::data_default();
1469 let t = tokio::time::Instant::now();
1470 rl.acquire("/closed-positions", None).await;
1471 assert!(
1472 t.elapsed() < Duration::from_millis(1),
1473 "an untripped limiter must not delay: waited {:?}",
1474 t.elapsed()
1475 );
1476 }
1477
1478 #[tokio::test(start_paused = true)]
1479 async fn a_cooldown_holds_back_a_path_that_never_saw_the_429() {
1480 let rl = RateLimiter::data_default();
1481 rl.begin_cooldown(Duration::from_secs(5));
1482
1483 let t = tokio::time::Instant::now();
1486 rl.acquire("/trades", None).await;
1487 assert!(
1488 t.elapsed() >= Duration::from_secs(5),
1489 "a sibling path resumed after {:?}, before the cooldown expired",
1490 t.elapsed()
1491 );
1492 }
1493
1494 #[tokio::test(start_paused = true)]
1495 async fn concurrent_requests_all_observe_one_cooldown() {
1496 let rl = RateLimiter::data_default();
1497 rl.begin_cooldown(Duration::from_secs(3));
1498
1499 let t = tokio::time::Instant::now();
1502 tokio::join!(
1503 rl.acquire("/closed-positions", None),
1504 rl.acquire("/closed-positions", None),
1505 rl.acquire("/closed-positions", None),
1506 rl.acquire("/closed-positions", None),
1507 );
1508 assert!(
1509 t.elapsed() >= Duration::from_secs(3),
1510 "concurrent callers resumed after {:?}",
1511 t.elapsed()
1512 );
1513 }
1514
1515 #[tokio::test(start_paused = true)]
1516 async fn a_shorter_cooldown_never_cuts_a_longer_one_short() {
1517 let rl = RateLimiter::data_default();
1518 rl.begin_cooldown(Duration::from_secs(10));
1519 rl.begin_cooldown(Duration::from_secs(1));
1523
1524 let t = tokio::time::Instant::now();
1525 rl.acquire("/positions", None).await;
1526 assert!(
1527 t.elapsed() >= Duration::from_secs(10),
1528 "the longer cooldown was truncated to {:?}",
1529 t.elapsed()
1530 );
1531 }
1532
1533 #[tokio::test(start_paused = true)]
1534 async fn a_cooldown_extended_mid_wait_is_honoured_in_full() {
1535 let rl = RateLimiter::data_default();
1536 rl.begin_cooldown(Duration::from_secs(2));
1537
1538 let extender = {
1539 let rl = rl.clone();
1540 tokio::spawn(async move {
1541 tokio::time::sleep(Duration::from_secs(1)).await;
1542 rl.begin_cooldown(Duration::from_secs(5));
1543 })
1544 };
1545
1546 let t = tokio::time::Instant::now();
1547 rl.acquire("/closed-positions", None).await;
1548 extender.await.unwrap();
1549 assert!(
1552 t.elapsed() >= Duration::from_secs(6),
1553 "resumed at {:?}, ignoring the cooldown extension",
1554 t.elapsed()
1555 );
1556 }
1557
1558 #[tokio::test(start_paused = true)]
1559 async fn an_expired_cooldown_stops_delaying() {
1560 let rl = RateLimiter::data_default();
1561 rl.begin_cooldown(Duration::from_secs(2));
1562 rl.acquire("/closed-positions", None).await;
1563
1564 let t = tokio::time::Instant::now();
1565 rl.acquire("/closed-positions", None).await;
1566 assert!(
1567 t.elapsed() < Duration::from_millis(1),
1568 "the limiter stayed blocked for {:?} after the cooldown expired",
1569 t.elapsed()
1570 );
1571 }
1572}