1use std::num::NonZeroU32;
2use std::sync::Arc;
3use std::time::Duration;
4
5use governor::Quota;
6use reqwest::Method;
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}
115
116fn quota(count: u32, period: Duration) -> Quota {
121 let count = count.max(1);
122 let interval = period / count;
123 Quota::with_period(interval)
124 .expect("quota interval must be non-zero")
125 .allow_burst(NonZeroU32::new(count).unwrap())
126}
127
128fn endpoint_limit(
130 path_prefix: &'static str,
131 method: Option<Method>,
132 buckets: Vec<Arc<Bucket>>,
133) -> EndpointLimit {
134 EndpointLimit {
135 path_prefix,
136 method,
137 match_mode: MatchMode::Prefix,
138 buckets,
139 }
140}
141
142fn simple_limit(
144 path_prefix: &'static str,
145 method: Option<Method>,
146 count: u32,
147 period: Duration,
148) -> EndpointLimit {
149 endpoint_limit(path_prefix, method, vec![Bucket::new(count, period)])
150}
151
152fn dual_limit(
154 path_prefix: &'static str,
155 method: Method,
156 burst: (u32, Duration),
157 sustained: (u32, Duration),
158) -> EndpointLimit {
159 endpoint_limit(
160 path_prefix,
161 Some(method),
162 vec![
163 Bucket::new(burst.0, burst.1),
164 Bucket::new(sustained.0, sustained.1),
165 ],
166 )
167}
168
169impl RateLimiter {
170 pub async fn acquire(&self, path: &str, method: Option<&Method>) {
175 self.inner.default.until_ready().await;
176
177 if let Some(limit) = self.inner.limits.iter().find(|l| l.matches(path, method)) {
178 for bucket in &limit.buckets {
179 bucket.limiter.until_ready().await;
180 }
181 }
182 }
183
184 #[cfg(test)]
190 fn resolve_specs(&self, path: &str, method: Option<&Method>) -> Vec<RateSpec> {
191 self.inner
192 .limits
193 .iter()
194 .find(|l| l.matches(path, method))
195 .map(|l| l.buckets.iter().map(|b| b.spec).collect())
196 .unwrap_or_default()
197 }
198
199 pub fn clob_default() -> Self {
219 let ten_sec = Duration::from_secs(10);
220 let ten_min = Duration::from_secs(600);
221 let get = Some(Method::GET);
222
223 let ledger_group = Bucket::new(900, ten_sec);
225
226 Self {
227 inner: Arc::new(RateLimiterInner {
228 default: DirectLimiter::direct(quota(9_000, ten_sec)),
229 limits: vec![
230 simple_limit("/balance-allowance/update", None, 50, ten_sec),
233 simple_limit("/balance-allowance", None, 200, ten_sec),
234 dual_limit("/order", Method::POST, (5_000, ten_sec), (120_000, ten_min)),
236 dual_limit(
237 "/order",
238 Method::DELETE,
239 (5_000, ten_sec),
240 (120_000, ten_min),
241 ),
242 dual_limit("/orders", Method::POST, (2_000, ten_sec), (21_000, ten_min)),
243 dual_limit(
244 "/orders",
245 Method::DELETE,
246 (2_000, ten_sec),
247 (15_000, ten_min),
248 ),
249 dual_limit(
250 "/cancel-all",
251 Method::DELETE,
252 (250, ten_sec),
253 (6_000, ten_min),
254 ),
255 dual_limit(
256 "/cancel-market-orders",
257 Method::DELETE,
258 (1_500, ten_sec),
259 (21_000, ten_min),
260 ),
261 endpoint_limit(
264 "/notifications",
265 None,
266 vec![ledger_group.clone(), Bucket::new(125, ten_sec)],
267 ),
268 endpoint_limit("/trades", get.clone(), vec![ledger_group.clone()]),
269 endpoint_limit("/orders", get.clone(), vec![ledger_group.clone()]),
270 endpoint_limit("/order", get.clone(), vec![ledger_group]),
271 simple_limit("/data/orders", None, 500, ten_sec),
275 simple_limit("/data/trades", None, 500, ten_sec),
276 simple_limit("/data", None, 500, ten_sec),
277 simple_limit("/auth", None, 100, ten_sec),
279 simple_limit("/prices-history", None, 1_000, ten_sec),
283 simple_limit("/book", None, 1_500, ten_sec),
284 simple_limit("/books", None, 500, ten_sec),
285 simple_limit("/price", None, 1_500, ten_sec),
286 simple_limit("/prices", None, 500, ten_sec),
287 simple_limit("/midpoint", None, 1_500, ten_sec),
288 simple_limit("/midpoints", None, 500, ten_sec),
289 simple_limit("/tick-size", None, 200, ten_sec),
290 simple_limit("/ok", None, 100, ten_sec),
292 simple_limit("/markets", None, 1_500, ten_sec),
297 simple_limit("/neg-risk", None, 1_500, ten_sec),
298 ],
299 }),
300 }
301 }
302
303 pub fn gamma_default() -> Self {
317 let ten_sec = Duration::from_secs(10);
318
319 Self {
320 inner: Arc::new(RateLimiterInner {
321 default: DirectLimiter::direct(quota(4_000, ten_sec)),
322 limits: vec![
323 simple_limit("/comments", None, 200, ten_sec),
324 simple_limit("/tags", None, 200, ten_sec),
325 simple_limit("/markets", None, 300, ten_sec),
326 simple_limit("/public-search", None, 350, ten_sec),
327 simple_limit("/events", None, 500, ten_sec),
328 simple_limit("/ok", None, 100, ten_sec),
329 ],
330 }),
331 }
332 }
333
334 pub fn data_default() -> Self {
341 let ten_sec = Duration::from_secs(10);
342
343 Self {
344 inner: Arc::new(RateLimiterInner {
345 default: DirectLimiter::direct(quota(1_000, ten_sec)),
346 limits: vec![
347 simple_limit("/closed-positions", None, 150, ten_sec),
348 simple_limit("/positions", None, 150, ten_sec),
349 simple_limit("/trades", None, 200, ten_sec),
350 simple_limit("/ok", None, 100, ten_sec),
351 ],
352 }),
353 }
354 }
355
356 pub fn relay_default() -> Self {
360 Self {
361 inner: Arc::new(RateLimiterInner {
362 default: DirectLimiter::direct(quota(25, Duration::from_secs(60))),
363 limits: vec![],
364 }),
365 }
366 }
367}
368
369#[derive(Debug, Clone)]
371pub struct RetryConfig {
372 pub max_retries: u32,
374 pub initial_backoff_ms: u64,
376 pub max_backoff_ms: u64,
378}
379
380impl Default for RetryConfig {
381 fn default() -> Self {
382 Self {
383 max_retries: 3,
384 initial_backoff_ms: 500,
385 max_backoff_ms: 10_000,
386 }
387 }
388}
389
390impl RetryConfig {
391 pub fn backoff(&self, attempt: u32) -> Duration {
396 let base = self
397 .initial_backoff_ms
398 .saturating_mul(1u64 << attempt.min(10));
399 let capped = base.min(self.max_backoff_ms);
400 let jitter_factor = 0.75 + (fastrand::f64() * 0.5);
402 let ms = (capped as f64 * jitter_factor) as u64;
403 Duration::from_millis(ms.max(1))
404 }
405}
406
407#[cfg(test)]
408mod documented_limits {
409 use super::*;
418
419 type DocumentedRule = (&'static str, Option<Method>, Vec<(u32, u64)>);
422
423 fn documented() -> Vec<DocumentedRule> {
425 vec![
426 ("/balance-allowance", Some(Method::GET), vec![(200, 10)]),
428 (
429 "/balance-allowance/update",
430 Some(Method::GET),
431 vec![(50, 10)],
432 ),
433 (
435 "/order",
436 Some(Method::POST),
437 vec![(5_000, 10), (120_000, 600)],
438 ),
439 (
440 "/order",
441 Some(Method::DELETE),
442 vec![(5_000, 10), (120_000, 600)],
443 ),
444 (
445 "/orders",
446 Some(Method::POST),
447 vec![(2_000, 10), (21_000, 600)],
448 ),
449 (
450 "/orders",
451 Some(Method::DELETE),
452 vec![(2_000, 10), (15_000, 600)],
453 ),
454 (
455 "/cancel-all",
456 Some(Method::DELETE),
457 vec![(250, 10), (6_000, 600)],
458 ),
459 (
460 "/cancel-market-orders",
461 Some(Method::DELETE),
462 vec![(1_500, 10), (21_000, 600)],
463 ),
464 ("/trades", Some(Method::GET), vec![(900, 10)]),
466 ("/orders", Some(Method::GET), vec![(900, 10)]),
467 ("/order", Some(Method::GET), vec![(900, 10)]),
468 (
469 "/notifications",
470 Some(Method::GET),
471 vec![(900, 10), (125, 10)],
472 ),
473 ("/data/orders", Some(Method::GET), vec![(500, 10)]),
474 ("/data/trades", Some(Method::GET), vec![(500, 10)]),
475 ("/book", Some(Method::GET), vec![(1_500, 10)]),
477 ("/books", Some(Method::POST), vec![(500, 10)]),
478 ("/price", Some(Method::GET), vec![(1_500, 10)]),
479 ("/prices", Some(Method::POST), vec![(500, 10)]),
480 ("/midpoint", Some(Method::GET), vec![(1_500, 10)]),
481 ("/midpoints", Some(Method::POST), vec![(500, 10)]),
482 ("/prices-history", Some(Method::GET), vec![(1_000, 10)]),
483 ("/tick-size", Some(Method::GET), vec![(200, 10)]),
484 ("/auth/api-key", Some(Method::POST), vec![(100, 10)]),
486 ("/ok", Some(Method::GET), vec![(100, 10)]),
487 ]
488 }
489
490 #[test]
491 fn every_documented_endpoint_resolves_to_its_published_quota() {
492 let rl = RateLimiter::clob_default();
493
494 for (path, method, expected) in documented() {
495 let resolved = rl.resolve_specs(path, method.as_ref());
496 assert!(
497 !resolved.is_empty(),
498 "{method:?} {path} matches no endpoint limit — it falls through to the \
499 general {}/10s bucket, over-permitting by {}x",
500 9_000,
501 9_000 / expected[0].0.max(1),
502 );
503 let actual: Vec<(u32, u64)> = resolved
504 .iter()
505 .map(|s| (s.count, s.period.as_secs()))
506 .collect();
507 assert_eq!(
508 actual, expected,
509 "{method:?} {path} resolves to {actual:?}, published limit is {expected:?}"
510 );
511 }
512 }
513
514 #[test]
515 fn batch_endpoints_do_not_inherit_their_singular_sibling() {
516 let rl = RateLimiter::clob_default();
519 for (batch, singular) in [
520 ("/books", "/book"),
521 ("/prices", "/price"),
522 ("/midpoints", "/midpoint"),
523 ] {
524 let batch_specs = rl.resolve_specs(batch, Some(&Method::POST));
525 let singular_specs = rl.resolve_specs(singular, Some(&Method::GET));
526 assert_ne!(
527 batch_specs, singular_specs,
528 "{batch} is being limited as if it were {singular}"
529 );
530 assert_eq!(batch_specs[0].count, 500, "{batch} should allow 500/10s");
531 }
532 }
533
534 #[test]
535 fn the_ledger_group_cap_is_one_shared_bucket() {
536 let rl = RateLimiter::clob_default();
540 let group: Vec<_> = ["/trades", "/orders", "/order", "/notifications"]
541 .iter()
542 .map(|p| {
543 rl.inner
544 .limits
545 .iter()
546 .find(|l| l.matches(p, Some(&Method::GET)))
547 .unwrap_or_else(|| panic!("{p} should match a ledger entry"))
548 .buckets[0]
549 .clone()
550 })
551 .collect();
552
553 for other in &group[1..] {
554 assert!(
555 Arc::ptr_eq(&group[0], other),
556 "ledger endpoints must share one bucket, not hold copies"
557 );
558 }
559 }
560
561 #[test]
562 fn balance_allowance_update_is_not_shadowed_by_its_parent_path() {
563 let rl = RateLimiter::clob_default();
567 let update = rl.resolve_specs("/balance-allowance/update", Some(&Method::GET));
568 assert_eq!(
569 update[0].count, 50,
570 "the tighter /balance-allowance/update rule must be ordered first"
571 );
572 }
573
574 #[tokio::test]
575 async fn a_documented_cap_actually_throttles() {
576 let rl = RateLimiter::clob_default();
580 for _ in 0..200 {
581 rl.acquire("/tick-size", Some(&Method::GET)).await;
582 }
583
584 let start = std::time::Instant::now();
585 rl.acquire("/tick-size", Some(&Method::GET)).await;
586 let waited = start.elapsed();
587
588 assert!(
589 waited >= Duration::from_millis(25),
590 "201st /tick-size request returned in {waited:?}; the cap is not being enforced"
591 );
592 }
593
594 #[tokio::test]
595 async fn the_ledger_group_allowance_is_consumed_jointly() {
596 let rl = RateLimiter::clob_default();
600 for _ in 0..900 {
601 rl.acquire("/trades", Some(&Method::GET)).await;
602 }
603
604 let start = std::time::Instant::now();
605 rl.acquire("/orders", Some(&Method::GET)).await;
606 let waited = start.elapsed();
607
608 assert!(
609 waited >= Duration::from_millis(5),
610 "GET /orders returned in {waited:?} after /trades drained the shared 900/10s \
611 allowance — the group cap is not actually shared"
612 );
613 }
614
615 #[test]
616 fn post_order_is_not_throttled_by_the_ledger_group() {
617 let rl = RateLimiter::clob_default();
622 let specs = rl.resolve_specs("/order", Some(&Method::POST));
623 assert_eq!(specs[0].count, 5_000);
624 assert!(
625 !specs.iter().any(|s| s.count == 900),
626 "POST /order must not be caught by the ledger read cap"
627 );
628 }
629}
630
631#[cfg(test)]
632mod tests {
633 use super::*;
634
635 #[test]
638 fn test_retry_config_default() {
639 let cfg = RetryConfig::default();
640 assert_eq!(cfg.max_retries, 3);
641 assert_eq!(cfg.initial_backoff_ms, 500);
642 assert_eq!(cfg.max_backoff_ms, 10_000);
643 }
644
645 #[test]
646 fn test_backoff_attempt_zero() {
647 let cfg = RetryConfig::default();
648 let d = cfg.backoff(0);
649 let ms = d.as_millis() as u64;
652 assert!(
653 (375..=625).contains(&ms),
654 "attempt 0: {ms}ms not in [375, 625]"
655 );
656 }
657
658 #[test]
659 fn test_backoff_exponential_growth() {
660 let cfg = RetryConfig::default();
661 let d0 = cfg.backoff(0);
662 let d1 = cfg.backoff(1);
663 let d2 = cfg.backoff(2);
664 assert!(d0 < d1, "d0={d0:?} should be < d1={d1:?}");
665 assert!(d1 < d2, "d1={d1:?} should be < d2={d2:?}");
666 }
667
668 #[test]
669 fn test_backoff_jitter_bounds() {
670 let cfg = RetryConfig::default();
671 for attempt in 0..20 {
672 let d = cfg.backoff(attempt);
673 let base = cfg
674 .initial_backoff_ms
675 .saturating_mul(1u64 << attempt.min(10));
676 let capped = base.min(cfg.max_backoff_ms);
677 let lower = (capped as f64 * 0.75) as u64;
678 let upper = (capped as f64 * 1.25) as u64;
679 let ms = d.as_millis() as u64;
680 assert!(
681 ms >= lower.max(1) && ms <= upper,
682 "attempt {attempt}: {ms}ms not in [{lower}, {upper}]"
683 );
684 }
685 }
686
687 #[test]
688 fn test_backoff_max_capping() {
689 let cfg = RetryConfig::default();
690 for attempt in 5..=10 {
691 let d = cfg.backoff(attempt);
692 let ceiling = (cfg.max_backoff_ms as f64 * 1.25) as u64;
693 assert!(
694 d.as_millis() as u64 <= ceiling,
695 "attempt {attempt}: {:?} exceeded ceiling {ceiling}ms",
696 d
697 );
698 }
699 }
700
701 #[test]
702 fn test_backoff_very_high_attempt() {
703 let cfg = RetryConfig::default();
704 let d = cfg.backoff(100);
705 let ceiling = (cfg.max_backoff_ms as f64 * 1.25) as u64;
706 assert!(d.as_millis() as u64 <= ceiling);
707 assert!(d.as_millis() >= 1);
708 }
709
710 #[test]
711 fn test_backoff_jitter_distribution() {
712 let cfg = RetryConfig::default();
715 let midpoint = cfg.initial_backoff_ms; let (mut below, mut above) = (0u32, 0u32);
717 for _ in 0..200 {
718 let ms = cfg.backoff(0).as_millis() as u64;
719 if ms < midpoint {
720 below += 1;
721 } else {
722 above += 1;
723 }
724 }
725 assert!(
726 below >= 20 && above >= 20,
727 "jitter looks degenerate: {below} below midpoint, {above} above"
728 );
729 }
730
731 #[test]
734 fn test_quota_creation() {
735 let _ = quota(100, Duration::from_secs(10));
737 let _ = quota(1, Duration::from_secs(60));
738 let _ = quota(9_000, Duration::from_secs(10));
739 }
740
741 #[test]
742 fn test_quota_edge_zero_count() {
743 let _ = quota(0, Duration::from_secs(10));
745 }
746
747 #[test]
750 fn test_clob_default_construction() {
751 let rl = RateLimiter::clob_default();
752 assert_eq!(rl.inner.limits.len(), 27);
753 assert!(format!("{:?}", rl).contains("endpoints"));
754 }
755
756 #[test]
757 fn test_gamma_default_construction() {
758 let rl = RateLimiter::gamma_default();
759 assert_eq!(rl.inner.limits.len(), 6);
760 }
761
762 #[test]
763 fn test_data_default_construction() {
764 let rl = RateLimiter::data_default();
765 assert_eq!(rl.inner.limits.len(), 4);
766 }
767
768 #[test]
769 fn test_relay_default_construction() {
770 let rl = RateLimiter::relay_default();
771 assert_eq!(rl.inner.limits.len(), 0);
772 }
773
774 #[test]
775 fn test_rate_limiter_debug_format() {
776 let rl = RateLimiter::clob_default();
777 let dbg = format!("{:?}", rl);
778 assert!(dbg.contains("RateLimiter"), "missing struct name: {dbg}");
779 assert!(dbg.contains("endpoints: 27"), "missing count: {dbg}");
780 }
781
782 #[test]
785 fn test_clob_tighter_rules_precede_the_prefixes_that_would_shadow_them() {
786 let rl = RateLimiter::clob_default();
790 let index_of = |path: &str| {
791 rl.inner
792 .limits
793 .iter()
794 .position(|l| l.path_prefix == path)
795 .unwrap_or_else(|| panic!("{path} should be configured"))
796 };
797
798 for (specific, general) in [
799 ("/balance-allowance/update", "/balance-allowance"),
800 ("/data/orders", "/data"),
801 ("/data/trades", "/data"),
802 ] {
803 assert!(
804 index_of(specific) < index_of(general),
805 "{specific} must be matched before {general} or it can never win"
806 );
807 }
808 }
809
810 #[tokio::test]
813 async fn test_acquire_single_completes_immediately() {
814 let rl = RateLimiter::clob_default();
815 let start = std::time::Instant::now();
816 rl.acquire("/order", Some(&Method::POST)).await;
817 assert!(start.elapsed() < Duration::from_millis(50));
818 }
819
820 #[tokio::test]
821 async fn test_acquire_matches_endpoint_by_prefix() {
822 let rl = RateLimiter::clob_default();
823 let start = std::time::Instant::now();
824 rl.acquire("/order/123", Some(&Method::POST)).await;
826 assert!(start.elapsed() < Duration::from_millis(50));
827 }
828
829 #[tokio::test]
830 async fn test_acquire_prefix_respects_segment_boundary() {
831 let rl = RateLimiter::clob_default();
832 let limits = &rl.inner.limits;
833
834 let price_idx = limits
836 .iter()
837 .position(|l| l.path_prefix == "/price")
838 .expect("/price endpoint exists");
839
840 let prices_history_idx = limits
842 .iter()
843 .position(|l| l.path_prefix == "/prices-history")
844 .expect("/prices-history endpoint exists");
845
846 assert!(
848 prices_history_idx < price_idx,
849 "/prices-history (idx {prices_history_idx}) should come before /price (idx {price_idx})"
850 );
851 }
852
853 #[test]
854 fn test_match_mode_prefix_segment_boundary() {
855 let pattern = "/price";
857
858 let check = |path: &str| -> bool {
859 match path.strip_prefix(pattern) {
860 Some(rest) => rest.is_empty() || rest.starts_with('/') || rest.starts_with('?'),
861 None => false,
862 }
863 };
864
865 assert!(check("/price"), "exact match");
867 assert!(check("/price/foo"), "sub-path");
868 assert!(check("/price?token=abc"), "query params");
869
870 assert!(!check("/prices-history"), "partial word /prices-history");
872 assert!(!check("/pricelist"), "partial word /pricelist");
873 assert!(!check("/pricing"), "partial word /pricing");
874
875 assert!(!check("/midpoint"), "different prefix");
877 }
878
879 #[test]
880 fn test_match_mode_exact() {
881 let pattern = "/trades";
883
884 let check = |path: &str| -> bool { path == pattern };
885
886 assert!(check("/trades"), "exact match");
887 assert!(!check("/trades/123"), "sub-path should not match");
888 assert!(!check("/trades?limit=10"), "query params should not match");
889 assert!(!check("/traded"), "different word should not match");
890 }
891
892 #[tokio::test]
893 async fn test_acquire_method_filtering() {
894 let rl = RateLimiter::clob_default();
895 let start = std::time::Instant::now();
896 rl.acquire("/order", Some(&Method::GET)).await;
898 assert!(start.elapsed() < Duration::from_millis(50));
899 }
900
901 #[tokio::test]
902 async fn test_acquire_no_endpoint_match_uses_default_only() {
903 let rl = RateLimiter::clob_default();
904 let start = std::time::Instant::now();
905 rl.acquire("/unknown/path", None).await;
906 assert!(start.elapsed() < Duration::from_millis(50));
907 }
908
909 #[tokio::test]
910 async fn test_acquire_method_none_matches_any_method() {
911 let rl = RateLimiter::gamma_default();
912 let start = std::time::Instant::now();
913 rl.acquire("/events", Some(&Method::GET)).await;
915 rl.acquire("/events", Some(&Method::POST)).await;
916 rl.acquire("/events", None).await;
917 assert!(start.elapsed() < Duration::from_millis(50));
918 }
919
920 #[test]
923 fn test_clob_price_and_prices_history_are_distinct() {
924 let rl = RateLimiter::clob_default();
925 let limits = &rl.inner.limits;
926
927 let price = limits.iter().find(|l| l.path_prefix == "/price").unwrap();
928 let prices_history = limits
929 .iter()
930 .find(|l| l.path_prefix == "/prices-history")
931 .unwrap();
932
933 assert_eq!(price.match_mode, MatchMode::Prefix);
935 assert_eq!(prices_history.match_mode, MatchMode::Prefix);
936
937 if let Some(rest) = "/prices-history".strip_prefix(price.path_prefix) {
939 assert!(
940 !rest.is_empty() && !rest.starts_with('/') && !rest.starts_with('?'),
941 "/prices-history must not match /price pattern, rest = '{rest}'"
942 );
943 }
944 }
945
946 #[test]
947 fn test_data_positions_and_closed_positions_are_distinct() {
948 let rl = RateLimiter::data_default();
949 let limits = &rl.inner.limits;
950
951 let positions = limits
952 .iter()
953 .find(|l| l.path_prefix == "/positions")
954 .unwrap();
955 let closed = limits
956 .iter()
957 .find(|l| l.path_prefix == "/closed-positions")
958 .unwrap();
959
960 assert_eq!(positions.match_mode, MatchMode::Prefix);
961 assert_eq!(closed.match_mode, MatchMode::Prefix);
962
963 assert!(
965 !"/closed-positions".starts_with(positions.path_prefix),
966 "/closed-positions should not match /positions prefix"
967 );
968 }
969
970 #[test]
971 fn test_all_clob_endpoints_have_match_mode() {
972 let rl = RateLimiter::clob_default();
973 for limit in &rl.inner.limits {
974 assert!(
976 limit.match_mode == MatchMode::Prefix || limit.match_mode == MatchMode::Exact,
977 "endpoint {} has no valid match mode",
978 limit.path_prefix
979 );
980 }
981 }
982
983 #[tokio::test]
986 async fn test_acquire_concurrent_tasks_all_complete() {
987 let rl = RateLimiter::clob_default(); let rl = std::sync::Arc::new(rl);
990
991 let mut handles = Vec::new();
992 for _ in 0..10 {
993 let rl = rl.clone();
994 handles.push(tokio::spawn(async move {
995 rl.acquire("/markets", None).await;
996 }));
997 }
998
999 let start = std::time::Instant::now();
1000 for handle in handles {
1001 handle.await.unwrap();
1002 }
1003 assert!(
1005 start.elapsed() < Duration::from_millis(100),
1006 "concurrent acquires took too long: {:?}",
1007 start.elapsed()
1008 );
1009 }
1010
1011 #[tokio::test]
1012 async fn test_acquire_concurrent_different_endpoints() {
1013 let rl = std::sync::Arc::new(RateLimiter::clob_default());
1015
1016 let rl1 = rl.clone();
1017 let rl2 = rl.clone();
1018 let rl3 = rl.clone();
1019
1020 let start = std::time::Instant::now();
1021 let (r1, r2, r3) = tokio::join!(
1022 tokio::spawn(async move { rl1.acquire("/markets", None).await }),
1023 tokio::spawn(async move { rl2.acquire("/auth", None).await }),
1024 tokio::spawn(async move { rl3.acquire("/order", Some(&Method::POST)).await }),
1025 );
1026 r1.unwrap();
1027 r2.unwrap();
1028 r3.unwrap();
1029
1030 assert!(
1031 start.elapsed() < Duration::from_millis(50),
1032 "different endpoints should not block: {:?}",
1033 start.elapsed()
1034 );
1035 }
1036
1037 #[test]
1040 fn test_clob_post_order_has_dual_window() {
1041 let rl = RateLimiter::clob_default();
1042 let post_order = rl
1043 .inner
1044 .limits
1045 .iter()
1046 .find(|l| l.path_prefix == "/order" && l.method == Some(Method::POST))
1047 .expect("POST /order endpoint should exist");
1048
1049 assert_eq!(
1050 post_order.buckets.len(),
1051 2,
1052 "POST /order should have a burst and a sustained window"
1053 );
1054 }
1055
1056 #[test]
1057 fn test_clob_delete_order_has_a_sustained_window_too() {
1058 let rl = RateLimiter::clob_default();
1062 let delete_order = rl
1063 .inner
1064 .limits
1065 .iter()
1066 .find(|l| l.path_prefix == "/order" && l.method == Some(Method::DELETE))
1067 .expect("DELETE /order endpoint should exist");
1068
1069 assert_eq!(
1070 delete_order.buckets.len(),
1071 2,
1072 "DELETE /order should have both a burst and a sustained window"
1073 );
1074 }
1075
1076 #[tokio::test]
1077 async fn test_dual_window_both_burst_and_sustained_are_awaited() {
1078 let rl = RateLimiter::clob_default();
1081 let start = std::time::Instant::now();
1082 rl.acquire("/order", Some(&Method::POST)).await;
1083 assert!(
1084 start.elapsed() < Duration::from_millis(50),
1085 "dual window single acquire should be fast: {:?}",
1086 start.elapsed()
1087 );
1088 }
1089
1090 #[test]
1093 fn test_should_retry_exhaustion() {
1094 let client = crate::HttpClientBuilder::new("https://example.com")
1096 .with_retry_config(RetryConfig {
1097 max_retries: 3,
1098 ..RetryConfig::default()
1099 })
1100 .build()
1101 .unwrap();
1102
1103 for attempt in 0..3 {
1105 assert!(
1106 client
1107 .should_retry(reqwest::StatusCode::TOO_MANY_REQUESTS, attempt, None)
1108 .is_some(),
1109 "attempt {attempt} should allow retry"
1110 );
1111 }
1112 assert!(
1114 client
1115 .should_retry(reqwest::StatusCode::TOO_MANY_REQUESTS, 3, None)
1116 .is_none(),
1117 "attempt 3 should exhaust retries"
1118 );
1119 }
1120
1121 #[test]
1122 fn test_should_retry_zero_max_retries_never_retries() {
1123 let client = crate::HttpClientBuilder::new("https://example.com")
1124 .with_retry_config(RetryConfig {
1125 max_retries: 0,
1126 ..RetryConfig::default()
1127 })
1128 .build()
1129 .unwrap();
1130
1131 assert!(
1132 client
1133 .should_retry(reqwest::StatusCode::TOO_MANY_REQUESTS, 0, None)
1134 .is_none(),
1135 "max_retries=0 should never retry"
1136 );
1137 }
1138}