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("a(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("a(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}