Skip to main content

polyoxide_core/
rate_limit.rs

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/// How an endpoint pattern should be matched against request paths.
15#[derive(Debug, Clone, Copy, PartialEq, Eq)]
16#[allow(dead_code)]
17enum MatchMode {
18    /// Match if the path starts with the pattern followed by a segment
19    /// boundary (`/`, `?`, or end-of-string). Prevents `/price` from
20    /// matching `/prices-history`.
21    Prefix,
22    /// Match only the exact path string.
23    Exact,
24}
25
26/// A quota as published by Polymarket: `count` requests per `period`.
27///
28/// Kept alongside the limiter so tests can assert the configured allowance
29/// against the documented table rather than merely checking an entry exists.
30#[derive(Debug, Clone, Copy, PartialEq, Eq)]
31struct RateSpec {
32    count: u32,
33    period: Duration,
34}
35
36/// One token bucket, shareable between endpoint patterns.
37///
38/// Sharing is what lets several paths sit under a single cap: upstream limits
39/// `/trades`, `/orders`, `/notifications` and `/order` to 900/10s *combined*,
40/// which four independent buckets would silently turn into 3,600/10s.
41struct Bucket {
42    /// The configured allowance. Read only by the agreement tests, which check
43    /// it against the published table.
44    #[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
58/// Rate limit configuration for a specific endpoint pattern.
59struct EndpointLimit {
60    path_prefix: &'static str,
61    method: Option<Method>,
62    match_mode: MatchMode,
63    /// Every bucket a matching request must pass, awaited in order.
64    buckets: Vec<Arc<Bucket>>,
65}
66
67impl EndpointLimit {
68    /// Whether this entry governs the given request.
69    ///
70    /// Shared by [`RateLimiter::acquire`] and the agreement tests so the two
71    /// cannot disagree about which rule applies.
72    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                // Ensure we're at a segment boundary, not a partial word match.
77                // "/price" should match "/price" and "/price/foo" but not "/prices-history".
78                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/// Holds all rate limiters for one API surface.
95///
96/// Created via factory methods like [`RateLimiter::clob_default()`] which
97/// configure hardcoded limits matching Polymarket's documented rate limits.
98#[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
116/// Helper to create a quota: `count` requests per `period`.
117///
118/// Uses `Quota::with_period` for exact rate enforcement rather than
119/// ceiling-based `per_second`, which can over-permit for non-round windows.
120fn 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
128/// Create an endpoint rate limit configuration from its own buckets.
129fn 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
142/// A single-window endpoint limit: `count` requests per `period`.
143fn 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
152/// A dual-window endpoint limit: a burst window plus a sustained window.
153fn 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    /// Await the appropriate limiter(s) for this endpoint.
171    ///
172    /// Always awaits the default (general) limiter, then additionally awaits
173    /// the first matching endpoint-specific limiter (burst + sustained).
174    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    /// The quotas a request would be held to, in the order they are awaited.
185    ///
186    /// Empty when nothing matches — meaning the request is governed only by the
187    /// general bucket, which is the shape every over-permit bug in this table
188    /// has taken.
189    #[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    /// CLOB API rate limits.
200    ///
201    /// Transcribed from <https://docs.polymarket.com/api-reference/rate-limits>
202    /// as fetched on 2026-07-25, and pinned by the `documented_limits` tests.
203    ///
204    /// Two things about the published tables need interpreting:
205    ///
206    /// - The **ledger group cap** (900/10s across `/trades`, `/orders`,
207    ///   `/notifications` and `/order`) is genuinely shared, so those entries
208    ///   hold clones of one shared bucket rather than four of their own.
209    /// - That group names `/order` and `/orders`, which also appear in the
210    ///   trading table at 5,000 and 2,000 per 10s. Both tables can only hold
211    ///   simultaneously if the group cap governs the ledger *reads*; a 900/10s
212    ///   cap on all methods would make the published trading burst
213    ///   unreachable. The group is therefore scoped to `GET`.
214    ///
215    /// Ordering matters wherever one pattern is a path-segment prefix of
216    /// another: `/balance-allowance/update` must precede `/balance-allowance`,
217    /// and the specific `/data/*` routes must precede the `/data` catch-all.
218    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        // Shared across the ledger read endpoints — one bucket, four patterns.
224        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                    // ── Account. The tighter /update route must come first:
231                    // it matches the /balance-allowance prefix at a boundary.
232                    simple_limit("/balance-allowance/update", None, 50, ten_sec),
233                    simple_limit("/balance-allowance", None, 200, ten_sec),
234                    // ── Trading (dual window: burst + sustained).
235                    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                    // ── Ledger reads, sharing one 900/10s bucket.
262                    // /notifications additionally carries its own 125/10s cap.
263                    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                    // Specific /data routes before the catch-all. The previous
272                    // pattern here was "/data/", which the segment-boundary
273                    // rule can never match — it was dead configuration.
274                    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                    // ── Auth (matches /auth/derive-api-key etc.)
278                    simple_limit("/auth", None, 100, ten_sec),
279                    // ── Market data. The batch forms are 3x tighter than their
280                    // singular siblings and do not match them: the boundary
281                    // rule means "/books" never resolves through "/book".
282                    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                    // ── Health.
291                    simple_limit("/ok", None, 100, ten_sec),
292                    // ── Not in the published table. These are local, deliberately
293                    // conservative caps kept from earlier revisions; they only
294                    // ever permit less than the general bucket would. Listed
295                    // last so no documented rule is shadowed by them.
296                    simple_limit("/markets", None, 1_500, ten_sec),
297                    simple_limit("/neg-risk", None, 1_500, ten_sec),
298                ],
299            }),
300        }
301    }
302
303    /// Gamma API rate limits.
304    ///
305    /// - General: 4,000/10s
306    /// - /events: 500/10s
307    /// - /markets: 300/10s
308    /// - /public-search: 350/10s
309    /// - /comments: 200/10s
310    /// - /tags: 200/10s
311    /// - `/ok`: 100/10s
312    ///
313    /// Upstream also lists a 900/10s cap shared by `/markets` + `/events`.
314    /// It is not modelled because it can never bind: the per-endpoint caps of
315    /// 300 and 500 sum to 800, which is already below it.
316    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    /// Data API rate limits.
335    ///
336    /// - General: 1,000/10s
337    /// - /trades: 200/10s
338    /// - /positions and /closed-positions: 150/10s
339    /// - `/ok`: 100/10s
340    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    /// Relay API rate limits.
357    ///
358    /// - 25 requests per 1 minute (single limiter, no endpoint-specific limits)
359    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/// Configuration for retry-on-429 with exponential backoff.
370#[derive(Debug, Clone)]
371pub struct RetryConfig {
372    /// Maximum number of retry attempts after the initial request (default: 3).
373    pub max_retries: u32,
374    /// Base backoff in milliseconds for the first retry, doubled each attempt (default: 500).
375    pub initial_backoff_ms: u64,
376    /// Upper bound in milliseconds for the backoff delay (default: 10_000).
377    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    /// Calculate backoff duration with jitter for attempt N.
392    ///
393    /// Uses `fastrand` for uniform jitter (75%-125% of base delay) to avoid
394    /// thundering herd when multiple clients retry simultaneously.
395    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        // Uniform jitter in 0.75..1.25 range
401        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    //! Table-driven agreement tests against Polymarket's published limits.
410    //!
411    //! Transcribed from <https://docs.polymarket.com/api-reference/rate-limits>
412    //! as fetched on 2026-07-25. These assert the *effective quota* a request
413    //! resolves to, not merely that some entry exists — the previous tests only
414    //! checked that entries were present and in the right order, which is why
415    //! `/balance-allowance` could be absent entirely while every test passed.
416
417    use super::*;
418
419    /// One published rule: the request it applies to, and the buckets it must
420    /// pass, as `(count, window_secs)` in the order `acquire` awaits them.
421    type DocumentedRule = (&'static str, Option<Method>, Vec<(u32, u64)>);
422
423    /// The published table, transcribed by hand. This is the golden vector.
424    fn documented() -> Vec<DocumentedRule> {
425        vec![
426            // ── Account ──
427            ("/balance-allowance", Some(Method::GET), vec![(200, 10)]),
428            (
429                "/balance-allowance/update",
430                Some(Method::GET),
431                vec![(50, 10)],
432            ),
433            // ── Trading (dual window) ──
434            (
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            // ── Ledger: a cap shared across the group, plus per-endpoint caps ──
465            ("/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            // ── Market data ──
476            ("/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 & health ──
485            ("/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        // `/books` must not resolve through the `/book` rule: they are
517        // different endpoints with a 3x difference in allowance.
518        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        // Upstream caps `/trades`, `/orders`, `/notifications` and `/order`
537        // at 900/10s *combined*. Modelling that as four independent 900/10s
538        // buckets would permit 3,600/10s.
539        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        // `/balance-allowance/update` starts with `/balance-allowance` at a
564        // segment boundary, so ordering decides which rule wins. The update
565        // route is four times tighter.
566        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        // Matching specs is not the same as enforcing them. `/tick-size` is
577        // 200/10s; the 201st request in a burst must wait for a token
578        // (200 per 10s replenishes one every ~50ms).
579        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        // The runtime counterpart to the Arc::ptr_eq check: draining the group
597        // through one endpoint must leave a *different* group member throttled.
598        // With four independent buckets this would return instantly.
599        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        // The ledger group names `/order`, but the trading table allows POST
618        // /order 5,000/10s. Both can only hold if the group cap is the ledger
619        // *read*. Applying it to POST would make the published burst
620        // unreachable.
621        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    // ── RetryConfig ──────────────────────────────────────────────
636
637    #[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        // base = 500 * 2^0 = 500, capped = 500, jitter in [0.75, 1.25]
650        // ms in [375, 625]
651        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        // Verify jitter isn't degenerate (all clustering at one end).
713        // Sample 200 values and check both halves of the range are hit.
714        let cfg = RetryConfig::default();
715        let midpoint = cfg.initial_backoff_ms; // 500ms (center of 375..625 range)
716        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    // ── quota() ──────────────────────────────────────────────────
732
733    #[test]
734    fn test_quota_creation() {
735        // Should not panic for representative values
736        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        // count=0 is guarded by .max(1) — should not panic
744        let _ = quota(0, Duration::from_secs(10));
745    }
746
747    // ── Factory methods ──────────────────────────────────────────
748
749    #[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    // ── Endpoint matching internals ──────────────────────────────
783
784    #[test]
785    fn test_clob_tighter_rules_precede_the_prefixes_that_would_shadow_them() {
786        // Ordering is only load-bearing where one pattern is a path-segment
787        // prefix of another. Asserting on fixed indices made this test brittle
788        // and told us nothing; assert the actual constraint instead.
789        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    // ── acquire() async behavior ─────────────────────────────────
811
812    #[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        // /order/123 should match the /order prefix
825        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        // Find the /price entry
835        let price_idx = limits
836            .iter()
837            .position(|l| l.path_prefix == "/price")
838            .expect("/price endpoint exists");
839
840        // /prices-history must NOT match /price — it's a different endpoint
841        let prices_history_idx = limits
842            .iter()
843            .position(|l| l.path_prefix == "/prices-history")
844            .expect("/prices-history endpoint exists");
845
846        // /prices-history should have its own entry, ordered before /price
847        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        // Verify the Prefix matching logic directly
856        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        // Should match: exact, sub-path, query params
866        assert!(check("/price"), "exact match");
867        assert!(check("/price/foo"), "sub-path");
868        assert!(check("/price?token=abc"), "query params");
869
870        // Should NOT match: partial word overlap
871        assert!(!check("/prices-history"), "partial word /prices-history");
872        assert!(!check("/pricelist"), "partial word /pricelist");
873        assert!(!check("/pricing"), "partial word /pricing");
874
875        // Should NOT match: different prefix
876        assert!(!check("/midpoint"), "different prefix");
877    }
878
879    #[test]
880    fn test_match_mode_exact() {
881        // Verify the Exact matching logic
882        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        // GET /order shouldn't match POST or DELETE /order endpoints — falls to default only
897        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        // /events has method: None — should match GET, POST, and None
914        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    // ── Prefix collision tests ──────────────────────────────────
921
922    #[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        // Both should use Prefix mode
934        assert_eq!(price.match_mode, MatchMode::Prefix);
935        assert_eq!(prices_history.match_mode, MatchMode::Prefix);
936
937        // Verify "/prices-history" does NOT match the "/price" pattern
938        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        // "/closed-positions" does NOT start with "/positions"
964        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            // Every endpoint should have an explicit match mode
975            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    // ── Concurrent access tests ─────────────────────────────────
984
985    #[tokio::test]
986    async fn test_acquire_concurrent_tasks_all_complete() {
987        // A rate limiter with high burst should allow many concurrent acquires
988        let rl = RateLimiter::clob_default(); // 9000/10s general
989        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        // 10 concurrent acquires against a 9000/10s limiter should complete fast
1004        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        // Concurrent tasks hitting different endpoints should not block each other
1014        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    // ── Dual-window interaction tests ───────────────────────────
1038
1039    #[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        // This previously asserted the *opposite* — that DELETE /order had only
1059        // a burst window — and so pinned the omission in place. Upstream
1060        // publishes 5,000/10s burst plus 120,000/10min sustained.
1061        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        // POST /order should await both burst and sustained limiters.
1079        // With high limits, a single acquire should still complete fast.
1080        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    // ── should_retry edge cases ─────────────────────────────────
1091
1092    #[test]
1093    fn test_should_retry_exhaustion() {
1094        // After max_retries, should_retry must return None
1095        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        // Attempts 0, 1, 2 should succeed
1104        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        // Attempt 3 should give up
1113        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}