Skip to main content

polyoxide_core/
rate_limit.rs

1use std::sync::{Arc, Mutex};
2use std::time::Duration;
3
4use governor::Quota;
5use reqwest::Method;
6use tokio::time::Instant;
7
8type DirectLimiter = governor::RateLimiter<
9    governor::state::NotKeyed,
10    governor::state::InMemoryState,
11    governor::clock::DefaultClock,
12>;
13
14/// 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    /// Deadline before which no request on this limiter may proceed.
115    ///
116    /// The buckets above encode the quota Polymarket *publishes*; this encodes
117    /// what the server actually just said. They disagree more often than the
118    /// tables suggest — Cloudflare's `error code: 1015` is an IP-scoped block
119    /// with its own window, and it answers 429 no matter how many tokens the
120    /// buckets still hold.
121    cooldown_until: Mutex<Option<Instant>>,
122}
123
124/// Helper to create a quota: at most `count` requests in *any* window of
125/// length `period`.
126///
127/// **There is deliberately no `allow_burst` call here.** `Quota::with_period`
128/// leaves capacity at a single token, and keeping it there is the entire point.
129/// A token bucket admits its depth *plus* everything the refill adds, so across
130/// a window of length `period` it lets through `burst + rate × period`. Funding
131/// a burst of `count` on top of a rate of `count/period` spends the published
132/// allowance twice — which is what this function did for every entry in every
133/// table until it was measured.
134///
135/// Depth is not free capacity; it is borrowed against the rate. Satisfying the
136/// bound with a burst of `B` costs `B` requests of sustained allowance forever,
137/// so the minimum depth is also the maximum throughput: 149/10s here rather
138/// than the 135/10s a 10% burst would leave. It is the safer shape too — the
139/// client never concentrates requests into an instant, including on release
140/// from a cooldown, when every parked request resumes at once and a burst
141/// allowance would fire them as a spike immediately after a ban.
142///
143/// `count < 2` degenerates to admitting 2 per window, since a bucket cannot
144/// hold less than one token. No published row is that small.
145///
146/// # Why it aims below the published count
147///
148/// Because the published count turns out not to be reachable as a rate.
149/// Measured against `data-api.polymarket.com` on `/closed-positions`, which
150/// publishes 150/10s:
151///
152/// | Sustained rate | Share of published | Result |
153/// |---|---|---|
154/// | 14.9/s (149 per 10s) | 100% | refused after 15.7s |
155/// | 14.25/s (142.5 per 10s) | 95% | refused after 17.3s |
156/// | 13.5/s (135 per 10s) | 90% | clean over 180s, 2,430 requests |
157///
158/// A one-shot burst of exactly 150 *is* accepted, so this is not the table
159/// overstating the cap: the count is reachable as a burst and not as a rate.
160/// Cloudflare's sliding-window estimator does not count the way a naive
161/// interval count does, and nothing outside the server can observe the
162/// difference — so the only safe response is to aim below the line rather than
163/// at it. [`RESERVED_FRACTION`] is that margin, measured rather than
164/// conventional: 95% is known-refused, 90% is known-clean.
165fn quota(count: u32, period: Duration) -> Quota {
166    Quota::with_period(period / sustained_slots(count)).expect("quota interval must be non-zero")
167}
168
169/// Slots per `period` the client actually paces out for a published `count`:
170/// the count, less its reserve, less the single token of depth.
171///
172/// Shared with the runtime agreement tests so their expected pacing cannot
173/// drift from what [`quota`] builds. They would still catch a request routed to
174/// the wrong bucket — a different `count` yields a different interval — but a
175/// hand-copied formula here would silently loosen them the next time the
176/// reserve changes.
177fn sustained_slots(count: u32) -> u32 {
178    let target = count.saturating_sub(count.div_ceil(RESERVED_FRACTION));
179    target.max(2) - 1
180}
181
182/// Reciprocal of the share of each published quota the client leaves unused:
183/// `10` reserves a tenth, so the client targets 90%.
184///
185/// Measured, not chosen — see the table on [`quota`].
186const RESERVED_FRACTION: u32 = 10;
187
188#[cfg(test)]
189mod quota_arithmetic {
190    //! The bound every bucket has to satisfy, checked as arithmetic.
191    //!
192    //! `agreement::assert_throttles_after` pins a bucket's *depth*: drain
193    //! `count` and the next call has to wait, so capacity is no larger than the
194    //! published figure. It says nothing about the refill rate, and depth and
195    //! rate are two separate spends of one budget. A bucket holding `count`
196    //! tokens that also replenishes `count` per `period` passes that test and
197    //! still admits `2 * count` in a single window — each assertion true, the
198    //! conjunction they exist to guarantee false.
199    //!
200    //! Measured against the live host on `/closed-positions` (150/10s): a
201    //! one-shot burst of exactly 150 in 0.70s is accepted, while sustained runs
202    //! tripped Cloudflare's `error code: 1015` at ~152 cumulative requests —
203    //! twice, at different rates, which is the signature of a cumulative cap
204    //! rather than a rate one. Upstream's published figure is accurate in both
205    //! count and window; the client was spending it twice.
206
207    use super::*;
208
209    /// Requests `q` admits in the worst-case window of length `period`: the
210    /// full bucket drained at `t=0`, plus every token the refill adds by
211    /// `t=period`.
212    ///
213    /// This is the quantity the published table bounds. Buckets start full, so
214    /// the worst case is always a fresh limiter.
215    fn admitted_in_one_window(q: &Quota, period: Duration) -> u128 {
216        let refilled = period.as_nanos() / q.replenish_interval().as_nanos();
217        u128::from(q.burst_size().get()) + refilled
218    }
219
220    /// The four general-purpose default buckets, plus a spread of endpoint
221    /// shapes for good measure.
222    ///
223    /// The defaults are the reason this list exists at all: they are built as
224    /// bare `DirectLimiter`s carrying no `RateSpec`, so the sweep below — which
225    /// walks the configured tables — cannot see them, and they are the largest
226    /// allowance on every surface.
227    const PUBLISHED_SHAPES: &[(u32, u64)] = &[
228        (9_000, 10),    // clob default
229        (4_000, 10),    // gamma default
230        (1_000, 10),    // data default / clob /prices-history
231        (25, 60),       // relay default
232        (150, 10),      // data /closed-positions, /positions
233        (200, 10),      // data /trades, clob /balance-allowance
234        (300, 10),      // gamma /markets
235        (350, 10),      // gamma /public-search
236        (500, 10),      // gamma /events
237        (100, 10),      // health routes
238        (50, 10),       // clob /balance-allowance/update
239        (5_000, 10),    // clob /order burst window
240        (120_000, 600), // clob /order sustained window
241    ];
242
243    #[test]
244    fn no_quota_admits_more_than_its_published_count_in_one_window() {
245        for &(count, secs) in PUBLISHED_SHAPES {
246            let period = Duration::from_secs(secs);
247            let q = quota(count, period);
248            let admitted = admitted_in_one_window(&q, period);
249
250            assert!(
251                admitted <= u128::from(count),
252                "{count}/{secs}s admits {admitted} in one window \
253                 ({} burst + {} refilled) — the published quota is spent twice",
254                q.burst_size(),
255                admitted - u128::from(q.burst_size().get()),
256            );
257        }
258    }
259
260    #[test]
261    fn every_quota_reserves_headroom_below_the_published_count() {
262        // Satisfying the published count exactly is not enough, because the
263        // published count is not actually reachable. Measured on
264        // `/closed-positions` (150/10s) against the live host: a sustained
265        // 142.5/10s — 95% — was refused after 17.3s, and the client's own
266        // exactly-100% pacing was refused after 15.7s, while 135/10s ran clean.
267        // Cloudflare's sliding-window estimator does not count the way a naive
268        // interval count does, and the client cannot observe the difference, so
269        // it aims below the line rather than at it.
270        for &(count, secs) in PUBLISHED_SHAPES {
271            let period = Duration::from_secs(secs);
272            let admitted = admitted_in_one_window(&quota(count, period), period);
273            let ceiling = u128::from(count - count.div_ceil(RESERVED_FRACTION));
274
275            assert!(
276                admitted <= ceiling,
277                "{count}/{secs}s admits {admitted} in one window, above the {ceiling} \
278                 the reserve allows — no headroom under the published cap"
279            );
280        }
281    }
282
283    #[test]
284    fn every_configured_bucket_satisfies_the_quota_it_publishes() {
285        for (surface, rl) in [
286            ("clob", RateLimiter::clob_default()),
287            ("gamma", RateLimiter::gamma_default()),
288            ("data", RateLimiter::data_default()),
289            ("relay", RateLimiter::relay_default()),
290        ] {
291            for limit in &rl.inner.limits {
292                for bucket in &limit.buckets {
293                    let RateSpec { count, period } = bucket.spec;
294                    let admitted = admitted_in_one_window(&quota(count, period), period);
295
296                    assert!(
297                        admitted <= u128::from(count),
298                        "{surface} {} is published as {count}/{period:?} but admits \
299                         {admitted} in one window",
300                        limit.path_prefix,
301                    );
302                }
303            }
304        }
305    }
306}
307
308/// Create an endpoint rate limit configuration from its own buckets.
309fn endpoint_limit(
310    path_prefix: &'static str,
311    method: Option<Method>,
312    buckets: Vec<Arc<Bucket>>,
313) -> EndpointLimit {
314    EndpointLimit {
315        path_prefix,
316        method,
317        match_mode: MatchMode::Prefix,
318        buckets,
319    }
320}
321
322/// A single-window endpoint limit: `count` requests per `period`.
323fn simple_limit(
324    path_prefix: &'static str,
325    method: Option<Method>,
326    count: u32,
327    period: Duration,
328) -> EndpointLimit {
329    endpoint_limit(path_prefix, method, vec![Bucket::new(count, period)])
330}
331
332/// A dual-window endpoint limit: a burst window plus a sustained window.
333fn dual_limit(
334    path_prefix: &'static str,
335    method: Method,
336    burst: (u32, Duration),
337    sustained: (u32, Duration),
338) -> EndpointLimit {
339    endpoint_limit(
340        path_prefix,
341        Some(method),
342        vec![
343            Bucket::new(burst.0, burst.1),
344            Bucket::new(sustained.0, sustained.1),
345        ],
346    )
347}
348
349impl RateLimiter {
350    /// Hold every request on this limiter for `delay`.
351    ///
352    /// Extends an existing cooldown but never shortens one: several concurrent
353    /// requests typically see the same 429 within a few milliseconds of each
354    /// other, and taking the most recent value would let whichever response
355    /// carried the smallest delay release all of them early.
356    ///
357    /// Prefer [`HttpClient::note_rate_limited`](crate::HttpClient::note_rate_limited),
358    /// which derives the delay from the response. Reach for this directly only
359    /// when driving the limiter from a transport this crate does not own.
360    pub fn begin_cooldown(&self, delay: Duration) {
361        let until = Instant::now() + delay;
362        let mut slot = self.lock_cooldown();
363        if slot.is_none_or(|current| until > current) {
364            *slot = Some(until);
365        }
366    }
367
368    /// A poison-tolerant lock on the cooldown slot.
369    ///
370    /// A panic elsewhere must not turn the rate limiter into a permanent
371    /// outage; the worst a torn write can cost here is one early or late
372    /// wakeup.
373    fn lock_cooldown(&self) -> std::sync::MutexGuard<'_, Option<Instant>> {
374        self.inner
375            .cooldown_until
376            .lock()
377            .unwrap_or_else(|poisoned| poisoned.into_inner())
378    }
379
380    /// Wait out any cooldown currently in force.
381    async fn await_cooldown(&self) {
382        loop {
383            // Read the deadline and release the guard before awaiting. Holding
384            // a `std::sync::MutexGuard` across an await makes the future
385            // `!Send`, which every caller of `acquire` needs it to be.
386            let deadline = *self.lock_cooldown();
387            let Some(deadline) = deadline else { return };
388            if deadline <= Instant::now() {
389                return;
390            }
391            // Loop rather than return after sleeping: a sibling's 429 can push
392            // the deadline out while we wait, and waking into a still-active
393            // block is how the storm restarts.
394            tokio::time::sleep_until(deadline).await;
395        }
396    }
397
398    /// Await the appropriate limiter(s) for this endpoint.
399    ///
400    /// Waits out any cooldown a previous 429 imposed, then awaits the default
401    /// (general) limiter, then additionally awaits the first matching
402    /// endpoint-specific limiter (burst + sustained).
403    pub async fn acquire(&self, path: &str, method: Option<&Method>) {
404        self.await_cooldown().await;
405        self.inner.default.until_ready().await;
406
407        if let Some(limit) = self.inner.limits.iter().find(|l| l.matches(path, method)) {
408            for bucket in &limit.buckets {
409                bucket.limiter.until_ready().await;
410            }
411        }
412    }
413
414    /// The quotas a request would be held to, in the order they are awaited.
415    ///
416    /// Empty when nothing matches — meaning the request is governed only by the
417    /// general bucket, which is the shape every over-permit bug in this table
418    /// has taken.
419    #[cfg(test)]
420    fn resolve_specs(&self, path: &str, method: Option<&Method>) -> Vec<RateSpec> {
421        self.inner
422            .limits
423            .iter()
424            .find(|l| l.matches(path, method))
425            .map(|l| l.buckets.iter().map(|b| b.spec).collect())
426            .unwrap_or_default()
427    }
428
429    /// CLOB API rate limits.
430    ///
431    /// Transcribed from <https://docs.polymarket.com/api-reference/rate-limits>
432    /// as fetched on 2026-07-25, and pinned by the `documented_limits` tests.
433    ///
434    /// Two things about the published tables need interpreting:
435    ///
436    /// - The **ledger group cap** (900/10s across `/trades`, `/orders`,
437    ///   `/notifications` and `/order`) is genuinely shared, so those entries
438    ///   hold clones of one shared bucket rather than four of their own.
439    /// - That group names `/order` and `/orders`, which also appear in the
440    ///   trading table at 5,000 and 2,000 per 10s. Both tables can only hold
441    ///   simultaneously if the group cap governs the ledger *reads*; a 900/10s
442    ///   cap on all methods would make the published trading burst
443    ///   unreachable. The group is therefore scoped to `GET`.
444    ///
445    /// Ordering matters wherever one pattern is a path-segment prefix of
446    /// another: `/balance-allowance/update` must precede `/balance-allowance`,
447    /// and the specific `/data/*` routes must precede the `/data` catch-all.
448    pub fn clob_default() -> Self {
449        let ten_sec = Duration::from_secs(10);
450        let ten_min = Duration::from_secs(600);
451        let get = Some(Method::GET);
452
453        // Shared across the ledger read endpoints — one bucket, four patterns.
454        let ledger_group = Bucket::new(900, ten_sec);
455
456        Self {
457            inner: Arc::new(RateLimiterInner {
458                default: DirectLimiter::direct(quota(9_000, ten_sec)),
459                cooldown_until: Mutex::new(None),
460                limits: vec![
461                    // ── Account. The tighter /update route must come first:
462                    // it matches the /balance-allowance prefix at a boundary.
463                    simple_limit("/balance-allowance/update", None, 50, ten_sec),
464                    simple_limit("/balance-allowance", None, 200, ten_sec),
465                    // ── Trading (dual window: burst + sustained).
466                    dual_limit("/order", Method::POST, (5_000, ten_sec), (120_000, ten_min)),
467                    dual_limit(
468                        "/order",
469                        Method::DELETE,
470                        (5_000, ten_sec),
471                        (120_000, ten_min),
472                    ),
473                    dual_limit("/orders", Method::POST, (2_000, ten_sec), (21_000, ten_min)),
474                    dual_limit(
475                        "/orders",
476                        Method::DELETE,
477                        (2_000, ten_sec),
478                        (15_000, ten_min),
479                    ),
480                    dual_limit(
481                        "/cancel-all",
482                        Method::DELETE,
483                        (250, ten_sec),
484                        (6_000, ten_min),
485                    ),
486                    dual_limit(
487                        "/cancel-market-orders",
488                        Method::DELETE,
489                        (1_500, ten_sec),
490                        (21_000, ten_min),
491                    ),
492                    // ── Ledger reads, sharing one 900/10s bucket.
493                    // /notifications additionally carries its own 125/10s cap.
494                    endpoint_limit(
495                        "/notifications",
496                        None,
497                        vec![ledger_group.clone(), Bucket::new(125, ten_sec)],
498                    ),
499                    endpoint_limit("/trades", get.clone(), vec![ledger_group.clone()]),
500                    endpoint_limit("/orders", get.clone(), vec![ledger_group.clone()]),
501                    endpoint_limit("/order", get.clone(), vec![ledger_group]),
502                    // Specific /data routes before the catch-all. The previous
503                    // pattern here was "/data/", which the segment-boundary
504                    // rule can never match — it was dead configuration.
505                    simple_limit("/data/orders", None, 500, ten_sec),
506                    simple_limit("/data/trades", None, 500, ten_sec),
507                    simple_limit("/data", None, 500, ten_sec),
508                    // ── Auth (matches /auth/derive-api-key etc.)
509                    simple_limit("/auth", None, 100, ten_sec),
510                    // ── Market data. The batch forms are 3x tighter than their
511                    // singular siblings and do not match them: the boundary
512                    // rule means "/books" never resolves through "/book".
513                    simple_limit("/prices-history", None, 1_000, ten_sec),
514                    simple_limit("/book", None, 1_500, ten_sec),
515                    simple_limit("/books", None, 500, ten_sec),
516                    simple_limit("/price", None, 1_500, ten_sec),
517                    simple_limit("/prices", None, 500, ten_sec),
518                    simple_limit("/midpoint", None, 1_500, ten_sec),
519                    simple_limit("/midpoints", None, 500, ten_sec),
520                    simple_limit("/tick-size", None, 200, ten_sec),
521                    // ── Health.
522                    simple_limit("/ok", None, 100, ten_sec),
523                    // ── Not in the published table. These are local, deliberately
524                    // conservative caps kept from earlier revisions; they only
525                    // ever permit less than the general bucket would. Listed
526                    // last so no documented rule is shadowed by them.
527                    simple_limit("/markets", None, 1_500, ten_sec),
528                    simple_limit("/neg-risk", None, 1_500, ten_sec),
529                ],
530            }),
531        }
532    }
533
534    /// Gamma API rate limits.
535    ///
536    /// - General: 4,000/10s
537    /// - /events: 500/10s
538    /// - /markets: 300/10s
539    /// - /public-search: 350/10s
540    /// - /comments: 200/10s
541    /// - /tags: 200/10s
542    /// - `/status` (health): 100/10s
543    ///
544    /// Upstream also lists a 900/10s cap shared by `/markets` + `/events`.
545    /// It is not modelled because it can never bind: the per-endpoint caps of
546    /// 300 and 500 sum to 800, which is already below it.
547    ///
548    /// The published table spells the health row `/ok`, but that path answers
549    /// **404** on `gamma-api.polymarket.com` — `/status` is the route that
550    /// answers 200, and the one `Gamma::health().ping()` requests. `/ok` is
551    /// boilerplate repeated into every surface's table; only the CLOB host
552    /// serves it.
553    pub fn gamma_default() -> Self {
554        let ten_sec = Duration::from_secs(10);
555
556        Self {
557            inner: Arc::new(RateLimiterInner {
558                default: DirectLimiter::direct(quota(4_000, ten_sec)),
559                cooldown_until: Mutex::new(None),
560                limits: vec![
561                    simple_limit("/comments", None, 200, ten_sec),
562                    simple_limit("/tags", None, 200, ten_sec),
563                    simple_limit("/markets", None, 300, ten_sec),
564                    simple_limit("/public-search", None, 350, ten_sec),
565                    simple_limit("/events", None, 500, ten_sec),
566                    simple_limit("/status", None, 100, ten_sec),
567                ],
568            }),
569        }
570    }
571
572    /// Data API rate limits.
573    ///
574    /// - General: 1,000/10s
575    /// - /trades: 200/10s
576    /// - /positions and /closed-positions: 150/10s
577    /// - `/` (health): 100/10s
578    ///
579    /// The published table spells the health row `/ok`, but that path answers
580    /// **404** on `data-api.polymarket.com` — `/` answers 200 `{"data":"OK"}`,
581    /// and is the route this crate requests. `/ok` is boilerplate repeated into
582    /// every surface's table; only the CLOB host serves it.
583    ///
584    /// Matching `/` is safe despite entries being prefix-matched: the
585    /// segment-boundary rule means `strip_prefix("/")` on `/positions` leaves
586    /// `positions`, which starts with neither `/` nor `?`, so the entry matches
587    /// only the bare root and the root with a query string.
588    ///
589    /// This limiter is shared with the two sibling hosts, so it also carries
590    /// their rules:
591    ///
592    /// - `/user-pnl`: 200/10s, published as the *host-wide* allowance for
593    ///   `user-pnl-api.polymarket.com`. Modelled per-path because it is the
594    ///   only route polyoxide calls there and matching has no host dimension.
595    /// - `lb-api.polymarket.com` (`/volume`, `/profit`) has no published limit,
596    ///   so those fall to the general bucket.
597    pub fn data_default() -> Self {
598        let ten_sec = Duration::from_secs(10);
599
600        Self {
601            inner: Arc::new(RateLimiterInner {
602                default: DirectLimiter::direct(quota(1_000, ten_sec)),
603                cooldown_until: Mutex::new(None),
604                limits: vec![
605                    simple_limit("/closed-positions", None, 150, ten_sec),
606                    simple_limit("/positions", None, 150, ten_sec),
607                    simple_limit("/trades", None, 200, ten_sec),
608                    simple_limit("/user-pnl", None, 200, ten_sec),
609                    simple_limit("/", None, 100, ten_sec),
610                ],
611            }),
612        }
613    }
614
615    /// Relay API rate limits.
616    ///
617    /// - 25 requests per 1 minute (single limiter, no endpoint-specific limits)
618    pub fn relay_default() -> Self {
619        Self {
620            inner: Arc::new(RateLimiterInner {
621                default: DirectLimiter::direct(quota(25, Duration::from_secs(60))),
622                cooldown_until: Mutex::new(None),
623                limits: vec![],
624            }),
625        }
626    }
627}
628
629/// Configuration for retry-on-429 with exponential backoff.
630#[derive(Debug, Clone)]
631pub struct RetryConfig {
632    /// Maximum number of retry attempts after the initial request (default: 3).
633    pub max_retries: u32,
634    /// Base backoff in milliseconds for the first retry, doubled each attempt (default: 500).
635    pub initial_backoff_ms: u64,
636    /// Upper bound in milliseconds for the backoff delay (default: 10_000).
637    pub max_backoff_ms: u64,
638}
639
640impl Default for RetryConfig {
641    fn default() -> Self {
642        Self {
643            max_retries: 3,
644            initial_backoff_ms: 500,
645            max_backoff_ms: 10_000,
646        }
647    }
648}
649
650impl RetryConfig {
651    /// Calculate backoff duration with jitter for attempt N.
652    ///
653    /// Uses `fastrand` for uniform jitter (75%-125% of base delay) to avoid
654    /// thundering herd when multiple clients retry simultaneously.
655    pub fn backoff(&self, attempt: u32) -> Duration {
656        let base = self
657            .initial_backoff_ms
658            .saturating_mul(1u64 << attempt.min(10));
659        let capped = base.min(self.max_backoff_ms);
660        // Uniform jitter in 0.75..1.25 range
661        let jitter_factor = 0.75 + (fastrand::f64() * 0.5);
662        let ms = (capped as f64 * jitter_factor) as u64;
663        Duration::from_millis(ms.max(1))
664    }
665}
666
667#[cfg(test)]
668mod agreement {
669    //! Shared machinery for the per-surface `documented_*_limits` modules.
670    //!
671    //! Every API surface pins its published table the same way: assert the
672    //! *effective quota* a request resolves to, not merely that some entry
673    //! exists. Checking only for presence and ordering is why
674    //! `/balance-allowance` could once be absent entirely while every test
675    //! passed, and why `/closed-positions` could be set to 66x its published
676    //! cap without a single failure.
677
678    use super::*;
679
680    /// One published rule: the request it applies to, and the buckets it must
681    /// pass, as `(count, window_secs)` in the order `acquire` awaits them.
682    pub type DocumentedRule = (&'static str, Option<Method>, Vec<(u32, u64)>);
683
684    /// Assert every rule resolves to exactly the quota Polymarket publishes.
685    ///
686    /// `general` is the surface's catch-all allowance. It is only used to make
687    /// the failure message name the over-permit factor, since falling through
688    /// to the general bucket is the shape every bug in these tables has taken.
689    pub fn assert_matches_published(rl: &RateLimiter, rules: Vec<DocumentedRule>, general: u32) {
690        for (path, method, expected) in rules {
691            let resolved = rl.resolve_specs(path, method.as_ref());
692            assert!(
693                !resolved.is_empty(),
694                "{method:?} {path} matches no endpoint limit — it falls through to the \
695                 general {general}/10s bucket, over-permitting by {}x",
696                general / expected[0].0.max(1),
697            );
698            let actual: Vec<(u32, u64)> = resolved
699                .iter()
700                .map(|s| (s.count, s.period.as_secs()))
701                .collect();
702            assert_eq!(
703                actual, expected,
704                "{method:?} {path} resolves to {actual:?}, published limit is {expected:?}"
705            );
706        }
707    }
708
709    /// Assert `path` is governed by nothing but the general bucket.
710    ///
711    /// Used to pin routes that upstream's table names but the host does not
712    /// actually serve, so a dead entry cannot quietly reappear.
713    pub fn assert_unconfigured(rl: &RateLimiter, path: &str) {
714        assert!(
715            rl.resolve_specs(path, Some(&Method::GET)).is_empty(),
716            "{path} has an endpoint limit configured, but the host answers 404 there — \
717             the entry is dead configuration and the real route is going unlimited"
718        );
719    }
720
721    /// Assert `path` is paced at runtime by the quota it publishes.
722    ///
723    /// Matching a spec is not the same as enforcing it; this is the runtime
724    /// half of the agreement. Buckets hold a single token, so one request
725    /// empties `path`'s bucket and the next has to wait a full replenish
726    /// interval — no `count`-sized drain required, and none wanted: draining
727    /// `count` under uniform pacing takes a real `period`, which would put a
728    /// 10-second sleep in the unit suite for every row asserted.
729    ///
730    /// Asserting *how long* the wait is, rather than merely that there was
731    /// one, is what makes this specific. The delay identifies which bucket the
732    /// request came from: `/closed-positions` (150/10s) paces at ~67ms while
733    /// the surface's general bucket paces at ~10ms. The upper bound catches
734    /// the inverse failure — a path resolving through some tighter rule that
735    /// shadows it, which is the shape every ordering bug in these tables has
736    /// taken.
737    pub async fn assert_paced_by_its_own_quota(
738        rl: &RateLimiter,
739        path: &str,
740        count: u32,
741        period: Duration,
742    ) {
743        let interval = period / sustained_slots(count);
744
745        rl.acquire(path, Some(&Method::GET)).await;
746
747        let start = std::time::Instant::now();
748        rl.acquire(path, Some(&Method::GET)).await;
749        let waited = start.elapsed();
750
751        assert!(
752            waited >= interval.mul_f64(0.8),
753            "the 2nd request to {path} returned in {waited:?}; {count}/{period:?} should pace \
754             it at {interval:?} and the cap is not being enforced"
755        );
756        assert!(
757            waited <= interval * 3 + Duration::from_millis(25),
758            "the 2nd request to {path} waited {waited:?}, far longer than the {interval:?} its \
759             published {count}/{period:?} implies — it is resolving through a tighter rule"
760        );
761    }
762}
763
764#[cfg(test)]
765mod documented_data_limits {
766    //! Agreement tests for the Data API's published table.
767    //!
768    //! Transcribed from <https://docs.polymarket.com/api-reference/rate-limits>
769    //! as fetched on 2026-08-05.
770    //!
771    //! Two rows need interpreting, both verified against the live hosts:
772    //!
773    //! - The health row is published as `/ok`, but `data-api.polymarket.com/ok`
774    //!   answers **404** while `/` answers 200 `{"data":"OK"}`. The `/ok`
775    //!   spelling is boilerplate repeated into every surface's table; only
776    //!   `clob.polymarket.com` actually serves it. The cap is therefore
777    //!   attached to `/`, the route this crate requests and the host answers.
778    //! - "User PNL API 200 req/10s" is published as a *host-wide* allowance for
779    //!   `user-pnl-api.polymarket.com`. It is modelled as a path rule on
780    //!   `/user-pnl` because that is the only route polyoxide calls there and
781    //!   the limiter matches on path alone, with no host dimension.
782
783    use super::agreement::*;
784    use super::*;
785
786    /// The published table, transcribed by hand. This is the golden vector.
787    fn documented() -> Vec<DocumentedRule> {
788        vec![
789            ("/trades", Some(Method::GET), vec![(200, 10)]),
790            ("/positions", Some(Method::GET), vec![(150, 10)]),
791            ("/closed-positions", Some(Method::GET), vec![(150, 10)]),
792            ("/", Some(Method::GET), vec![(100, 10)]),
793            ("/user-pnl", Some(Method::GET), vec![(200, 10)]),
794        ]
795    }
796
797    #[test]
798    fn every_documented_endpoint_resolves_to_its_published_quota() {
799        assert_matches_published(&RateLimiter::data_default(), documented(), 1_000);
800    }
801
802    #[test]
803    fn the_health_cap_is_attached_to_the_route_the_host_answers_on() {
804        // `/ok` is a 404 on data-api. An entry there caps nothing and leaves
805        // the real health route — `/` — on the 10x-looser general bucket.
806        assert_unconfigured(&RateLimiter::data_default(), "/ok");
807    }
808
809    #[test]
810    fn the_root_health_rule_does_not_swallow_every_other_route() {
811        // `/` under prefix matching could plausibly match everything. The
812        // segment-boundary rule saves it: `strip_prefix("/")` on `/positions`
813        // leaves `positions`, which starts with neither `/` nor `?`.
814        let rl = RateLimiter::data_default();
815        for (path, expected) in [
816            ("/positions", 150),
817            ("/closed-positions", 150),
818            ("/trades", 200),
819            ("/", 100),
820        ] {
821            let specs = rl.resolve_specs(path, Some(&Method::GET));
822            assert_eq!(
823                specs[0].count, expected,
824                "{path} resolved through the wrong rule — the `/` entry is over-matching"
825            );
826        }
827    }
828
829    #[tokio::test]
830    async fn the_closed_positions_cap_actually_throttles() {
831        // 150/10s paces one request every ~67ms.
832        assert_paced_by_its_own_quota(
833            &RateLimiter::data_default(),
834            "/closed-positions",
835            150,
836            Duration::from_secs(10),
837        )
838        .await;
839    }
840
841    #[tokio::test]
842    async fn closed_positions_and_positions_do_not_share_an_allowance() {
843        // Upstream publishes 150/10s for each, not 150/10s combined. Emptying
844        // one must leave the other untouched — the inverse of the CLOB ledger
845        // group, where sharing *is* the published behaviour.
846        //
847        // The margin here is ~67ms (shared) against ~10ms (separate, and only
848        // that much because the request still passes the surface's general
849        // 1,000/10s bucket). Both sides of that gap are load-bearing, so the
850        // threshold sits between them rather than at zero.
851        let rl = RateLimiter::data_default();
852        rl.acquire("/closed-positions", Some(&Method::GET)).await;
853
854        let start = std::time::Instant::now();
855        rl.acquire("/positions", Some(&Method::GET)).await;
856        assert!(
857            start.elapsed() < Duration::from_millis(25),
858            "/positions was throttled by /closed-positions emptying its own bucket"
859        );
860    }
861}
862
863#[cfg(test)]
864mod documented_gamma_limits {
865    //! Agreement tests for the Gamma API's published table.
866    //!
867    //! Transcribed from <https://docs.polymarket.com/api-reference/rate-limits>
868    //! as fetched on 2026-08-05. As with the Data API, the published health row
869    //! reads `/ok`, but `gamma-api.polymarket.com/ok` answers 404 — `/status`
870    //! is the route that answers 200.
871
872    use super::agreement::*;
873    use super::*;
874
875    /// The published table, transcribed by hand. This is the golden vector.
876    fn documented() -> Vec<DocumentedRule> {
877        vec![
878            ("/events", Some(Method::GET), vec![(500, 10)]),
879            ("/public-search", Some(Method::GET), vec![(350, 10)]),
880            ("/markets", Some(Method::GET), vec![(300, 10)]),
881            ("/comments", Some(Method::GET), vec![(200, 10)]),
882            ("/tags", Some(Method::GET), vec![(200, 10)]),
883            ("/status", Some(Method::GET), vec![(100, 10)]),
884        ]
885    }
886
887    #[test]
888    fn every_documented_endpoint_resolves_to_its_published_quota() {
889        assert_matches_published(&RateLimiter::gamma_default(), documented(), 4_000);
890    }
891
892    #[test]
893    fn the_health_cap_is_attached_to_the_route_the_host_answers_on() {
894        assert_unconfigured(&RateLimiter::gamma_default(), "/ok");
895    }
896
897    #[test]
898    fn the_markets_plus_events_group_cap_can_never_bind() {
899        // Upstream also publishes a 900/10s cap shared by /markets + /events.
900        // It is deliberately not modelled because the per-endpoint caps sum to
901        // less than it. If either cap is ever raised, this stops being true and
902        // the group bucket has to be added — that is what this test watches.
903        let rl = RateLimiter::gamma_default();
904        let markets = rl.resolve_specs("/markets", Some(&Method::GET))[0].count;
905        let events = rl.resolve_specs("/events", Some(&Method::GET))[0].count;
906        assert!(
907            markets + events <= 900,
908            "/markets ({markets}) + /events ({events}) now exceeds the published 900/10s \
909             group cap, which is no longer unreachable and must be modelled"
910        );
911    }
912
913    #[tokio::test]
914    async fn the_markets_cap_actually_throttles() {
915        assert_paced_by_its_own_quota(
916            &RateLimiter::gamma_default(),
917            "/markets",
918            300,
919            Duration::from_secs(10),
920        )
921        .await;
922    }
923}
924
925#[cfg(test)]
926mod documented_limits {
927    //! Table-driven agreement tests against Polymarket's published limits.
928    //!
929    //! Transcribed from <https://docs.polymarket.com/api-reference/rate-limits>
930    //! as fetched on 2026-07-25, re-confirmed 2026-08-05. These assert the
931    //! *effective quota* a request resolves to, not merely that some entry
932    //! exists — the previous tests only checked that entries were present and
933    //! in the right order, which is why `/balance-allowance` could be absent
934    //! entirely while every test passed.
935
936    use super::agreement::{assert_paced_by_its_own_quota, DocumentedRule};
937    use super::*;
938
939    /// The published table, transcribed by hand. This is the golden vector.
940    fn documented() -> Vec<DocumentedRule> {
941        vec![
942            // ── Account ──
943            ("/balance-allowance", Some(Method::GET), vec![(200, 10)]),
944            (
945                "/balance-allowance/update",
946                Some(Method::GET),
947                vec![(50, 10)],
948            ),
949            // ── Trading (dual window) ──
950            (
951                "/order",
952                Some(Method::POST),
953                vec![(5_000, 10), (120_000, 600)],
954            ),
955            (
956                "/order",
957                Some(Method::DELETE),
958                vec![(5_000, 10), (120_000, 600)],
959            ),
960            (
961                "/orders",
962                Some(Method::POST),
963                vec![(2_000, 10), (21_000, 600)],
964            ),
965            (
966                "/orders",
967                Some(Method::DELETE),
968                vec![(2_000, 10), (15_000, 600)],
969            ),
970            (
971                "/cancel-all",
972                Some(Method::DELETE),
973                vec![(250, 10), (6_000, 600)],
974            ),
975            (
976                "/cancel-market-orders",
977                Some(Method::DELETE),
978                vec![(1_500, 10), (21_000, 600)],
979            ),
980            // ── Ledger: a cap shared across the group, plus per-endpoint caps ──
981            ("/trades", Some(Method::GET), vec![(900, 10)]),
982            ("/orders", Some(Method::GET), vec![(900, 10)]),
983            ("/order", Some(Method::GET), vec![(900, 10)]),
984            (
985                "/notifications",
986                Some(Method::GET),
987                vec![(900, 10), (125, 10)],
988            ),
989            ("/data/orders", Some(Method::GET), vec![(500, 10)]),
990            ("/data/trades", Some(Method::GET), vec![(500, 10)]),
991            // ── Market data ──
992            ("/book", Some(Method::GET), vec![(1_500, 10)]),
993            ("/books", Some(Method::POST), vec![(500, 10)]),
994            ("/price", Some(Method::GET), vec![(1_500, 10)]),
995            ("/prices", Some(Method::POST), vec![(500, 10)]),
996            ("/midpoint", Some(Method::GET), vec![(1_500, 10)]),
997            ("/midpoints", Some(Method::POST), vec![(500, 10)]),
998            ("/prices-history", Some(Method::GET), vec![(1_000, 10)]),
999            ("/tick-size", Some(Method::GET), vec![(200, 10)]),
1000            // ── Auth & health ──
1001            ("/auth/api-key", Some(Method::POST), vec![(100, 10)]),
1002            ("/ok", Some(Method::GET), vec![(100, 10)]),
1003        ]
1004    }
1005
1006    #[test]
1007    fn every_documented_endpoint_resolves_to_its_published_quota() {
1008        let rl = RateLimiter::clob_default();
1009
1010        for (path, method, expected) in documented() {
1011            let resolved = rl.resolve_specs(path, method.as_ref());
1012            assert!(
1013                !resolved.is_empty(),
1014                "{method:?} {path} matches no endpoint limit — it falls through to the \
1015                 general {}/10s bucket, over-permitting by {}x",
1016                9_000,
1017                9_000 / expected[0].0.max(1),
1018            );
1019            let actual: Vec<(u32, u64)> = resolved
1020                .iter()
1021                .map(|s| (s.count, s.period.as_secs()))
1022                .collect();
1023            assert_eq!(
1024                actual, expected,
1025                "{method:?} {path} resolves to {actual:?}, published limit is {expected:?}"
1026            );
1027        }
1028    }
1029
1030    #[test]
1031    fn batch_endpoints_do_not_inherit_their_singular_sibling() {
1032        // `/books` must not resolve through the `/book` rule: they are
1033        // different endpoints with a 3x difference in allowance.
1034        let rl = RateLimiter::clob_default();
1035        for (batch, singular) in [
1036            ("/books", "/book"),
1037            ("/prices", "/price"),
1038            ("/midpoints", "/midpoint"),
1039        ] {
1040            let batch_specs = rl.resolve_specs(batch, Some(&Method::POST));
1041            let singular_specs = rl.resolve_specs(singular, Some(&Method::GET));
1042            assert_ne!(
1043                batch_specs, singular_specs,
1044                "{batch} is being limited as if it were {singular}"
1045            );
1046            assert_eq!(batch_specs[0].count, 500, "{batch} should allow 500/10s");
1047        }
1048    }
1049
1050    #[test]
1051    fn the_ledger_group_cap_is_one_shared_bucket() {
1052        // Upstream caps `/trades`, `/orders`, `/notifications` and `/order`
1053        // at 900/10s *combined*. Modelling that as four independent 900/10s
1054        // buckets would permit 3,600/10s.
1055        let rl = RateLimiter::clob_default();
1056        let group: Vec<_> = ["/trades", "/orders", "/order", "/notifications"]
1057            .iter()
1058            .map(|p| {
1059                rl.inner
1060                    .limits
1061                    .iter()
1062                    .find(|l| l.matches(p, Some(&Method::GET)))
1063                    .unwrap_or_else(|| panic!("{p} should match a ledger entry"))
1064                    .buckets[0]
1065                    .clone()
1066            })
1067            .collect();
1068
1069        for other in &group[1..] {
1070            assert!(
1071                Arc::ptr_eq(&group[0], other),
1072                "ledger endpoints must share one bucket, not hold copies"
1073            );
1074        }
1075    }
1076
1077    #[test]
1078    fn balance_allowance_update_is_not_shadowed_by_its_parent_path() {
1079        // `/balance-allowance/update` starts with `/balance-allowance` at a
1080        // segment boundary, so ordering decides which rule wins. The update
1081        // route is four times tighter.
1082        let rl = RateLimiter::clob_default();
1083        let update = rl.resolve_specs("/balance-allowance/update", Some(&Method::GET));
1084        assert_eq!(
1085            update[0].count, 50,
1086            "the tighter /balance-allowance/update rule must be ordered first"
1087        );
1088    }
1089
1090    #[tokio::test]
1091    async fn a_documented_cap_actually_throttles() {
1092        // Matching specs is not the same as enforcing them. `/tick-size` is
1093        // 200/10s, which paces one request every ~50ms.
1094        assert_paced_by_its_own_quota(
1095            &RateLimiter::clob_default(),
1096            "/tick-size",
1097            200,
1098            Duration::from_secs(10),
1099        )
1100        .await;
1101    }
1102
1103    #[tokio::test]
1104    async fn the_ledger_group_allowance_is_consumed_jointly() {
1105        // The runtime counterpart to the Arc::ptr_eq check: consuming the group
1106        // through one endpoint must leave a *different* group member throttled.
1107        // The shared 900/10s bucket paces at ~11ms; with four independent
1108        // buckets /orders would only meet the general 9,000/10s one at ~1.1ms.
1109        let rl = RateLimiter::clob_default();
1110        rl.acquire("/trades", Some(&Method::GET)).await;
1111
1112        let start = std::time::Instant::now();
1113        rl.acquire("/orders", Some(&Method::GET)).await;
1114        let waited = start.elapsed();
1115
1116        assert!(
1117            waited >= Duration::from_millis(5),
1118            "GET /orders returned in {waited:?} after /trades consumed from the shared 900/10s \
1119             allowance — the group cap is not actually shared"
1120        );
1121    }
1122
1123    #[test]
1124    fn post_order_is_not_throttled_by_the_ledger_group() {
1125        // The ledger group names `/order`, but the trading table allows POST
1126        // /order 5,000/10s. Both can only hold if the group cap is the ledger
1127        // *read*. Applying it to POST would make the published burst
1128        // unreachable.
1129        let rl = RateLimiter::clob_default();
1130        let specs = rl.resolve_specs("/order", Some(&Method::POST));
1131        assert_eq!(specs[0].count, 5_000);
1132        assert!(
1133            !specs.iter().any(|s| s.count == 900),
1134            "POST /order must not be caught by the ledger read cap"
1135        );
1136    }
1137}
1138
1139#[cfg(test)]
1140mod tests {
1141    use super::*;
1142
1143    // ── RetryConfig ──────────────────────────────────────────────
1144
1145    #[test]
1146    fn test_retry_config_default() {
1147        let cfg = RetryConfig::default();
1148        assert_eq!(cfg.max_retries, 3);
1149        assert_eq!(cfg.initial_backoff_ms, 500);
1150        assert_eq!(cfg.max_backoff_ms, 10_000);
1151    }
1152
1153    #[test]
1154    fn test_backoff_attempt_zero() {
1155        let cfg = RetryConfig::default();
1156        let d = cfg.backoff(0);
1157        // base = 500 * 2^0 = 500, capped = 500, jitter in [0.75, 1.25]
1158        // ms in [375, 625]
1159        let ms = d.as_millis() as u64;
1160        assert!(
1161            (375..=625).contains(&ms),
1162            "attempt 0: {ms}ms not in [375, 625]"
1163        );
1164    }
1165
1166    #[test]
1167    fn test_backoff_exponential_growth() {
1168        let cfg = RetryConfig::default();
1169        let d0 = cfg.backoff(0);
1170        let d1 = cfg.backoff(1);
1171        let d2 = cfg.backoff(2);
1172        assert!(d0 < d1, "d0={d0:?} should be < d1={d1:?}");
1173        assert!(d1 < d2, "d1={d1:?} should be < d2={d2:?}");
1174    }
1175
1176    #[test]
1177    fn test_backoff_jitter_bounds() {
1178        let cfg = RetryConfig::default();
1179        for attempt in 0..20 {
1180            let d = cfg.backoff(attempt);
1181            let base = cfg
1182                .initial_backoff_ms
1183                .saturating_mul(1u64 << attempt.min(10));
1184            let capped = base.min(cfg.max_backoff_ms);
1185            let lower = (capped as f64 * 0.75) as u64;
1186            let upper = (capped as f64 * 1.25) as u64;
1187            let ms = d.as_millis() as u64;
1188            assert!(
1189                ms >= lower.max(1) && ms <= upper,
1190                "attempt {attempt}: {ms}ms not in [{lower}, {upper}]"
1191            );
1192        }
1193    }
1194
1195    #[test]
1196    fn test_backoff_max_capping() {
1197        let cfg = RetryConfig::default();
1198        for attempt in 5..=10 {
1199            let d = cfg.backoff(attempt);
1200            let ceiling = (cfg.max_backoff_ms as f64 * 1.25) as u64;
1201            assert!(
1202                d.as_millis() as u64 <= ceiling,
1203                "attempt {attempt}: {:?} exceeded ceiling {ceiling}ms",
1204                d
1205            );
1206        }
1207    }
1208
1209    #[test]
1210    fn test_backoff_very_high_attempt() {
1211        let cfg = RetryConfig::default();
1212        let d = cfg.backoff(100);
1213        let ceiling = (cfg.max_backoff_ms as f64 * 1.25) as u64;
1214        assert!(d.as_millis() as u64 <= ceiling);
1215        assert!(d.as_millis() >= 1);
1216    }
1217
1218    #[test]
1219    fn test_backoff_jitter_distribution() {
1220        // Verify jitter isn't degenerate (all clustering at one end).
1221        // Sample 200 values and check both halves of the range are hit.
1222        let cfg = RetryConfig::default();
1223        let midpoint = cfg.initial_backoff_ms; // 500ms (center of 375..625 range)
1224        let (mut below, mut above) = (0u32, 0u32);
1225        for _ in 0..200 {
1226            let ms = cfg.backoff(0).as_millis() as u64;
1227            if ms < midpoint {
1228                below += 1;
1229            } else {
1230                above += 1;
1231            }
1232        }
1233        assert!(
1234            below >= 20 && above >= 20,
1235            "jitter looks degenerate: {below} below midpoint, {above} above"
1236        );
1237    }
1238
1239    // ── quota() ──────────────────────────────────────────────────
1240
1241    #[test]
1242    fn test_quota_creation() {
1243        // Should not panic for representative values
1244        let _ = quota(100, Duration::from_secs(10));
1245        let _ = quota(1, Duration::from_secs(60));
1246        let _ = quota(9_000, Duration::from_secs(10));
1247    }
1248
1249    #[test]
1250    fn test_quota_edge_zero_count() {
1251        // The sustained rate is count-1, so 0 and 1 both have to be clamped or
1252        // the period is divided by zero. Neither appears in any table.
1253        let _ = quota(0, Duration::from_secs(10));
1254        let _ = quota(1, Duration::from_secs(10));
1255    }
1256
1257    // ── Factory methods ──────────────────────────────────────────
1258
1259    #[test]
1260    fn test_clob_default_construction() {
1261        let rl = RateLimiter::clob_default();
1262        assert_eq!(rl.inner.limits.len(), 27);
1263        assert!(format!("{:?}", rl).contains("endpoints"));
1264    }
1265
1266    #[test]
1267    fn test_gamma_default_construction() {
1268        let rl = RateLimiter::gamma_default();
1269        assert_eq!(rl.inner.limits.len(), 6);
1270    }
1271
1272    #[test]
1273    fn test_data_default_construction() {
1274        let rl = RateLimiter::data_default();
1275        assert_eq!(rl.inner.limits.len(), 5);
1276    }
1277
1278    #[test]
1279    fn test_relay_default_construction() {
1280        let rl = RateLimiter::relay_default();
1281        assert_eq!(rl.inner.limits.len(), 0);
1282    }
1283
1284    #[test]
1285    fn test_rate_limiter_debug_format() {
1286        let rl = RateLimiter::clob_default();
1287        let dbg = format!("{:?}", rl);
1288        assert!(dbg.contains("RateLimiter"), "missing struct name: {dbg}");
1289        assert!(dbg.contains("endpoints: 27"), "missing count: {dbg}");
1290    }
1291
1292    // ── Endpoint matching internals ──────────────────────────────
1293
1294    #[test]
1295    fn test_clob_tighter_rules_precede_the_prefixes_that_would_shadow_them() {
1296        // Ordering is only load-bearing where one pattern is a path-segment
1297        // prefix of another. Asserting on fixed indices made this test brittle
1298        // and told us nothing; assert the actual constraint instead.
1299        let rl = RateLimiter::clob_default();
1300        let index_of = |path: &str| {
1301            rl.inner
1302                .limits
1303                .iter()
1304                .position(|l| l.path_prefix == path)
1305                .unwrap_or_else(|| panic!("{path} should be configured"))
1306        };
1307
1308        for (specific, general) in [
1309            ("/balance-allowance/update", "/balance-allowance"),
1310            ("/data/orders", "/data"),
1311            ("/data/trades", "/data"),
1312        ] {
1313            assert!(
1314                index_of(specific) < index_of(general),
1315                "{specific} must be matched before {general} or it can never win"
1316            );
1317        }
1318    }
1319
1320    // ── acquire() async behavior ─────────────────────────────────
1321
1322    #[tokio::test]
1323    async fn test_acquire_single_completes_immediately() {
1324        let rl = RateLimiter::clob_default();
1325        let start = std::time::Instant::now();
1326        rl.acquire("/order", Some(&Method::POST)).await;
1327        assert!(start.elapsed() < Duration::from_millis(50));
1328    }
1329
1330    #[tokio::test]
1331    async fn test_acquire_matches_endpoint_by_prefix() {
1332        let rl = RateLimiter::clob_default();
1333        let start = std::time::Instant::now();
1334        // /order/123 should match the /order prefix
1335        rl.acquire("/order/123", Some(&Method::POST)).await;
1336        assert!(start.elapsed() < Duration::from_millis(50));
1337    }
1338
1339    #[tokio::test]
1340    async fn test_acquire_prefix_respects_segment_boundary() {
1341        let rl = RateLimiter::clob_default();
1342        let limits = &rl.inner.limits;
1343
1344        // Find the /price entry
1345        let price_idx = limits
1346            .iter()
1347            .position(|l| l.path_prefix == "/price")
1348            .expect("/price endpoint exists");
1349
1350        // /prices-history must NOT match /price — it's a different endpoint
1351        let prices_history_idx = limits
1352            .iter()
1353            .position(|l| l.path_prefix == "/prices-history")
1354            .expect("/prices-history endpoint exists");
1355
1356        // /prices-history should have its own entry, ordered before /price
1357        assert!(
1358            prices_history_idx < price_idx,
1359            "/prices-history (idx {prices_history_idx}) should come before /price (idx {price_idx})"
1360        );
1361    }
1362
1363    #[test]
1364    fn test_match_mode_prefix_segment_boundary() {
1365        // Verify the Prefix matching logic directly
1366        let pattern = "/price";
1367
1368        let check = |path: &str| -> bool {
1369            match path.strip_prefix(pattern) {
1370                Some(rest) => rest.is_empty() || rest.starts_with('/') || rest.starts_with('?'),
1371                None => false,
1372            }
1373        };
1374
1375        // Should match: exact, sub-path, query params
1376        assert!(check("/price"), "exact match");
1377        assert!(check("/price/foo"), "sub-path");
1378        assert!(check("/price?token=abc"), "query params");
1379
1380        // Should NOT match: partial word overlap
1381        assert!(!check("/prices-history"), "partial word /prices-history");
1382        assert!(!check("/pricelist"), "partial word /pricelist");
1383        assert!(!check("/pricing"), "partial word /pricing");
1384
1385        // Should NOT match: different prefix
1386        assert!(!check("/midpoint"), "different prefix");
1387    }
1388
1389    #[test]
1390    fn test_match_mode_exact() {
1391        // Verify the Exact matching logic
1392        let pattern = "/trades";
1393
1394        let check = |path: &str| -> bool { path == pattern };
1395
1396        assert!(check("/trades"), "exact match");
1397        assert!(!check("/trades/123"), "sub-path should not match");
1398        assert!(!check("/trades?limit=10"), "query params should not match");
1399        assert!(!check("/traded"), "different word should not match");
1400    }
1401
1402    #[tokio::test]
1403    async fn test_acquire_method_filtering() {
1404        let rl = RateLimiter::clob_default();
1405        let start = std::time::Instant::now();
1406        // GET /order shouldn't match POST or DELETE /order endpoints — falls to default only
1407        rl.acquire("/order", Some(&Method::GET)).await;
1408        assert!(start.elapsed() < Duration::from_millis(50));
1409    }
1410
1411    #[tokio::test]
1412    async fn test_acquire_no_endpoint_match_uses_default_only() {
1413        let rl = RateLimiter::clob_default();
1414        let start = std::time::Instant::now();
1415        rl.acquire("/unknown/path", None).await;
1416        assert!(start.elapsed() < Duration::from_millis(50));
1417    }
1418
1419    #[tokio::test]
1420    async fn test_acquire_method_none_matches_any_method() {
1421        let rl = RateLimiter::gamma_default();
1422        let start = std::time::Instant::now();
1423        // /events has method: None — should match GET, POST, and None
1424        rl.acquire("/events", Some(&Method::GET)).await;
1425        rl.acquire("/events", Some(&Method::POST)).await;
1426        rl.acquire("/events", None).await;
1427        assert!(start.elapsed() < Duration::from_millis(50));
1428    }
1429
1430    // ── Prefix collision tests ──────────────────────────────────
1431
1432    #[test]
1433    fn test_clob_price_and_prices_history_are_distinct() {
1434        let rl = RateLimiter::clob_default();
1435        let limits = &rl.inner.limits;
1436
1437        let price = limits.iter().find(|l| l.path_prefix == "/price").unwrap();
1438        let prices_history = limits
1439            .iter()
1440            .find(|l| l.path_prefix == "/prices-history")
1441            .unwrap();
1442
1443        // Both should use Prefix mode
1444        assert_eq!(price.match_mode, MatchMode::Prefix);
1445        assert_eq!(prices_history.match_mode, MatchMode::Prefix);
1446
1447        // Verify "/prices-history" does NOT match the "/price" pattern
1448        if let Some(rest) = "/prices-history".strip_prefix(price.path_prefix) {
1449            assert!(
1450                !rest.is_empty() && !rest.starts_with('/') && !rest.starts_with('?'),
1451                "/prices-history must not match /price pattern, rest = '{rest}'"
1452            );
1453        }
1454    }
1455
1456    #[test]
1457    fn test_data_positions_and_closed_positions_are_distinct() {
1458        // This previously asserted `!"/closed-positions".starts_with("/positions")`
1459        // — a tautology about two string literals that never touched the
1460        // limiter, and so held even with `/closed-positions` set to 66x its
1461        // published cap. Ask the limiter instead.
1462        let rl = RateLimiter::data_default();
1463
1464        let closed = rl.resolve_specs("/closed-positions", Some(&Method::GET));
1465        let positions = rl.resolve_specs("/positions", Some(&Method::GET));
1466        assert_eq!(closed, positions, "both are published at 150/10s");
1467
1468        let bucket_for = |path: &str| {
1469            rl.inner
1470                .limits
1471                .iter()
1472                .find(|l| l.matches(path, Some(&Method::GET)))
1473                .unwrap_or_else(|| panic!("{path} should match a rule"))
1474                .buckets[0]
1475                .clone()
1476        };
1477        assert!(
1478            !Arc::ptr_eq(&bucket_for("/closed-positions"), &bucket_for("/positions")),
1479            "equal quotas must still be separate buckets — upstream publishes \
1480             150/10s each, not 150/10s combined"
1481        );
1482    }
1483
1484    #[test]
1485    fn test_all_clob_endpoints_have_match_mode() {
1486        let rl = RateLimiter::clob_default();
1487        for limit in &rl.inner.limits {
1488            // Every endpoint should have an explicit match mode
1489            assert!(
1490                limit.match_mode == MatchMode::Prefix || limit.match_mode == MatchMode::Exact,
1491                "endpoint {} has no valid match mode",
1492                limit.path_prefix
1493            );
1494        }
1495    }
1496
1497    // ── Concurrent access tests ─────────────────────────────────
1498
1499    #[tokio::test]
1500    async fn concurrent_acquires_are_paced_against_one_shared_allowance() {
1501        // Concurrency must not multiply the allowance. Ten tasks racing on one
1502        // limiter have to serialise into ten successive slots, not each take a
1503        // token of their own — the limiter's state is shared, and this is the
1504        // assertion that says so.
1505        //
1506        // /markets is locally capped at 1,500/10s, pacing at ~6.7ms, so ten
1507        // acquires occupy ~60ms. The floor is the real assertion; the ceiling
1508        // only catches a stall.
1509        const TASKS: u32 = 10;
1510        let interval = Duration::from_secs(10) / (1_500 - 1);
1511
1512        let rl = std::sync::Arc::new(RateLimiter::clob_default());
1513
1514        let start = std::time::Instant::now();
1515        let mut handles = Vec::new();
1516        for _ in 0..TASKS {
1517            let rl = rl.clone();
1518            handles.push(tokio::spawn(async move {
1519                rl.acquire("/markets", None).await;
1520            }));
1521        }
1522        for handle in handles {
1523            handle.await.unwrap();
1524        }
1525        let elapsed = start.elapsed();
1526
1527        assert!(
1528            elapsed >= interval * (TASKS - 1) / 2,
1529            "{TASKS} concurrent acquires completed in {elapsed:?}; pacing at {interval:?} each \
1530             they cannot, so concurrent tasks are not sharing one allowance"
1531        );
1532        assert!(
1533            elapsed < Duration::from_secs(1),
1534            "{TASKS} concurrent acquires took {elapsed:?} — they are stalling, not pacing"
1535        );
1536    }
1537
1538    #[tokio::test]
1539    async fn test_acquire_concurrent_different_endpoints() {
1540        // Concurrent tasks hitting different endpoints should not block each other
1541        let rl = std::sync::Arc::new(RateLimiter::clob_default());
1542
1543        let rl1 = rl.clone();
1544        let rl2 = rl.clone();
1545        let rl3 = rl.clone();
1546
1547        let start = std::time::Instant::now();
1548        let (r1, r2, r3) = tokio::join!(
1549            tokio::spawn(async move { rl1.acquire("/markets", None).await }),
1550            tokio::spawn(async move { rl2.acquire("/auth", None).await }),
1551            tokio::spawn(async move { rl3.acquire("/order", Some(&Method::POST)).await }),
1552        );
1553        r1.unwrap();
1554        r2.unwrap();
1555        r3.unwrap();
1556
1557        assert!(
1558            start.elapsed() < Duration::from_millis(50),
1559            "different endpoints should not block: {:?}",
1560            start.elapsed()
1561        );
1562    }
1563
1564    // ── Dual-window interaction tests ───────────────────────────
1565
1566    #[test]
1567    fn test_clob_post_order_has_dual_window() {
1568        let rl = RateLimiter::clob_default();
1569        let post_order = rl
1570            .inner
1571            .limits
1572            .iter()
1573            .find(|l| l.path_prefix == "/order" && l.method == Some(Method::POST))
1574            .expect("POST /order endpoint should exist");
1575
1576        assert_eq!(
1577            post_order.buckets.len(),
1578            2,
1579            "POST /order should have a burst and a sustained window"
1580        );
1581    }
1582
1583    #[test]
1584    fn test_clob_delete_order_has_a_sustained_window_too() {
1585        // This previously asserted the *opposite* — that DELETE /order had only
1586        // a burst window — and so pinned the omission in place. Upstream
1587        // publishes 5,000/10s burst plus 120,000/10min sustained.
1588        let rl = RateLimiter::clob_default();
1589        let delete_order = rl
1590            .inner
1591            .limits
1592            .iter()
1593            .find(|l| l.path_prefix == "/order" && l.method == Some(Method::DELETE))
1594            .expect("DELETE /order endpoint should exist");
1595
1596        assert_eq!(
1597            delete_order.buckets.len(),
1598            2,
1599            "DELETE /order should have both a burst and a sustained window"
1600        );
1601    }
1602
1603    #[tokio::test]
1604    async fn test_dual_window_both_burst_and_sustained_are_awaited() {
1605        // POST /order should await both burst and sustained limiters.
1606        // With high limits, a single acquire should still complete fast.
1607        let rl = RateLimiter::clob_default();
1608        let start = std::time::Instant::now();
1609        rl.acquire("/order", Some(&Method::POST)).await;
1610        assert!(
1611            start.elapsed() < Duration::from_millis(50),
1612            "dual window single acquire should be fast: {:?}",
1613            start.elapsed()
1614        );
1615    }
1616
1617    // ── should_retry edge cases ─────────────────────────────────
1618
1619    #[test]
1620    fn test_should_retry_exhaustion() {
1621        // After max_retries, should_retry must return None
1622        let client = crate::HttpClientBuilder::new("https://example.com")
1623            .with_retry_config(RetryConfig {
1624                max_retries: 3,
1625                ..RetryConfig::default()
1626            })
1627            .build()
1628            .unwrap();
1629
1630        // Attempts 0, 1, 2 should succeed
1631        for attempt in 0..3 {
1632            assert!(
1633                client
1634                    .should_retry(reqwest::StatusCode::TOO_MANY_REQUESTS, attempt, None)
1635                    .is_some(),
1636                "attempt {attempt} should allow retry"
1637            );
1638        }
1639        // Attempt 3 should give up
1640        assert!(
1641            client
1642                .should_retry(reqwest::StatusCode::TOO_MANY_REQUESTS, 3, None)
1643                .is_none(),
1644            "attempt 3 should exhaust retries"
1645        );
1646    }
1647
1648    #[test]
1649    fn test_should_retry_zero_max_retries_never_retries() {
1650        let client = crate::HttpClientBuilder::new("https://example.com")
1651            .with_retry_config(RetryConfig {
1652                max_retries: 0,
1653                ..RetryConfig::default()
1654            })
1655            .build()
1656            .unwrap();
1657
1658        assert!(
1659            client
1660                .should_retry(reqwest::StatusCode::TOO_MANY_REQUESTS, 0, None)
1661                .is_none(),
1662            "max_retries=0 should never retry"
1663        );
1664    }
1665}
1666
1667#[cfg(test)]
1668mod cooldown_tests {
1669    //! A 429 is a fact about the host, not about the request that saw it.
1670    //!
1671    //! The token buckets above model the *published* quota, which is all a
1672    //! client can know in advance. When the server disagrees — Cloudflare's
1673    //! `error code: 1015` arrives as a 429 whatever our buckets believe — that
1674    //! correction has to reach every request sharing the limiter, or the
1675    //! siblings already in flight keep feeding a ban that is timed, and so gets
1676    //! longer the more it is hit.
1677
1678    use super::*;
1679
1680    #[tokio::test(start_paused = true)]
1681    async fn acquire_is_immediate_without_a_cooldown() {
1682        let rl = RateLimiter::data_default();
1683        let t = tokio::time::Instant::now();
1684        rl.acquire("/closed-positions", None).await;
1685        assert!(
1686            t.elapsed() < Duration::from_millis(1),
1687            "an untripped limiter must not delay: waited {:?}",
1688            t.elapsed()
1689        );
1690    }
1691
1692    #[tokio::test(start_paused = true)]
1693    async fn a_cooldown_holds_back_a_path_that_never_saw_the_429() {
1694        let rl = RateLimiter::data_default();
1695        rl.begin_cooldown(Duration::from_secs(5));
1696
1697        // /trades has its own bucket, full and untouched. It must wait anyway:
1698        // the block is on the IP, and every path shares it.
1699        let t = tokio::time::Instant::now();
1700        rl.acquire("/trades", None).await;
1701        assert!(
1702            t.elapsed() >= Duration::from_secs(5),
1703            "a sibling path resumed after {:?}, before the cooldown expired",
1704            t.elapsed()
1705        );
1706    }
1707
1708    #[tokio::test(start_paused = true)]
1709    async fn concurrent_requests_all_observe_one_cooldown() {
1710        let rl = RateLimiter::data_default();
1711        rl.begin_cooldown(Duration::from_secs(3));
1712
1713        // The shape from the report: several /closed-positions calls in flight
1714        // at once. One 429 has to stop all of them, not just its own caller.
1715        let t = tokio::time::Instant::now();
1716        tokio::join!(
1717            rl.acquire("/closed-positions", None),
1718            rl.acquire("/closed-positions", None),
1719            rl.acquire("/closed-positions", None),
1720            rl.acquire("/closed-positions", None),
1721        );
1722        assert!(
1723            t.elapsed() >= Duration::from_secs(3),
1724            "concurrent callers resumed after {:?}",
1725            t.elapsed()
1726        );
1727    }
1728
1729    #[tokio::test(start_paused = true)]
1730    async fn a_shorter_cooldown_never_cuts_a_longer_one_short() {
1731        let rl = RateLimiter::data_default();
1732        rl.begin_cooldown(Duration::from_secs(10));
1733        // A sibling's 429 lands next, carrying a smaller delay. Taking the
1734        // latest value would let the shortest response win the race and
1735        // release everyone early.
1736        rl.begin_cooldown(Duration::from_secs(1));
1737
1738        let t = tokio::time::Instant::now();
1739        rl.acquire("/positions", None).await;
1740        assert!(
1741            t.elapsed() >= Duration::from_secs(10),
1742            "the longer cooldown was truncated to {:?}",
1743            t.elapsed()
1744        );
1745    }
1746
1747    #[tokio::test(start_paused = true)]
1748    async fn a_cooldown_extended_mid_wait_is_honoured_in_full() {
1749        let rl = RateLimiter::data_default();
1750        rl.begin_cooldown(Duration::from_secs(2));
1751
1752        let extender = {
1753            let rl = rl.clone();
1754            tokio::spawn(async move {
1755                tokio::time::sleep(Duration::from_secs(1)).await;
1756                rl.begin_cooldown(Duration::from_secs(5));
1757            })
1758        };
1759
1760        let t = tokio::time::Instant::now();
1761        rl.acquire("/closed-positions", None).await;
1762        extender.await.unwrap();
1763        // Extended to 1s + 5s = 6s. Waking at the original 2s deadline and
1764        // returning would resume straight into the still-active ban.
1765        assert!(
1766            t.elapsed() >= Duration::from_secs(6),
1767            "resumed at {:?}, ignoring the cooldown extension",
1768            t.elapsed()
1769        );
1770    }
1771
1772    #[tokio::test(start_paused = true)]
1773    async fn an_expired_cooldown_stops_delaying() {
1774        let rl = RateLimiter::data_default();
1775        rl.begin_cooldown(Duration::from_secs(2));
1776        rl.acquire("/closed-positions", None).await;
1777
1778        let t = tokio::time::Instant::now();
1779        rl.acquire("/closed-positions", None).await;
1780        assert!(
1781            t.elapsed() < Duration::from_millis(1),
1782            "the limiter stayed blocked for {:?} after the cooldown expired",
1783            t.elapsed()
1784        );
1785    }
1786}