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