Skip to main content

polyoxide_core/
rate_limit.rs

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