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    /// - Data API v2: measured per route, since upstream publishes no figures;
579    ///   see `docs/specs/data-v2/OBSERVED.md`
580    ///
581    /// The published table spells the health row `/ok`, but that path answers
582    /// **404** on `data-api.polymarket.com` — `/` answers 200 `{"data":"OK"}`,
583    /// and is the route this crate requests. `/ok` is boilerplate repeated into
584    /// every surface's table; only the CLOB host serves it.
585    ///
586    /// Matching `/` is safe despite entries being prefix-matched: the
587    /// segment-boundary rule means `strip_prefix("/")` on `/positions` leaves
588    /// `positions`, which starts with neither `/` nor `?`, so the entry matches
589    /// only the bare root and the root with a query string.
590    ///
591    /// This limiter is shared with the two sibling hosts, so it also carries
592    /// their rules:
593    ///
594    /// - `/user-pnl`: 200/10s, published as the *host-wide* allowance for
595    ///   `user-pnl-api.polymarket.com`. Modelled per-path because it is the
596    ///   only route polyoxide calls there and matching has no host dimension.
597    /// - `lb-api.polymarket.com` (`/volume`, `/profit`) has no published limit,
598    ///   so those fall to the general bucket.
599    pub fn data_default() -> Self {
600        let ten_sec = Duration::from_secs(10);
601
602        Self {
603            inner: Arc::new(RateLimiterInner {
604                default: DirectLimiter::direct(quota(1_000, ten_sec)),
605                cooldown_until: Mutex::new(None),
606                limits: vec![
607                    simple_limit("/closed-positions", None, 150, ten_sec),
608                    simple_limit("/positions", None, 150, ten_sec),
609                    simple_limit("/trades", None, 200, ten_sec),
610                    simple_limit("/user-pnl", None, 200, ten_sec),
611                    // Data API v2. Upstream publishes no v2 figures; each count is the
612                    // highest clean rate from the ramps in
613                    // docs/specs/data-v2/OBSERVED.md, and `quota()` reserves a tenth.
614                    // `/v2/positions/combos` must precede `/v2/positions`: prefix
615                    // matching takes the first row that matches.
616                    simple_limit("/v2/positions/combos", None, 400, ten_sec),
617                    simple_limit("/v2/positions", None, 200, ten_sec),
618                    simple_limit("/v2/trades", None, 200, ten_sec),
619                    simple_limit("/v2/activity", None, 400, ten_sec),
620                    simple_limit("/v2/user-pnl", None, 400, ten_sec),
621                    simple_limit("/v2/holders", None, 400, ten_sec),
622                    simple_limit("/", None, 100, ten_sec),
623                ],
624            }),
625        }
626    }
627
628    /// Relay API rate limits.
629    ///
630    /// - 25 requests per 1 minute (single limiter, no endpoint-specific limits)
631    pub fn relay_default() -> Self {
632        Self {
633            inner: Arc::new(RateLimiterInner {
634                default: DirectLimiter::direct(quota(25, Duration::from_secs(60))),
635                cooldown_until: Mutex::new(None),
636                limits: vec![],
637            }),
638        }
639    }
640}
641
642/// Configuration for retry-on-429 with exponential backoff.
643#[derive(Debug, Clone)]
644pub struct RetryConfig {
645    /// Maximum number of retry attempts after the initial request (default: 3).
646    pub max_retries: u32,
647    /// Base backoff in milliseconds for the first retry, doubled each attempt (default: 500).
648    pub initial_backoff_ms: u64,
649    /// Upper bound in milliseconds for the backoff delay (default: 10_000).
650    pub max_backoff_ms: u64,
651}
652
653impl Default for RetryConfig {
654    fn default() -> Self {
655        Self {
656            max_retries: 3,
657            initial_backoff_ms: 500,
658            max_backoff_ms: 10_000,
659        }
660    }
661}
662
663impl RetryConfig {
664    /// Calculate backoff duration with jitter for attempt N.
665    ///
666    /// Uses `fastrand` for uniform jitter (75%-125% of base delay) to avoid
667    /// thundering herd when multiple clients retry simultaneously.
668    pub fn backoff(&self, attempt: u32) -> Duration {
669        let base = self
670            .initial_backoff_ms
671            .saturating_mul(1u64 << attempt.min(10));
672        let capped = base.min(self.max_backoff_ms);
673        // Uniform jitter in 0.75..1.25 range
674        let jitter_factor = 0.75 + (fastrand::f64() * 0.5);
675        let ms = (capped as f64 * jitter_factor) as u64;
676        Duration::from_millis(ms.max(1))
677    }
678}
679
680#[cfg(test)]
681mod agreement {
682    //! Shared machinery for the per-surface `documented_*_limits` modules.
683    //!
684    //! Every API surface pins its published table the same way: assert the
685    //! *effective quota* a request resolves to, not merely that some entry
686    //! exists. Checking only for presence and ordering is why
687    //! `/balance-allowance` could once be absent entirely while every test
688    //! passed, and why `/closed-positions` could be set to 66x its published
689    //! cap without a single failure.
690
691    use super::*;
692
693    /// One published rule: the request it applies to, and the buckets it must
694    /// pass, as `(count, window_secs)` in the order `acquire` awaits them.
695    pub type DocumentedRule = (&'static str, Option<Method>, Vec<(u32, u64)>);
696
697    /// Assert every rule resolves to exactly the quota Polymarket publishes.
698    ///
699    /// `general` is the surface's catch-all allowance. It is only used to make
700    /// the failure message name the over-permit factor, since falling through
701    /// to the general bucket is the shape every bug in these tables has taken.
702    pub fn assert_matches_published(rl: &RateLimiter, rules: Vec<DocumentedRule>, general: u32) {
703        for (path, method, expected) in rules {
704            let resolved = rl.resolve_specs(path, method.as_ref());
705            assert!(
706                !resolved.is_empty(),
707                "{method:?} {path} matches no endpoint limit — it falls through to the \
708                 general {general}/10s bucket, over-permitting by {}x",
709                general / expected[0].0.max(1),
710            );
711            let actual: Vec<(u32, u64)> = resolved
712                .iter()
713                .map(|s| (s.count, s.period.as_secs()))
714                .collect();
715            assert_eq!(
716                actual, expected,
717                "{method:?} {path} resolves to {actual:?}, published limit is {expected:?}"
718            );
719        }
720    }
721
722    /// Assert `path` is governed by nothing but the general bucket.
723    ///
724    /// Used to pin routes that upstream's table names but the host does not
725    /// actually serve, so a dead entry cannot quietly reappear.
726    pub fn assert_unconfigured(rl: &RateLimiter, path: &str) {
727        assert!(
728            rl.resolve_specs(path, Some(&Method::GET)).is_empty(),
729            "{path} has an endpoint limit configured, but the host answers 404 there — \
730             the entry is dead configuration and the real route is going unlimited"
731        );
732    }
733
734    /// Assert `path` is paced at runtime by the quota it publishes.
735    ///
736    /// Matching a spec is not the same as enforcing it; this is the runtime
737    /// half of the agreement. Buckets hold a single token, so one request
738    /// empties `path`'s bucket and the next has to wait a full replenish
739    /// interval — no `count`-sized drain required, and none wanted: draining
740    /// `count` under uniform pacing takes a real `period`, which would put a
741    /// 10-second sleep in the unit suite for every row asserted.
742    ///
743    /// Asserting *how long* the wait is, rather than merely that there was
744    /// one, is what makes this specific. The delay identifies which bucket the
745    /// request came from: `/closed-positions` (150/10s) paces at ~67ms while
746    /// the surface's general bucket paces at ~10ms. The upper bound catches
747    /// the inverse failure — a path resolving through some tighter rule that
748    /// shadows it, which is the shape every ordering bug in these tables has
749    /// taken.
750    pub async fn assert_paced_by_its_own_quota(
751        rl: &RateLimiter,
752        path: &str,
753        count: u32,
754        period: Duration,
755    ) {
756        let interval = period / sustained_slots(count);
757
758        rl.acquire(path, Some(&Method::GET)).await;
759
760        let start = std::time::Instant::now();
761        rl.acquire(path, Some(&Method::GET)).await;
762        let waited = start.elapsed();
763
764        assert!(
765            waited >= interval.mul_f64(0.8),
766            "the 2nd request to {path} returned in {waited:?}; {count}/{period:?} should pace \
767             it at {interval:?} and the cap is not being enforced"
768        );
769        assert!(
770            waited <= interval * 3 + Duration::from_millis(25),
771            "the 2nd request to {path} waited {waited:?}, far longer than the {interval:?} its \
772             published {count}/{period:?} implies — it is resolving through a tighter rule"
773        );
774    }
775}
776
777#[cfg(test)]
778mod documented_data_limits {
779    //! Agreement tests for the Data API's published table.
780    //!
781    //! Transcribed from <https://docs.polymarket.com/api-reference/rate-limits>
782    //! as fetched on 2026-08-05.
783    //!
784    //! Two rows need interpreting, both verified against the live hosts:
785    //!
786    //! - The health row is published as `/ok`, but `data-api.polymarket.com/ok`
787    //!   answers **404** while `/` answers 200 `{"data":"OK"}`. The `/ok`
788    //!   spelling is boilerplate repeated into every surface's table; only
789    //!   `clob.polymarket.com` actually serves it. The cap is therefore
790    //!   attached to `/`, the route this crate requests and the host answers.
791    //! - "User PNL API 200 req/10s" is published as a *host-wide* allowance for
792    //!   `user-pnl-api.polymarket.com`. It is modelled as a path rule on
793    //!   `/user-pnl` because that is the only route polyoxide calls there and
794    //!   the limiter matches on path alone, with no host dimension.
795
796    use super::agreement::*;
797    use super::*;
798
799    /// The published table, transcribed by hand. This is the golden vector.
800    fn documented() -> Vec<DocumentedRule> {
801        vec![
802            ("/trades", Some(Method::GET), vec![(200, 10)]),
803            ("/positions", Some(Method::GET), vec![(150, 10)]),
804            ("/closed-positions", Some(Method::GET), vec![(150, 10)]),
805            ("/", Some(Method::GET), vec![(100, 10)]),
806            ("/user-pnl", Some(Method::GET), vec![(200, 10)]),
807        ]
808    }
809
810    #[test]
811    fn every_documented_endpoint_resolves_to_its_published_quota() {
812        assert_matches_published(&RateLimiter::data_default(), documented(), 1_000);
813    }
814
815    /// Data API v2 rows. Upstream publishes no v2 figures; each count is the
816    /// highest clean ramp rate recorded in `docs/specs/data-v2/OBSERVED.md`.
817    fn measured_v2() -> Vec<DocumentedRule> {
818        vec![
819            ("/v2/positions", Some(Method::GET), vec![(200, 10)]),
820            ("/v2/positions/combos", Some(Method::GET), vec![(400, 10)]),
821            ("/v2/trades", Some(Method::GET), vec![(200, 10)]),
822            ("/v2/activity", Some(Method::GET), vec![(400, 10)]),
823            ("/v2/user-pnl", Some(Method::GET), vec![(400, 10)]),
824            ("/v2/holders", Some(Method::GET), vec![(400, 10)]),
825        ]
826    }
827
828    #[test]
829    fn every_v2_route_resolves_to_its_measured_quota() {
830        assert_matches_published(&RateLimiter::data_default(), measured_v2(), 1_000);
831    }
832
833    #[test]
834    fn the_health_cap_is_attached_to_the_route_the_host_answers_on() {
835        // `/ok` is a 404 on data-api. An entry there caps nothing and leaves
836        // the real health route — `/` — on the 10x-looser general bucket.
837        assert_unconfigured(&RateLimiter::data_default(), "/ok");
838    }
839
840    #[test]
841    fn the_root_health_rule_does_not_swallow_every_other_route() {
842        // `/` under prefix matching could plausibly match everything. The
843        // segment-boundary rule saves it: `strip_prefix("/")` on `/positions`
844        // leaves `positions`, which starts with neither `/` nor `?`.
845        let rl = RateLimiter::data_default();
846        for (path, expected) in [
847            ("/positions", 150),
848            ("/closed-positions", 150),
849            ("/trades", 200),
850            ("/", 100),
851        ] {
852            let specs = rl.resolve_specs(path, Some(&Method::GET));
853            assert_eq!(
854                specs[0].count, expected,
855                "{path} resolved through the wrong rule — the `/` entry is over-matching"
856            );
857        }
858    }
859
860    #[tokio::test]
861    async fn the_closed_positions_cap_actually_throttles() {
862        // 150/10s paces one request every ~67ms.
863        assert_paced_by_its_own_quota(
864            &RateLimiter::data_default(),
865            "/closed-positions",
866            150,
867            Duration::from_secs(10),
868        )
869        .await;
870    }
871
872    #[tokio::test]
873    async fn closed_positions_and_positions_do_not_share_an_allowance() {
874        // Upstream publishes 150/10s for each, not 150/10s combined. Emptying
875        // one must leave the other untouched — the inverse of the CLOB ledger
876        // group, where sharing *is* the published behaviour.
877        //
878        // The margin here is ~67ms (shared) against ~10ms (separate, and only
879        // that much because the request still passes the surface's general
880        // 1,000/10s bucket). Both sides of that gap are load-bearing, so the
881        // threshold sits between them rather than at zero.
882        let rl = RateLimiter::data_default();
883        rl.acquire("/closed-positions", Some(&Method::GET)).await;
884
885        let start = std::time::Instant::now();
886        rl.acquire("/positions", Some(&Method::GET)).await;
887        assert!(
888            start.elapsed() < Duration::from_millis(25),
889            "/positions was throttled by /closed-positions emptying its own bucket"
890        );
891    }
892}
893
894#[cfg(test)]
895mod documented_gamma_limits {
896    //! Agreement tests for the Gamma API's published table.
897    //!
898    //! Transcribed from <https://docs.polymarket.com/api-reference/rate-limits>
899    //! as fetched on 2026-08-05. As with the Data API, the published health row
900    //! reads `/ok`, but `gamma-api.polymarket.com/ok` answers 404 — `/status`
901    //! is the route that answers 200.
902
903    use super::agreement::*;
904    use super::*;
905
906    /// The published table, transcribed by hand. This is the golden vector.
907    fn documented() -> Vec<DocumentedRule> {
908        vec![
909            ("/events", Some(Method::GET), vec![(500, 10)]),
910            ("/public-search", Some(Method::GET), vec![(350, 10)]),
911            ("/markets", Some(Method::GET), vec![(300, 10)]),
912            ("/comments", Some(Method::GET), vec![(200, 10)]),
913            ("/tags", Some(Method::GET), vec![(200, 10)]),
914            ("/status", Some(Method::GET), vec![(100, 10)]),
915        ]
916    }
917
918    #[test]
919    fn every_documented_endpoint_resolves_to_its_published_quota() {
920        assert_matches_published(&RateLimiter::gamma_default(), documented(), 4_000);
921    }
922
923    #[test]
924    fn the_health_cap_is_attached_to_the_route_the_host_answers_on() {
925        assert_unconfigured(&RateLimiter::gamma_default(), "/ok");
926    }
927
928    #[test]
929    fn the_markets_plus_events_group_cap_can_never_bind() {
930        // Upstream also publishes a 900/10s cap shared by /markets + /events.
931        // It is deliberately not modelled because the per-endpoint caps sum to
932        // less than it. If either cap is ever raised, this stops being true and
933        // the group bucket has to be added — that is what this test watches.
934        let rl = RateLimiter::gamma_default();
935        let markets = rl.resolve_specs("/markets", Some(&Method::GET))[0].count;
936        let events = rl.resolve_specs("/events", Some(&Method::GET))[0].count;
937        assert!(
938            markets + events <= 900,
939            "/markets ({markets}) + /events ({events}) now exceeds the published 900/10s \
940             group cap, which is no longer unreachable and must be modelled"
941        );
942    }
943
944    #[tokio::test]
945    async fn the_markets_cap_actually_throttles() {
946        assert_paced_by_its_own_quota(
947            &RateLimiter::gamma_default(),
948            "/markets",
949            300,
950            Duration::from_secs(10),
951        )
952        .await;
953    }
954}
955
956#[cfg(test)]
957mod documented_limits {
958    //! Table-driven agreement tests against Polymarket's published limits.
959    //!
960    //! Transcribed from <https://docs.polymarket.com/api-reference/rate-limits>
961    //! as fetched on 2026-07-25, re-confirmed 2026-08-05. These assert the
962    //! *effective quota* a request resolves to, not merely that some entry
963    //! exists — the previous tests only checked that entries were present and
964    //! in the right order, which is why `/balance-allowance` could be absent
965    //! entirely while every test passed.
966
967    use super::agreement::{assert_paced_by_its_own_quota, DocumentedRule};
968    use super::*;
969
970    /// The published table, transcribed by hand. This is the golden vector.
971    fn documented() -> Vec<DocumentedRule> {
972        vec![
973            // ── Account ──
974            ("/balance-allowance", Some(Method::GET), vec![(200, 10)]),
975            (
976                "/balance-allowance/update",
977                Some(Method::GET),
978                vec![(50, 10)],
979            ),
980            // ── Trading (dual window) ──
981            (
982                "/order",
983                Some(Method::POST),
984                vec![(5_000, 10), (120_000, 600)],
985            ),
986            (
987                "/order",
988                Some(Method::DELETE),
989                vec![(5_000, 10), (120_000, 600)],
990            ),
991            (
992                "/orders",
993                Some(Method::POST),
994                vec![(2_000, 10), (21_000, 600)],
995            ),
996            (
997                "/orders",
998                Some(Method::DELETE),
999                vec![(2_000, 10), (15_000, 600)],
1000            ),
1001            (
1002                "/cancel-all",
1003                Some(Method::DELETE),
1004                vec![(250, 10), (6_000, 600)],
1005            ),
1006            (
1007                "/cancel-market-orders",
1008                Some(Method::DELETE),
1009                vec![(1_500, 10), (21_000, 600)],
1010            ),
1011            // ── Ledger: a cap shared across the group, plus per-endpoint caps ──
1012            ("/trades", Some(Method::GET), vec![(900, 10)]),
1013            ("/orders", Some(Method::GET), vec![(900, 10)]),
1014            ("/order", Some(Method::GET), vec![(900, 10)]),
1015            (
1016                "/notifications",
1017                Some(Method::GET),
1018                vec![(900, 10), (125, 10)],
1019            ),
1020            ("/data/orders", Some(Method::GET), vec![(500, 10)]),
1021            ("/data/trades", Some(Method::GET), vec![(500, 10)]),
1022            // ── Market data ──
1023            ("/book", Some(Method::GET), vec![(1_500, 10)]),
1024            ("/books", Some(Method::POST), vec![(500, 10)]),
1025            ("/price", Some(Method::GET), vec![(1_500, 10)]),
1026            ("/prices", Some(Method::POST), vec![(500, 10)]),
1027            ("/midpoint", Some(Method::GET), vec![(1_500, 10)]),
1028            ("/midpoints", Some(Method::POST), vec![(500, 10)]),
1029            ("/prices-history", Some(Method::GET), vec![(1_000, 10)]),
1030            ("/tick-size", Some(Method::GET), vec![(200, 10)]),
1031            // ── Auth & health ──
1032            ("/auth/api-key", Some(Method::POST), vec![(100, 10)]),
1033            ("/ok", Some(Method::GET), vec![(100, 10)]),
1034        ]
1035    }
1036
1037    #[test]
1038    fn every_documented_endpoint_resolves_to_its_published_quota() {
1039        let rl = RateLimiter::clob_default();
1040
1041        for (path, method, expected) in documented() {
1042            let resolved = rl.resolve_specs(path, method.as_ref());
1043            assert!(
1044                !resolved.is_empty(),
1045                "{method:?} {path} matches no endpoint limit — it falls through to the \
1046                 general {}/10s bucket, over-permitting by {}x",
1047                9_000,
1048                9_000 / expected[0].0.max(1),
1049            );
1050            let actual: Vec<(u32, u64)> = resolved
1051                .iter()
1052                .map(|s| (s.count, s.period.as_secs()))
1053                .collect();
1054            assert_eq!(
1055                actual, expected,
1056                "{method:?} {path} resolves to {actual:?}, published limit is {expected:?}"
1057            );
1058        }
1059    }
1060
1061    #[test]
1062    fn batch_endpoints_do_not_inherit_their_singular_sibling() {
1063        // `/books` must not resolve through the `/book` rule: they are
1064        // different endpoints with a 3x difference in allowance.
1065        let rl = RateLimiter::clob_default();
1066        for (batch, singular) in [
1067            ("/books", "/book"),
1068            ("/prices", "/price"),
1069            ("/midpoints", "/midpoint"),
1070        ] {
1071            let batch_specs = rl.resolve_specs(batch, Some(&Method::POST));
1072            let singular_specs = rl.resolve_specs(singular, Some(&Method::GET));
1073            assert_ne!(
1074                batch_specs, singular_specs,
1075                "{batch} is being limited as if it were {singular}"
1076            );
1077            assert_eq!(batch_specs[0].count, 500, "{batch} should allow 500/10s");
1078        }
1079    }
1080
1081    #[test]
1082    fn the_ledger_group_cap_is_one_shared_bucket() {
1083        // Upstream caps `/trades`, `/orders`, `/notifications` and `/order`
1084        // at 900/10s *combined*. Modelling that as four independent 900/10s
1085        // buckets would permit 3,600/10s.
1086        let rl = RateLimiter::clob_default();
1087        let group: Vec<_> = ["/trades", "/orders", "/order", "/notifications"]
1088            .iter()
1089            .map(|p| {
1090                rl.inner
1091                    .limits
1092                    .iter()
1093                    .find(|l| l.matches(p, Some(&Method::GET)))
1094                    .unwrap_or_else(|| panic!("{p} should match a ledger entry"))
1095                    .buckets[0]
1096                    .clone()
1097            })
1098            .collect();
1099
1100        for other in &group[1..] {
1101            assert!(
1102                Arc::ptr_eq(&group[0], other),
1103                "ledger endpoints must share one bucket, not hold copies"
1104            );
1105        }
1106    }
1107
1108    #[test]
1109    fn balance_allowance_update_is_not_shadowed_by_its_parent_path() {
1110        // `/balance-allowance/update` starts with `/balance-allowance` at a
1111        // segment boundary, so ordering decides which rule wins. The update
1112        // route is four times tighter.
1113        let rl = RateLimiter::clob_default();
1114        let update = rl.resolve_specs("/balance-allowance/update", Some(&Method::GET));
1115        assert_eq!(
1116            update[0].count, 50,
1117            "the tighter /balance-allowance/update rule must be ordered first"
1118        );
1119    }
1120
1121    #[tokio::test]
1122    async fn a_documented_cap_actually_throttles() {
1123        // Matching specs is not the same as enforcing them. `/tick-size` is
1124        // 200/10s, which paces one request every ~50ms.
1125        assert_paced_by_its_own_quota(
1126            &RateLimiter::clob_default(),
1127            "/tick-size",
1128            200,
1129            Duration::from_secs(10),
1130        )
1131        .await;
1132    }
1133
1134    #[tokio::test]
1135    async fn the_ledger_group_allowance_is_consumed_jointly() {
1136        // The runtime counterpart to the Arc::ptr_eq check: consuming the group
1137        // through one endpoint must leave a *different* group member throttled.
1138        // The shared 900/10s bucket paces at ~11ms; with four independent
1139        // buckets /orders would only meet the general 9,000/10s one at ~1.1ms.
1140        let rl = RateLimiter::clob_default();
1141        rl.acquire("/trades", Some(&Method::GET)).await;
1142
1143        let start = std::time::Instant::now();
1144        rl.acquire("/orders", Some(&Method::GET)).await;
1145        let waited = start.elapsed();
1146
1147        assert!(
1148            waited >= Duration::from_millis(5),
1149            "GET /orders returned in {waited:?} after /trades consumed from the shared 900/10s \
1150             allowance — the group cap is not actually shared"
1151        );
1152    }
1153
1154    #[test]
1155    fn post_order_is_not_throttled_by_the_ledger_group() {
1156        // The ledger group names `/order`, but the trading table allows POST
1157        // /order 5,000/10s. Both can only hold if the group cap is the ledger
1158        // *read*. Applying it to POST would make the published burst
1159        // unreachable.
1160        let rl = RateLimiter::clob_default();
1161        let specs = rl.resolve_specs("/order", Some(&Method::POST));
1162        assert_eq!(specs[0].count, 5_000);
1163        assert!(
1164            !specs.iter().any(|s| s.count == 900),
1165            "POST /order must not be caught by the ledger read cap"
1166        );
1167    }
1168}
1169
1170#[cfg(test)]
1171mod tests {
1172    use super::*;
1173
1174    // ── RetryConfig ──────────────────────────────────────────────
1175
1176    #[test]
1177    fn test_retry_config_default() {
1178        let cfg = RetryConfig::default();
1179        assert_eq!(cfg.max_retries, 3);
1180        assert_eq!(cfg.initial_backoff_ms, 500);
1181        assert_eq!(cfg.max_backoff_ms, 10_000);
1182    }
1183
1184    #[test]
1185    fn test_backoff_attempt_zero() {
1186        let cfg = RetryConfig::default();
1187        let d = cfg.backoff(0);
1188        // base = 500 * 2^0 = 500, capped = 500, jitter in [0.75, 1.25]
1189        // ms in [375, 625]
1190        let ms = d.as_millis() as u64;
1191        assert!(
1192            (375..=625).contains(&ms),
1193            "attempt 0: {ms}ms not in [375, 625]"
1194        );
1195    }
1196
1197    #[test]
1198    fn test_backoff_exponential_growth() {
1199        let cfg = RetryConfig::default();
1200        let d0 = cfg.backoff(0);
1201        let d1 = cfg.backoff(1);
1202        let d2 = cfg.backoff(2);
1203        assert!(d0 < d1, "d0={d0:?} should be < d1={d1:?}");
1204        assert!(d1 < d2, "d1={d1:?} should be < d2={d2:?}");
1205    }
1206
1207    #[test]
1208    fn test_backoff_jitter_bounds() {
1209        let cfg = RetryConfig::default();
1210        for attempt in 0..20 {
1211            let d = cfg.backoff(attempt);
1212            let base = cfg
1213                .initial_backoff_ms
1214                .saturating_mul(1u64 << attempt.min(10));
1215            let capped = base.min(cfg.max_backoff_ms);
1216            let lower = (capped as f64 * 0.75) as u64;
1217            let upper = (capped as f64 * 1.25) as u64;
1218            let ms = d.as_millis() as u64;
1219            assert!(
1220                ms >= lower.max(1) && ms <= upper,
1221                "attempt {attempt}: {ms}ms not in [{lower}, {upper}]"
1222            );
1223        }
1224    }
1225
1226    #[test]
1227    fn test_backoff_max_capping() {
1228        let cfg = RetryConfig::default();
1229        for attempt in 5..=10 {
1230            let d = cfg.backoff(attempt);
1231            let ceiling = (cfg.max_backoff_ms as f64 * 1.25) as u64;
1232            assert!(
1233                d.as_millis() as u64 <= ceiling,
1234                "attempt {attempt}: {:?} exceeded ceiling {ceiling}ms",
1235                d
1236            );
1237        }
1238    }
1239
1240    #[test]
1241    fn test_backoff_very_high_attempt() {
1242        let cfg = RetryConfig::default();
1243        let d = cfg.backoff(100);
1244        let ceiling = (cfg.max_backoff_ms as f64 * 1.25) as u64;
1245        assert!(d.as_millis() as u64 <= ceiling);
1246        assert!(d.as_millis() >= 1);
1247    }
1248
1249    #[test]
1250    fn test_backoff_jitter_distribution() {
1251        // Verify jitter isn't degenerate (all clustering at one end).
1252        // Sample 200 values and check both halves of the range are hit.
1253        let cfg = RetryConfig::default();
1254        let midpoint = cfg.initial_backoff_ms; // 500ms (center of 375..625 range)
1255        let (mut below, mut above) = (0u32, 0u32);
1256        for _ in 0..200 {
1257            let ms = cfg.backoff(0).as_millis() as u64;
1258            if ms < midpoint {
1259                below += 1;
1260            } else {
1261                above += 1;
1262            }
1263        }
1264        assert!(
1265            below >= 20 && above >= 20,
1266            "jitter looks degenerate: {below} below midpoint, {above} above"
1267        );
1268    }
1269
1270    // ── quota() ──────────────────────────────────────────────────
1271
1272    #[test]
1273    fn test_quota_creation() {
1274        // Should not panic for representative values
1275        let _ = quota(100, Duration::from_secs(10));
1276        let _ = quota(1, Duration::from_secs(60));
1277        let _ = quota(9_000, Duration::from_secs(10));
1278    }
1279
1280    #[test]
1281    fn test_quota_edge_zero_count() {
1282        // The sustained rate is count-1, so 0 and 1 both have to be clamped or
1283        // the period is divided by zero. Neither appears in any table.
1284        let _ = quota(0, Duration::from_secs(10));
1285        let _ = quota(1, Duration::from_secs(10));
1286    }
1287
1288    // ── Factory methods ──────────────────────────────────────────
1289
1290    #[test]
1291    fn test_clob_default_construction() {
1292        let rl = RateLimiter::clob_default();
1293        assert_eq!(rl.inner.limits.len(), 27);
1294        assert!(format!("{:?}", rl).contains("endpoints"));
1295    }
1296
1297    #[test]
1298    fn test_gamma_default_construction() {
1299        let rl = RateLimiter::gamma_default();
1300        assert_eq!(rl.inner.limits.len(), 6);
1301    }
1302
1303    #[test]
1304    fn test_data_default_construction() {
1305        let rl = RateLimiter::data_default();
1306        assert_eq!(rl.inner.limits.len(), 11);
1307    }
1308
1309    #[test]
1310    fn test_relay_default_construction() {
1311        let rl = RateLimiter::relay_default();
1312        assert_eq!(rl.inner.limits.len(), 0);
1313    }
1314
1315    #[test]
1316    fn test_rate_limiter_debug_format() {
1317        let rl = RateLimiter::clob_default();
1318        let dbg = format!("{:?}", rl);
1319        assert!(dbg.contains("RateLimiter"), "missing struct name: {dbg}");
1320        assert!(dbg.contains("endpoints: 27"), "missing count: {dbg}");
1321    }
1322
1323    // ── Endpoint matching internals ──────────────────────────────
1324
1325    #[test]
1326    fn test_clob_tighter_rules_precede_the_prefixes_that_would_shadow_them() {
1327        // Ordering is only load-bearing where one pattern is a path-segment
1328        // prefix of another. Asserting on fixed indices made this test brittle
1329        // and told us nothing; assert the actual constraint instead.
1330        let rl = RateLimiter::clob_default();
1331        let index_of = |path: &str| {
1332            rl.inner
1333                .limits
1334                .iter()
1335                .position(|l| l.path_prefix == path)
1336                .unwrap_or_else(|| panic!("{path} should be configured"))
1337        };
1338
1339        for (specific, general) in [
1340            ("/balance-allowance/update", "/balance-allowance"),
1341            ("/data/orders", "/data"),
1342            ("/data/trades", "/data"),
1343        ] {
1344            assert!(
1345                index_of(specific) < index_of(general),
1346                "{specific} must be matched before {general} or it can never win"
1347            );
1348        }
1349    }
1350
1351    // ── acquire() async behavior ─────────────────────────────────
1352
1353    #[tokio::test]
1354    async fn test_acquire_single_completes_immediately() {
1355        let rl = RateLimiter::clob_default();
1356        let start = std::time::Instant::now();
1357        rl.acquire("/order", Some(&Method::POST)).await;
1358        assert!(start.elapsed() < Duration::from_millis(50));
1359    }
1360
1361    #[tokio::test]
1362    async fn test_acquire_matches_endpoint_by_prefix() {
1363        let rl = RateLimiter::clob_default();
1364        let start = std::time::Instant::now();
1365        // /order/123 should match the /order prefix
1366        rl.acquire("/order/123", Some(&Method::POST)).await;
1367        assert!(start.elapsed() < Duration::from_millis(50));
1368    }
1369
1370    #[tokio::test]
1371    async fn test_acquire_prefix_respects_segment_boundary() {
1372        let rl = RateLimiter::clob_default();
1373        let limits = &rl.inner.limits;
1374
1375        // Find the /price entry
1376        let price_idx = limits
1377            .iter()
1378            .position(|l| l.path_prefix == "/price")
1379            .expect("/price endpoint exists");
1380
1381        // /prices-history must NOT match /price — it's a different endpoint
1382        let prices_history_idx = limits
1383            .iter()
1384            .position(|l| l.path_prefix == "/prices-history")
1385            .expect("/prices-history endpoint exists");
1386
1387        // /prices-history should have its own entry, ordered before /price
1388        assert!(
1389            prices_history_idx < price_idx,
1390            "/prices-history (idx {prices_history_idx}) should come before /price (idx {price_idx})"
1391        );
1392    }
1393
1394    #[test]
1395    fn test_match_mode_prefix_segment_boundary() {
1396        // Verify the Prefix matching logic directly
1397        let pattern = "/price";
1398
1399        let check = |path: &str| -> bool {
1400            match path.strip_prefix(pattern) {
1401                Some(rest) => rest.is_empty() || rest.starts_with('/') || rest.starts_with('?'),
1402                None => false,
1403            }
1404        };
1405
1406        // Should match: exact, sub-path, query params
1407        assert!(check("/price"), "exact match");
1408        assert!(check("/price/foo"), "sub-path");
1409        assert!(check("/price?token=abc"), "query params");
1410
1411        // Should NOT match: partial word overlap
1412        assert!(!check("/prices-history"), "partial word /prices-history");
1413        assert!(!check("/pricelist"), "partial word /pricelist");
1414        assert!(!check("/pricing"), "partial word /pricing");
1415
1416        // Should NOT match: different prefix
1417        assert!(!check("/midpoint"), "different prefix");
1418    }
1419
1420    #[test]
1421    fn test_match_mode_exact() {
1422        // Verify the Exact matching logic
1423        let pattern = "/trades";
1424
1425        let check = |path: &str| -> bool { path == pattern };
1426
1427        assert!(check("/trades"), "exact match");
1428        assert!(!check("/trades/123"), "sub-path should not match");
1429        assert!(!check("/trades?limit=10"), "query params should not match");
1430        assert!(!check("/traded"), "different word should not match");
1431    }
1432
1433    #[tokio::test]
1434    async fn test_acquire_method_filtering() {
1435        let rl = RateLimiter::clob_default();
1436        let start = std::time::Instant::now();
1437        // GET /order shouldn't match POST or DELETE /order endpoints — falls to default only
1438        rl.acquire("/order", Some(&Method::GET)).await;
1439        assert!(start.elapsed() < Duration::from_millis(50));
1440    }
1441
1442    #[tokio::test]
1443    async fn test_acquire_no_endpoint_match_uses_default_only() {
1444        let rl = RateLimiter::clob_default();
1445        let start = std::time::Instant::now();
1446        rl.acquire("/unknown/path", None).await;
1447        assert!(start.elapsed() < Duration::from_millis(50));
1448    }
1449
1450    #[tokio::test]
1451    async fn test_acquire_method_none_matches_any_method() {
1452        let rl = RateLimiter::gamma_default();
1453        let start = std::time::Instant::now();
1454        // /events has method: None — should match GET, POST, and None
1455        rl.acquire("/events", Some(&Method::GET)).await;
1456        rl.acquire("/events", Some(&Method::POST)).await;
1457        rl.acquire("/events", None).await;
1458        assert!(start.elapsed() < Duration::from_millis(50));
1459    }
1460
1461    // ── Prefix collision tests ──────────────────────────────────
1462
1463    #[test]
1464    fn test_clob_price_and_prices_history_are_distinct() {
1465        let rl = RateLimiter::clob_default();
1466        let limits = &rl.inner.limits;
1467
1468        let price = limits.iter().find(|l| l.path_prefix == "/price").unwrap();
1469        let prices_history = limits
1470            .iter()
1471            .find(|l| l.path_prefix == "/prices-history")
1472            .unwrap();
1473
1474        // Both should use Prefix mode
1475        assert_eq!(price.match_mode, MatchMode::Prefix);
1476        assert_eq!(prices_history.match_mode, MatchMode::Prefix);
1477
1478        // Verify "/prices-history" does NOT match the "/price" pattern
1479        if let Some(rest) = "/prices-history".strip_prefix(price.path_prefix) {
1480            assert!(
1481                !rest.is_empty() && !rest.starts_with('/') && !rest.starts_with('?'),
1482                "/prices-history must not match /price pattern, rest = '{rest}'"
1483            );
1484        }
1485    }
1486
1487    #[test]
1488    fn test_data_positions_and_closed_positions_are_distinct() {
1489        // This previously asserted `!"/closed-positions".starts_with("/positions")`
1490        // — a tautology about two string literals that never touched the
1491        // limiter, and so held even with `/closed-positions` set to 66x its
1492        // published cap. Ask the limiter instead.
1493        let rl = RateLimiter::data_default();
1494
1495        let closed = rl.resolve_specs("/closed-positions", Some(&Method::GET));
1496        let positions = rl.resolve_specs("/positions", Some(&Method::GET));
1497        assert_eq!(closed, positions, "both are published at 150/10s");
1498
1499        let bucket_for = |path: &str| {
1500            rl.inner
1501                .limits
1502                .iter()
1503                .find(|l| l.matches(path, Some(&Method::GET)))
1504                .unwrap_or_else(|| panic!("{path} should match a rule"))
1505                .buckets[0]
1506                .clone()
1507        };
1508        assert!(
1509            !Arc::ptr_eq(&bucket_for("/closed-positions"), &bucket_for("/positions")),
1510            "equal quotas must still be separate buckets — upstream publishes \
1511             150/10s each, not 150/10s combined"
1512        );
1513    }
1514
1515    #[test]
1516    fn test_all_clob_endpoints_have_match_mode() {
1517        let rl = RateLimiter::clob_default();
1518        for limit in &rl.inner.limits {
1519            // Every endpoint should have an explicit match mode
1520            assert!(
1521                limit.match_mode == MatchMode::Prefix || limit.match_mode == MatchMode::Exact,
1522                "endpoint {} has no valid match mode",
1523                limit.path_prefix
1524            );
1525        }
1526    }
1527
1528    // ── Concurrent access tests ─────────────────────────────────
1529
1530    #[tokio::test]
1531    async fn concurrent_acquires_are_paced_against_one_shared_allowance() {
1532        // Concurrency must not multiply the allowance. Ten tasks racing on one
1533        // limiter have to serialise into ten successive slots, not each take a
1534        // token of their own — the limiter's state is shared, and this is the
1535        // assertion that says so.
1536        //
1537        // /markets is locally capped at 1,500/10s, pacing at ~6.7ms, so ten
1538        // acquires occupy ~60ms. The floor is the real assertion; the ceiling
1539        // only catches a stall.
1540        const TASKS: u32 = 10;
1541        let interval = Duration::from_secs(10) / (1_500 - 1);
1542
1543        let rl = std::sync::Arc::new(RateLimiter::clob_default());
1544
1545        let start = std::time::Instant::now();
1546        let mut handles = Vec::new();
1547        for _ in 0..TASKS {
1548            let rl = rl.clone();
1549            handles.push(tokio::spawn(async move {
1550                rl.acquire("/markets", None).await;
1551            }));
1552        }
1553        for handle in handles {
1554            handle.await.unwrap();
1555        }
1556        let elapsed = start.elapsed();
1557
1558        assert!(
1559            elapsed >= interval * (TASKS - 1) / 2,
1560            "{TASKS} concurrent acquires completed in {elapsed:?}; pacing at {interval:?} each \
1561             they cannot, so concurrent tasks are not sharing one allowance"
1562        );
1563        assert!(
1564            elapsed < Duration::from_secs(1),
1565            "{TASKS} concurrent acquires took {elapsed:?} — they are stalling, not pacing"
1566        );
1567    }
1568
1569    #[tokio::test]
1570    async fn test_acquire_concurrent_different_endpoints() {
1571        // Concurrent tasks hitting different endpoints should not block each other
1572        let rl = std::sync::Arc::new(RateLimiter::clob_default());
1573
1574        let rl1 = rl.clone();
1575        let rl2 = rl.clone();
1576        let rl3 = rl.clone();
1577
1578        let start = std::time::Instant::now();
1579        let (r1, r2, r3) = tokio::join!(
1580            tokio::spawn(async move { rl1.acquire("/markets", None).await }),
1581            tokio::spawn(async move { rl2.acquire("/auth", None).await }),
1582            tokio::spawn(async move { rl3.acquire("/order", Some(&Method::POST)).await }),
1583        );
1584        r1.unwrap();
1585        r2.unwrap();
1586        r3.unwrap();
1587
1588        assert!(
1589            start.elapsed() < Duration::from_millis(50),
1590            "different endpoints should not block: {:?}",
1591            start.elapsed()
1592        );
1593    }
1594
1595    // ── Dual-window interaction tests ───────────────────────────
1596
1597    #[test]
1598    fn test_clob_post_order_has_dual_window() {
1599        let rl = RateLimiter::clob_default();
1600        let post_order = rl
1601            .inner
1602            .limits
1603            .iter()
1604            .find(|l| l.path_prefix == "/order" && l.method == Some(Method::POST))
1605            .expect("POST /order endpoint should exist");
1606
1607        assert_eq!(
1608            post_order.buckets.len(),
1609            2,
1610            "POST /order should have a burst and a sustained window"
1611        );
1612    }
1613
1614    #[test]
1615    fn test_clob_delete_order_has_a_sustained_window_too() {
1616        // This previously asserted the *opposite* — that DELETE /order had only
1617        // a burst window — and so pinned the omission in place. Upstream
1618        // publishes 5,000/10s burst plus 120,000/10min sustained.
1619        let rl = RateLimiter::clob_default();
1620        let delete_order = rl
1621            .inner
1622            .limits
1623            .iter()
1624            .find(|l| l.path_prefix == "/order" && l.method == Some(Method::DELETE))
1625            .expect("DELETE /order endpoint should exist");
1626
1627        assert_eq!(
1628            delete_order.buckets.len(),
1629            2,
1630            "DELETE /order should have both a burst and a sustained window"
1631        );
1632    }
1633
1634    #[tokio::test]
1635    async fn test_dual_window_both_burst_and_sustained_are_awaited() {
1636        // POST /order should await both burst and sustained limiters.
1637        // With high limits, a single acquire should still complete fast.
1638        let rl = RateLimiter::clob_default();
1639        let start = std::time::Instant::now();
1640        rl.acquire("/order", Some(&Method::POST)).await;
1641        assert!(
1642            start.elapsed() < Duration::from_millis(50),
1643            "dual window single acquire should be fast: {:?}",
1644            start.elapsed()
1645        );
1646    }
1647
1648    // ── should_retry edge cases ─────────────────────────────────
1649
1650    #[test]
1651    fn test_should_retry_exhaustion() {
1652        // After max_retries, should_retry must return None
1653        let client = crate::HttpClientBuilder::new("https://example.com")
1654            .with_retry_config(RetryConfig {
1655                max_retries: 3,
1656                ..RetryConfig::default()
1657            })
1658            .build()
1659            .unwrap();
1660
1661        // Attempts 0, 1, 2 should succeed
1662        for attempt in 0..3 {
1663            assert!(
1664                client
1665                    .should_retry(reqwest::StatusCode::TOO_MANY_REQUESTS, attempt, None)
1666                    .is_some(),
1667                "attempt {attempt} should allow retry"
1668            );
1669        }
1670        // Attempt 3 should give up
1671        assert!(
1672            client
1673                .should_retry(reqwest::StatusCode::TOO_MANY_REQUESTS, 3, None)
1674                .is_none(),
1675            "attempt 3 should exhaust retries"
1676        );
1677    }
1678
1679    #[test]
1680    fn test_should_retry_zero_max_retries_never_retries() {
1681        let client = crate::HttpClientBuilder::new("https://example.com")
1682            .with_retry_config(RetryConfig {
1683                max_retries: 0,
1684                ..RetryConfig::default()
1685            })
1686            .build()
1687            .unwrap();
1688
1689        assert!(
1690            client
1691                .should_retry(reqwest::StatusCode::TOO_MANY_REQUESTS, 0, None)
1692                .is_none(),
1693            "max_retries=0 should never retry"
1694        );
1695    }
1696}
1697
1698#[cfg(test)]
1699mod cooldown_tests {
1700    //! A 429 is a fact about the host, not about the request that saw it.
1701    //!
1702    //! The token buckets above model the *published* quota, which is all a
1703    //! client can know in advance. When the server disagrees — Cloudflare's
1704    //! `error code: 1015` arrives as a 429 whatever our buckets believe — that
1705    //! correction has to reach every request sharing the limiter, or the
1706    //! siblings already in flight keep feeding a ban that is timed, and so gets
1707    //! longer the more it is hit.
1708
1709    use super::*;
1710
1711    #[tokio::test(start_paused = true)]
1712    async fn acquire_is_immediate_without_a_cooldown() {
1713        let rl = RateLimiter::data_default();
1714        let t = tokio::time::Instant::now();
1715        rl.acquire("/closed-positions", None).await;
1716        assert!(
1717            t.elapsed() < Duration::from_millis(1),
1718            "an untripped limiter must not delay: waited {:?}",
1719            t.elapsed()
1720        );
1721    }
1722
1723    #[tokio::test(start_paused = true)]
1724    async fn a_cooldown_holds_back_a_path_that_never_saw_the_429() {
1725        let rl = RateLimiter::data_default();
1726        rl.begin_cooldown(Duration::from_secs(5));
1727
1728        // /trades has its own bucket, full and untouched. It must wait anyway:
1729        // the block is on the IP, and every path shares it.
1730        let t = tokio::time::Instant::now();
1731        rl.acquire("/trades", None).await;
1732        assert!(
1733            t.elapsed() >= Duration::from_secs(5),
1734            "a sibling path resumed after {:?}, before the cooldown expired",
1735            t.elapsed()
1736        );
1737    }
1738
1739    #[tokio::test(start_paused = true)]
1740    async fn concurrent_requests_all_observe_one_cooldown() {
1741        let rl = RateLimiter::data_default();
1742        rl.begin_cooldown(Duration::from_secs(3));
1743
1744        // The shape from the report: several /closed-positions calls in flight
1745        // at once. One 429 has to stop all of them, not just its own caller.
1746        let t = tokio::time::Instant::now();
1747        tokio::join!(
1748            rl.acquire("/closed-positions", None),
1749            rl.acquire("/closed-positions", None),
1750            rl.acquire("/closed-positions", None),
1751            rl.acquire("/closed-positions", None),
1752        );
1753        assert!(
1754            t.elapsed() >= Duration::from_secs(3),
1755            "concurrent callers resumed after {:?}",
1756            t.elapsed()
1757        );
1758    }
1759
1760    #[tokio::test(start_paused = true)]
1761    async fn a_shorter_cooldown_never_cuts_a_longer_one_short() {
1762        let rl = RateLimiter::data_default();
1763        rl.begin_cooldown(Duration::from_secs(10));
1764        // A sibling's 429 lands next, carrying a smaller delay. Taking the
1765        // latest value would let the shortest response win the race and
1766        // release everyone early.
1767        rl.begin_cooldown(Duration::from_secs(1));
1768
1769        let t = tokio::time::Instant::now();
1770        rl.acquire("/positions", None).await;
1771        assert!(
1772            t.elapsed() >= Duration::from_secs(10),
1773            "the longer cooldown was truncated to {:?}",
1774            t.elapsed()
1775        );
1776    }
1777
1778    #[tokio::test(start_paused = true)]
1779    async fn a_cooldown_extended_mid_wait_is_honoured_in_full() {
1780        let rl = RateLimiter::data_default();
1781        rl.begin_cooldown(Duration::from_secs(2));
1782
1783        let extender = {
1784            let rl = rl.clone();
1785            tokio::spawn(async move {
1786                tokio::time::sleep(Duration::from_secs(1)).await;
1787                rl.begin_cooldown(Duration::from_secs(5));
1788            })
1789        };
1790
1791        let t = tokio::time::Instant::now();
1792        rl.acquire("/closed-positions", None).await;
1793        extender.await.unwrap();
1794        // Extended to 1s + 5s = 6s. Waking at the original 2s deadline and
1795        // returning would resume straight into the still-active ban.
1796        assert!(
1797            t.elapsed() >= Duration::from_secs(6),
1798            "resumed at {:?}, ignoring the cooldown extension",
1799            t.elapsed()
1800        );
1801    }
1802
1803    #[tokio::test(start_paused = true)]
1804    async fn an_expired_cooldown_stops_delaying() {
1805        let rl = RateLimiter::data_default();
1806        rl.begin_cooldown(Duration::from_secs(2));
1807        rl.acquire("/closed-positions", None).await;
1808
1809        let t = tokio::time::Instant::now();
1810        rl.acquire("/closed-positions", None).await;
1811        assert!(
1812            t.elapsed() < Duration::from_millis(1),
1813            "the limiter stayed blocked for {:?} after the cooldown expired",
1814            t.elapsed()
1815        );
1816    }
1817}