polyoxide_core/rate_limit.rs
1//! Window quotas: [`WindowQuotaTable`] builds a [`RateLimiter`], which counts
2//! requests against the buckets a request's method and path resolve to.
3//!
4//! Polymarket's tables are built in [`polymarket`](crate::polymarket).
5
6use std::sync::atomic::{AtomicU32, Ordering};
7use std::sync::Arc;
8use std::time::Duration;
9
10use crate::hold::Hold;
11use crate::hooks::{AttemptInfo, Charge, Refused, RequestMeta, ResponseMeta, Throttle};
12use governor::Quota;
13use reqwest::Method;
14
15type DirectLimiter = governor::RateLimiter<
16 governor::state::NotKeyed,
17 governor::state::InMemoryState,
18 governor::clock::DefaultClock,
19>;
20
21/// How a row's pattern is matched against a request path.
22#[derive(Debug, Clone, Copy, PartialEq, Eq)]
23pub enum Matching {
24 /// The path starts with the pattern at a segment boundary: followed by
25 /// `/`, `?`, or nothing. `/price` matches `/price`, `/price/1` and
26 /// `/price?id=1`, and never `/prices-history`.
27 Prefix,
28 /// The path is the pattern, and nothing more.
29 Exact,
30}
31
32/// A quota as published: `count` requests per `period`.
33///
34/// Kept alongside the limiter so tests can assert the configured allowance
35/// against the documented table rather than merely checking an entry exists.
36#[derive(Debug, Clone, Copy, PartialEq, Eq)]
37struct RateSpec {
38 count: u32,
39 period: Duration,
40}
41
42/// One token bucket, shareable between endpoint patterns.
43///
44/// Sharing is what lets several paths sit under a single cap: upstream limits
45/// `/trades`, `/orders`, `/notifications` and `/order` to 900/10s *combined*,
46/// which four independent buckets would silently turn into 3,600/10s.
47struct Bucket {
48 id: BucketId,
49 spec: RateSpec,
50 /// The quota `limiter` was built from, read back by [`EffectiveQuota`].
51 quota: Quota,
52 limiter: DirectLimiter,
53}
54
55impl Bucket {
56 fn new(id: BucketId, count: u32, period: Duration) -> Arc<Self> {
57 let quota = quota(count, period);
58 Arc::new(Self {
59 id,
60 spec: RateSpec { count, period },
61 quota,
62 limiter: DirectLimiter::direct(quota),
63 })
64 }
65
66 fn effective(&self) -> EffectiveQuota {
67 EffectiveQuota {
68 bucket: self.id,
69 count: self.spec.count,
70 period: self.spec.period,
71 quota: self.quota,
72 }
73 }
74}
75
76/// Rate limit configuration for a specific endpoint pattern.
77struct EndpointLimit {
78 path_prefix: &'static str,
79 method: Option<Method>,
80 match_mode: Matching,
81 /// Every bucket a matching request must pass, awaited in order.
82 buckets: Vec<Arc<Bucket>>,
83}
84
85impl EndpointLimit {
86 /// Whether this entry governs the given request.
87 ///
88 /// Shared by [`RateLimiter::acquire`] and [`RateLimiter::effective_quota`]
89 /// so the two cannot disagree about which rule applies.
90 fn matches(&self, path: &str, method: Option<&Method>) -> bool {
91 let path_matches = match self.match_mode {
92 Matching::Exact => path == self.path_prefix,
93 Matching::Prefix => {
94 // Ensure we're at a segment boundary, not a partial word match.
95 // "/price" should match "/price" and "/price/foo" but not "/prices-history".
96 match path.strip_prefix(self.path_prefix) {
97 Some(rest) => rest.is_empty() || rest.starts_with('/') || rest.starts_with('?'),
98 None => false,
99 }
100 }
101 };
102 if !path_matches {
103 return false;
104 }
105 match &self.method {
106 Some(expected) => method == Some(expected),
107 None => true,
108 }
109 }
110}
111
112/// Window quotas for one API surface: a general bucket every request passes,
113/// then the buckets of the first row its method and path match.
114///
115/// Built by [`WindowQuotaTable`]. Polymarket's tables are
116/// [`polymarket::clob_limits`](crate::polymarket::clob_limits) and its
117/// siblings. A clone shares the buckets and the [`Hold`].
118#[derive(Clone)]
119pub struct RateLimiter {
120 inner: Arc<RateLimiterInner>,
121}
122
123impl std::fmt::Debug for RateLimiter {
124 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
125 f.debug_struct("RateLimiter")
126 .field("endpoints", &self.inner.limits.len())
127 .finish()
128 }
129}
130
131struct RateLimiterInner {
132 limits: Vec<EndpointLimit>,
133 general: Arc<Bucket>,
134 /// What the server said, as against what the buckets model: no request
135 /// on this limiter proceeds before it.
136 hold: Hold,
137}
138
139/// Helper to create a quota: at most `count` requests in *any* window of
140/// length `period`.
141///
142/// **There is deliberately no `allow_burst` call here.** `Quota::with_period`
143/// leaves capacity at a single token, and keeping it there is the entire point.
144/// A token bucket admits its depth *plus* everything the refill adds, so across
145/// a window of length `period` it lets through `burst + rate × period`. Funding
146/// a burst of `count` on top of a rate of `count/period` spends the published
147/// allowance twice — which is what this function did for every entry in every
148/// table until it was measured.
149///
150/// Depth is not free capacity; it is borrowed against the rate. Satisfying the
151/// bound with a burst of `B` costs `B` requests of sustained allowance forever,
152/// so the minimum depth is also the maximum throughput: 149/10s here rather
153/// than the 135/10s a 10% burst would leave. It is the safer shape too: a
154/// request that arrives during a hold waits before any bucket and is paced
155/// when the hold ends, where a burst allowance would fire every one of them
156/// as a spike immediately after a ban. Only a request already waiting on a
157/// bucket when the hold began takes its token during the hold; those are
158/// released together when it ends, so that burst is at most the requests in
159/// flight then (on the send loop, the concurrency permits).
160///
161/// `count < 2` degenerates to admitting 2 per window, since a bucket cannot
162/// hold less than one token. No published row is that small.
163///
164/// # Why it aims below the published count
165///
166/// Because the published count turns out not to be reachable as a rate.
167/// Measured against `data-api.polymarket.com` on `/closed-positions`, which
168/// publishes 150/10s:
169///
170/// | Sustained rate | Share of published | Result |
171/// |---|---|---|
172/// | 14.9/s (149 per 10s) | 100% | refused after 15.7s |
173/// | 14.25/s (142.5 per 10s) | 95% | refused after 17.3s |
174/// | 13.5/s (135 per 10s) | 90% | clean over 180s, 2,430 requests |
175///
176/// A one-shot burst of exactly 150 *is* accepted, so this is not the table
177/// overstating the cap: the count is reachable as a burst and not as a rate.
178/// Cloudflare's sliding-window estimator does not count the way a naive
179/// interval count does, and nothing outside the server can observe the
180/// difference — so the only safe response is to aim below the line rather than
181/// at it. [`RESERVED_FRACTION`] is that margin, measured rather than
182/// conventional: 95% is known-refused, 90% is known-clean.
183fn quota(count: u32, period: Duration) -> Quota {
184 Quota::with_period(WindowQuotaTable::paced_interval(count, period))
185 .expect("quota interval must be non-zero")
186}
187
188/// Requests `q` admits in the worst-case window of length `period`: the full
189/// bucket drained at `t=0`, plus every token the refill adds by `t=period`.
190///
191/// This is the quantity the published table bounds. Buckets start full, so the
192/// worst case is always a fresh limiter.
193fn admitted_in_one_window(q: &Quota, period: Duration) -> u128 {
194 let refilled = period.as_nanos() / q.replenish_interval().as_nanos();
195 u128::from(q.burst_size().get()) + refilled
196}
197
198/// Slots per `period` the client actually paces out for a published `count`:
199/// the count, less its reserve, less the single token of depth.
200///
201/// Read through [`WindowQuotaTable::paced_interval`], which the runtime
202/// agreement tests share, so their expected pacing cannot drift from what
203/// [`quota`] builds. They would still catch a request routed to
204/// the wrong bucket — a different `count` yields a different interval — but a
205/// hand-copied formula here would silently loosen them the next time the
206/// reserve changes.
207fn sustained_slots(count: u32) -> u32 {
208 let target = count.saturating_sub(count.div_ceil(RESERVED_FRACTION));
209 target.max(2) - 1
210}
211
212/// Reciprocal of the share of each published quota the client leaves unused:
213/// `10` reserves a tenth, so the client targets 90%.
214///
215/// Measured, not chosen — see the table on [`quota`].
216const RESERVED_FRACTION: u32 = 10;
217
218#[cfg(test)]
219mod quota_arithmetic {
220 //! The bound every bucket has to satisfy, checked as arithmetic.
221 //!
222 //! `agreement::assert_throttles_after` pins a bucket's *depth*: drain
223 //! `count` and the next call has to wait, so capacity is no larger than the
224 //! published figure. It says nothing about the refill rate, and depth and
225 //! rate are two separate spends of one budget. A bucket holding `count`
226 //! tokens that also replenishes `count` per `period` passes that test and
227 //! still admits `2 * count` in a single window — each assertion true, the
228 //! conjunction they exist to guarantee false.
229 //!
230 //! Measured against the live host on `/closed-positions` (150/10s): a
231 //! one-shot burst of exactly 150 in 0.70s is accepted, while sustained runs
232 //! tripped Cloudflare's `error code: 1015` at ~152 cumulative requests —
233 //! twice, at different rates, which is the signature of a cumulative cap
234 //! rather than a rate one. Upstream's published figure is accurate in both
235 //! count and window; the client was spending it twice.
236
237 use super::*;
238
239 /// The four general-purpose default buckets, plus a spread of endpoint
240 /// shapes for good measure.
241 ///
242 /// The defaults are the reason this list exists at all: they are built as
243 /// bare `DirectLimiter`s carrying no `RateSpec`, so the sweep below — which
244 /// walks the configured tables — cannot see them, and they are the largest
245 /// allowance on every surface.
246 const PUBLISHED_SHAPES: &[(u32, u64)] = &[
247 (9_000, 10), // clob default
248 (4_000, 10), // gamma default
249 (1_000, 10), // data default / clob /prices-history
250 (25, 60), // relay default
251 (150, 10), // data /closed-positions, /positions
252 (200, 10), // data /trades, clob /balance-allowance
253 (300, 10), // gamma /markets
254 (350, 10), // gamma /public-search
255 (500, 10), // gamma /events
256 (100, 10), // health routes
257 (50, 10), // clob /balance-allowance/update
258 (5_000, 10), // clob /order burst window
259 (120_000, 600), // clob /order sustained window
260 ];
261
262 #[test]
263 fn no_quota_admits_more_than_its_published_count_in_one_window() {
264 for &(count, secs) in PUBLISHED_SHAPES {
265 let period = Duration::from_secs(secs);
266 let q = quota(count, period);
267 let admitted = admitted_in_one_window(&q, period);
268
269 assert!(
270 admitted <= u128::from(count),
271 "{count}/{secs}s admits {admitted} in one window \
272 ({} burst + {} refilled) — the published quota is spent twice",
273 q.burst_size(),
274 admitted - u128::from(q.burst_size().get()),
275 );
276 }
277 }
278
279 #[test]
280 fn every_quota_reserves_headroom_below_the_published_count() {
281 // Satisfying the published count exactly is not enough, because the
282 // published count is not actually reachable. Measured on
283 // `/closed-positions` (150/10s) against the live host: a sustained
284 // 142.5/10s — 95% — was refused after 17.3s, and the client's own
285 // exactly-100% pacing was refused after 15.7s, while 135/10s ran clean.
286 // Cloudflare's sliding-window estimator does not count the way a naive
287 // interval count does, and the client cannot observe the difference, so
288 // it aims below the line rather than at it.
289 for &(count, secs) in PUBLISHED_SHAPES {
290 let period = Duration::from_secs(secs);
291 let admitted = admitted_in_one_window("a(count, period), period);
292 let ceiling = u128::from(count - count.div_ceil(RESERVED_FRACTION));
293
294 assert!(
295 admitted <= ceiling,
296 "{count}/{secs}s admits {admitted} in one window, above the {ceiling} \
297 the reserve allows — no headroom under the published cap"
298 );
299 }
300 }
301
302 #[test]
303 fn a_published_150_per_10s_admits_135_per_window() {
304 // The reserve's golden value. On `/closed-positions` (150/10s) a
305 // sustained 135 per 10s ran clean for 180s and 142.5 was refused. The
306 // two sweeps above derive their ceiling from `RESERVED_FRACTION`
307 // itself, so a changed reserve still passes them; this pins the 90%.
308 let period = Duration::from_secs(10);
309 assert_eq!(admitted_in_one_window("a(150, period), period), 135);
310 }
311}
312
313/// One bucket of a [`WindowQuotaTable`], and of the [`RateLimiter`] it builds.
314///
315/// Rows that name one `BucketId` share one allowance.
316#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
317pub struct BucketId {
318 table: u32,
319 index: u32,
320}
321
322/// Tables made so far, so each [`BucketId`] names the table that made it.
323static TABLES: AtomicU32 = AtomicU32::new(0);
324
325/// A quota a request is held to: one bucket, admitting `count` requests per
326/// `period` and paced as [`WindowQuotaTable`] describes.
327#[derive(Debug, Clone, Copy, PartialEq, Eq)]
328pub struct EffectiveQuota {
329 /// The bucket. Two quotas naming one bucket are one allowance.
330 pub bucket: BucketId,
331 /// The published count.
332 pub count: u32,
333 /// The published window.
334 pub period: Duration,
335 quota: Quota,
336}
337
338impl EffectiveQuota {
339 /// The interval the bucket paces requests at, read from the bucket itself.
340 pub fn interval(&self) -> Duration {
341 self.quota.replenish_interval()
342 }
343
344 /// Requests the bucket admits in the worst-case window of length
345 /// `period`: its whole depth at once, plus every token its refill adds by
346 /// the window's end. A fresh bucket is the worst case, since buckets
347 /// start full.
348 pub fn admitted_in_one_window(&self) -> u128 {
349 admitted_in_one_window(&self.quota, self.period)
350 }
351}
352
353/// One row of a [`RateLimiter`], as [`RateLimiter::rows`] reports it.
354#[derive(Debug, Clone, PartialEq, Eq)]
355pub struct QuotaRow<'a> {
356 /// The path pattern.
357 pub pattern: &'static str,
358 /// The method the row is scoped to. `None` matches every method.
359 pub method: Option<&'a Method>,
360 /// How `pattern` is matched.
361 pub matching: Matching,
362 /// The row's buckets, in the order a request awaits them.
363 pub buckets: Vec<EffectiveQuota>,
364}
365
366/// Builds a [`RateLimiter`] from a venue's window quotas: `count` requests in
367/// any window of length `period`.
368///
369/// Every request passes the general bucket, then the buckets of the first row
370/// whose pattern and method it matches, in the order the row lists them. Two
371/// rows naming one [`BucketId`] share one allowance: Polymarket caps four
372/// ledger routes at 900 per 10 s combined, which four buckets of their own
373/// would turn into 3,600.
374///
375/// # How a bucket paces
376///
377/// Each bucket holds one token and refills one every
378/// [`paced_interval`](Self::paced_interval), which aims at nine tenths of
379/// `count`. There is no burst setting, on purpose:
380///
381/// - A token bucket admits its depth *plus* everything its refill adds. A
382/// bucket holding `count` tokens and refilling `count` per `period` admits
383/// twice the quota in one window. Depth is borrowed against the rate, so
384/// the least depth is also the most throughput.
385/// - A request that arrives during a hold waits before any bucket, and is
386/// paced when the hold ends. A request already waiting on a bucket when the
387/// hold begins takes its token, then waits out the hold too, and is
388/// released with the others when it ends: that burst is at most the
389/// requests in flight when the hold began (on the send loop, the
390/// concurrency permits).
391/// - A published count is reachable as a burst and not as a rate. On
392/// Polymarket's `/closed-positions` (150 per 10 s) a sustained 95% was
393/// refused and 90% ran clean for 180 s, so a bucket keeps a tenth back.
394///
395/// A count below 2 paces as 2 per window, since a bucket holds at least one
396/// token.
397///
398/// # Example
399///
400/// ```
401/// use std::time::Duration;
402///
403/// use polyoxide_core::{Matching, WindowQuotaTable};
404/// use reqwest::Method;
405///
406/// let ten_seconds = Duration::from_secs(10);
407/// let mut table = WindowQuotaTable::new(1_000, ten_seconds);
408/// let ledger = table.bucket(900, ten_seconds);
409/// table
410/// .prefix("/positions", None, 150, ten_seconds)
411/// .row(Matching::Prefix, "/trades", Some(Method::GET), &[ledger])
412/// .row(Matching::Exact, "/orders", Some(Method::GET), &[ledger]);
413/// let limiter = table.build();
414///
415/// // The general bucket first, then the row's.
416/// let trades = limiter.effective_quota(&Method::GET, "/trades");
417/// let orders = limiter.effective_quota(&Method::GET, "/orders");
418/// assert_eq!(trades[1].bucket, orders[1].bucket);
419/// assert_eq!(trades[1].admitted_in_one_window(), 810);
420/// // An exact row does not match a sub-path.
421/// assert_eq!(limiter.effective_quota(&Method::GET, "/orders/1").len(), 1);
422/// ```
423pub struct WindowQuotaTable {
424 id: u32,
425 /// The general bucket first, then every bucket in the order it was made.
426 buckets: Vec<Arc<Bucket>>,
427 rows: Vec<EndpointLimit>,
428 hold: Hold,
429}
430
431impl WindowQuotaTable {
432 /// A table whose general bucket admits `count` requests per `period`.
433 ///
434 /// # Panics
435 ///
436 /// When `period` is too short to pace `count`, since a bucket that paces
437 /// at an interval of zero would never hold anyone back: a zero `period`,
438 /// or one shorter than a nanosecond per request it paces.
439 pub fn new(count: u32, period: Duration) -> Self {
440 let mut table = Self {
441 id: TABLES.fetch_add(1, Ordering::Relaxed),
442 buckets: Vec::new(),
443 rows: Vec::new(),
444 hold: Hold::unbounded(),
445 };
446 table.bucket(count, period);
447 table
448 }
449
450 /// Make a bucket admitting `count` requests per `period`, for rows to
451 /// name.
452 ///
453 /// # Panics
454 ///
455 /// When `period` is too short to pace `count`, since a bucket that paces
456 /// at an interval of zero would never hold anyone back: a zero `period`,
457 /// or one shorter than a nanosecond per request it paces.
458 pub fn bucket(&mut self, count: u32, period: Duration) -> BucketId {
459 let id = BucketId {
460 table: self.id,
461 index: u32::try_from(self.buckets.len()).expect("fewer than 2^32 buckets"),
462 };
463 self.buckets.push(Bucket::new(id, count, period));
464 id
465 }
466
467 /// Add a row: a request whose path matches `pattern`, and whose method is
468 /// `method` when one is given, awaits `buckets` in order.
469 ///
470 /// Only the first row a request matches applies, so a pattern goes before
471 /// any shorter one that also matches it.
472 ///
473 /// # Panics
474 ///
475 /// When one of `buckets` was made by another table.
476 pub fn row(
477 &mut self,
478 matching: Matching,
479 pattern: &'static str,
480 method: Option<Method>,
481 buckets: &[BucketId],
482 ) -> &mut Self {
483 let buckets = buckets
484 .iter()
485 .map(|id| {
486 assert_eq!(id.table, self.id, "{id:?} was made by another table");
487 self.buckets[id.index as usize].clone()
488 })
489 .collect();
490 self.rows.push(EndpointLimit {
491 path_prefix: pattern,
492 method,
493 match_mode: matching,
494 buckets,
495 });
496 self
497 }
498
499 /// Add a [`Matching::Prefix`] row with a bucket of its own, admitting
500 /// `count` requests per `period`.
501 ///
502 /// # Panics
503 ///
504 /// When `period` is too short to pace `count`, since a bucket that paces
505 /// at an interval of zero would never hold anyone back: a zero `period`,
506 /// or one shorter than a nanosecond per request it paces.
507 pub fn prefix(
508 &mut self,
509 pattern: &'static str,
510 method: Option<Method>,
511 count: u32,
512 period: Duration,
513 ) -> &mut Self {
514 let bucket = self.bucket(count, period);
515 self.row(Matching::Prefix, pattern, method, &[bucket])
516 }
517
518 /// Wait out `hold` instead of a hold of the limiter's own, so that
519 /// every layer sharing it holds together. Default: [`Hold::unbounded`].
520 pub fn with_hold(&mut self, hold: Hold) -> &mut Self {
521 self.hold = hold;
522 self
523 }
524
525 /// The limiter, with every bucket full.
526 pub fn build(self) -> RateLimiter {
527 RateLimiter {
528 inner: Arc::new(RateLimiterInner {
529 general: self.buckets[0].clone(),
530 limits: self.rows,
531 hold: self.hold,
532 }),
533 }
534 }
535
536 /// The interval a bucket for `count` requests per `period` paces at:
537 /// `period` over nine tenths of `count`, less the one token of depth.
538 ///
539 /// The one place the pacing formula lives; every bucket is built from it.
540 pub fn paced_interval(count: u32, period: Duration) -> Duration {
541 period / sustained_slots(count)
542 }
543}
544
545impl RateLimiter {
546 /// Hold every request on this limiter for `delay`: [`Hold::extend`] on
547 /// its hold, which extends a hold in force but never shortens one.
548 ///
549 /// The send loop holds through [`Throttle::hold`], which calls this, when
550 /// its policy decides a response holds the client. Reach for this directly
551 /// only when driving the limiter from a transport this crate does not own.
552 pub fn begin_cooldown(&self, delay: Duration) {
553 self.inner.hold.extend(delay);
554 }
555
556 /// The hold every request on this limiter waits out, to share with
557 /// another layer.
558 pub fn hold(&self) -> &Hold {
559 &self.inner.hold
560 }
561
562 /// Await the appropriate limiter(s) for this endpoint.
563 ///
564 /// Waits out any hold a previous 429 imposed, then awaits the default
565 /// (general) limiter, then additionally awaits the first matching
566 /// endpoint-specific limiter (burst + sustained), then waits out the hold
567 /// again, in case it moved while this call waited on a bucket.
568 pub async fn acquire(&self, path: &str, method: Option<&Method>) {
569 self.inner.hold.wait().await;
570 self.inner.general.limiter.until_ready().await;
571
572 if let Some(limit) = self.row_for(path, method) {
573 for bucket in &limit.buckets {
574 bucket.limiter.until_ready().await;
575 }
576 }
577 // AD-23: a hold set while this call waited on a bucket is honoured,
578 // rather than sending into it.
579 self.inner.hold.wait().await;
580 }
581
582 /// The quotas a request is held to, in the order
583 /// [`acquire`](Self::acquire) awaits them: the general bucket first, then
584 /// the buckets of the first row `method` and `path` match.
585 pub fn effective_quota(&self, method: &Method, path: &str) -> Vec<EffectiveQuota> {
586 std::iter::once(&self.inner.general)
587 .chain(
588 self.row_for(path, Some(method))
589 .into_iter()
590 .flat_map(|limit| &limit.buckets),
591 )
592 .map(|bucket| bucket.effective())
593 .collect()
594 }
595
596 /// Every row, in the order requests are matched against them.
597 pub fn rows(&self) -> Vec<QuotaRow<'_>> {
598 self.inner
599 .limits
600 .iter()
601 .map(|limit| QuotaRow {
602 pattern: limit.path_prefix,
603 method: limit.method.as_ref(),
604 matching: limit.match_mode,
605 buckets: limit.buckets.iter().map(|b| b.effective()).collect(),
606 })
607 .collect()
608 }
609
610 /// The first row a request matches.
611 fn row_for(&self, path: &str, method: Option<&Method>) -> Option<&EndpointLimit> {
612 self.inner.limits.iter().find(|l| l.matches(path, method))
613 }
614}
615
616/// Counts requests: each attempt is charged one request against the general
617/// bucket and its row's buckets, found from the method and path, and
618/// [`hold`](Throttle::hold) extends the limiter's [`Hold`].
619impl Throttle for RateLimiter {
620 async fn acquire(&self, meta: &RequestMeta<'_>) -> Result<Charge, Refused> {
621 RateLimiter::acquire(self, meta.path, Some(meta.method)).await;
622 Ok(Charge::none())
623 }
624
625 /// Nothing to record: the buckets model the published quota, not what a
626 /// response says.
627 fn observe(&self, _charge: &Charge, _response: &ResponseMeta<'_>, _attempt: &AttemptInfo) {}
628
629 fn hold(&self, delay: Duration) {
630 self.inner.hold.extend(delay);
631 }
632}
633
634#[cfg(test)]
635mod window_table {
636 //! [`WindowQuotaTable`], through the API a venue outside core sees.
637
638 use super::*;
639
640 const TEN_SECONDS: Duration = Duration::from_secs(10);
641 const TEN_MINUTES: Duration = Duration::from_secs(600);
642
643 /// `(count, period)` of each quota, general bucket first.
644 fn shape(quotas: &[EffectiveQuota]) -> Vec<(u32, Duration)> {
645 quotas.iter().map(|q| (q.count, q.period)).collect()
646 }
647
648 #[test]
649 fn a_request_awaits_the_general_bucket_then_its_rows_buckets_in_order() {
650 let mut table = WindowQuotaTable::new(1_000, TEN_SECONDS);
651 let burst = table.bucket(500, TEN_SECONDS);
652 let sustained = table.bucket(6_000, TEN_MINUTES);
653 table.row(
654 Matching::Prefix,
655 "/order",
656 Some(Method::POST),
657 &[burst, sustained],
658 );
659 let limiter = table.build();
660
661 let quotas = limiter.effective_quota(&Method::POST, "/order");
662 assert_eq!(
663 shape("as),
664 [
665 (1_000, TEN_SECONDS),
666 (500, TEN_SECONDS),
667 (6_000, TEN_MINUTES)
668 ]
669 );
670 assert_eq!(quotas[1].bucket, burst);
671 assert_eq!(quotas[2].bucket, sustained);
672 assert_eq!(
673 shape(&limiter.effective_quota(&Method::GET, "/elsewhere")),
674 [(1_000, TEN_SECONDS)],
675 "an unmatched request is held to the general bucket alone"
676 );
677 }
678
679 #[tokio::test]
680 async fn two_rows_naming_one_bucket_share_one_allowance() {
681 let mut table = WindowQuotaTable::new(100_000, TEN_SECONDS);
682 let shared = table.bucket(100, TEN_SECONDS);
683 table
684 .row(Matching::Prefix, "/a", None, &[shared])
685 .row(Matching::Prefix, "/b", None, &[shared])
686 .prefix("/c", None, 100, TEN_SECONDS);
687 let limiter = table.build();
688
689 let a = limiter.effective_quota(&Method::GET, "/a")[1].bucket;
690 let b = limiter.effective_quota(&Method::GET, "/b")[1].bucket;
691 let c = limiter.effective_quota(&Method::GET, "/c")[1].bucket;
692 assert_eq!(a, b);
693 assert_ne!(a, c, "a row's own bucket is shared with nobody");
694
695 // 100 per 10 s paces at ~112ms: /b waits for the token /a took.
696 limiter.acquire("/a", None).await;
697 let start = std::time::Instant::now();
698 limiter.acquire("/b", None).await;
699 assert!(
700 start.elapsed() >= Duration::from_millis(80),
701 "/b went out after {:?}, so /a and /b are not one allowance",
702 start.elapsed()
703 );
704 }
705
706 #[test]
707 fn the_first_matching_row_wins() {
708 let mut table = WindowQuotaTable::new(1_000, TEN_SECONDS);
709 table
710 .prefix("/data/orders", None, 50, TEN_SECONDS)
711 .prefix("/data", None, 500, TEN_SECONDS)
712 .prefix("/data/trades", None, 60, TEN_SECONDS);
713 let limiter = table.build();
714
715 let count = |path| limiter.effective_quota(&Method::GET, path)[1].count;
716 assert_eq!(count("/data/orders"), 50);
717 assert_eq!(count("/data"), 500);
718 assert_eq!(
719 count("/data/trades"),
720 500,
721 "a row after a shorter pattern that matches it is never reached"
722 );
723 }
724
725 #[test]
726 fn a_prefix_row_matches_only_at_a_segment_boundary() {
727 let mut table = WindowQuotaTable::new(1_000, TEN_SECONDS);
728 table.prefix("/price", None, 100, TEN_SECONDS);
729 let limiter = table.build();
730
731 for path in ["/price", "/price/1", "/price?token=1"] {
732 assert_eq!(
733 limiter.effective_quota(&Method::GET, path).len(),
734 2,
735 "{path}"
736 );
737 }
738 for path in ["/prices-history", "/pricing", "/midpoint"] {
739 assert_eq!(
740 limiter.effective_quota(&Method::GET, path).len(),
741 1,
742 "{path}"
743 );
744 }
745 }
746
747 #[test]
748 fn an_exact_row_matches_only_its_own_path() {
749 let mut table = WindowQuotaTable::new(1_000, TEN_SECONDS);
750 let bucket = table.bucket(100, TEN_SECONDS);
751 table.row(Matching::Exact, "/trades", None, &[bucket]);
752 let limiter = table.build();
753
754 assert_eq!(limiter.effective_quota(&Method::GET, "/trades").len(), 2);
755 for path in ["/trades/1", "/trades?limit=10", "/traded"] {
756 assert_eq!(
757 limiter.effective_quota(&Method::GET, path).len(),
758 1,
759 "{path}"
760 );
761 }
762 }
763
764 #[test]
765 fn a_method_scoped_row_ignores_other_methods() {
766 let mut table = WindowQuotaTable::new(1_000, TEN_SECONDS);
767 table
768 .prefix("/order", Some(Method::POST), 500, TEN_SECONDS)
769 .prefix("/order", None, 90, TEN_SECONDS);
770 let limiter = table.build();
771
772 let count = |method| limiter.effective_quota(&method, "/order")[1].count;
773 assert_eq!(count(Method::POST), 500);
774 assert_eq!(count(Method::GET), 90);
775 assert_eq!(count(Method::DELETE), 90);
776 }
777
778 #[test]
779 fn rows_reports_every_row_in_match_order() {
780 let mut table = WindowQuotaTable::new(1_000, TEN_SECONDS);
781 let shared = table.bucket(900, TEN_SECONDS);
782 table
783 .prefix("/balance-allowance/update", None, 50, TEN_SECONDS)
784 .row(Matching::Exact, "/trades", Some(Method::GET), &[shared]);
785 let limiter = table.build();
786
787 let rows = limiter.rows();
788 assert_eq!(rows.len(), 2);
789 assert_eq!(rows[0].pattern, "/balance-allowance/update");
790 assert_eq!(rows[0].method, None);
791 assert_eq!(rows[0].matching, Matching::Prefix);
792 assert_eq!(shape(&rows[0].buckets), [(50, TEN_SECONDS)]);
793 assert_eq!(rows[1].pattern, "/trades");
794 assert_eq!(rows[1].method, Some(&Method::GET));
795 assert_eq!(rows[1].matching, Matching::Exact);
796 assert_eq!(rows[1].buckets[0].bucket, shared);
797 assert_eq!(format!("{limiter:?}"), "RateLimiter { endpoints: 2 }");
798 }
799
800 #[test]
801 fn every_bucket_paces_at_its_paced_interval_and_keeps_a_tenth_back() {
802 for (count, period) in [
803 (25, TEN_SECONDS * 6),
804 (150, TEN_SECONDS),
805 (9_000, TEN_SECONDS),
806 ] {
807 let mut table = WindowQuotaTable::new(count, period);
808 table.prefix("/row", None, count, period);
809 let limiter = table.build();
810
811 for quota in limiter.effective_quota(&Method::GET, "/row") {
812 assert_eq!(
813 quota.interval(),
814 WindowQuotaTable::paced_interval(count, period)
815 );
816 assert!(
817 quota.admitted_in_one_window() <= u128::from(count - count.div_ceil(10)),
818 "{count}/{period:?} admits {} in one window",
819 quota.admitted_in_one_window()
820 );
821 }
822 }
823 assert_eq!(
824 WindowQuotaTable::paced_interval(150, TEN_SECONDS),
825 TEN_SECONDS / 134
826 );
827 }
828
829 #[tokio::test]
830 async fn a_hold_set_during_a_bucket_wait_is_honoured() {
831 // 2 per 300ms paces one request every 300ms. The second request waits
832 // for its token; 50ms in, a 429 elsewhere holds the limiter for 600ms.
833 // Going out when the token arrives would send into that hold.
834 let mut table = WindowQuotaTable::new(100_000, TEN_SECONDS);
835 table.prefix("/slow", None, 2, Duration::from_millis(300));
836 let limiter = table.build();
837 limiter.acquire("/slow", None).await;
838
839 let start = std::time::Instant::now();
840 let holder = {
841 let limiter = limiter.clone();
842 tokio::spawn(async move {
843 tokio::time::sleep(Duration::from_millis(50)).await;
844 limiter.begin_cooldown(Duration::from_millis(600));
845 })
846 };
847 limiter.acquire("/slow", None).await;
848 holder.await.unwrap();
849 assert!(
850 start.elapsed() >= Duration::from_millis(600),
851 "the request went out after {:?}, inside a hold set while it waited for its \
852 token",
853 start.elapsed()
854 );
855 }
856
857 #[test]
858 #[should_panic(expected = "was made by another table")]
859 fn a_bucket_from_another_table_is_refused() {
860 let mut other = WindowQuotaTable::new(1_000, TEN_SECONDS);
861 let foreign = other.bucket(100, TEN_SECONDS);
862 let mut table = WindowQuotaTable::new(1_000, TEN_SECONDS);
863 table.bucket(100, TEN_SECONDS);
864 table.row(Matching::Prefix, "/a", None, &[foreign]);
865 }
866}
867
868/// Configuration for retry-on-429 with exponential backoff.
869#[derive(Debug, Clone)]
870pub struct RetryConfig {
871 /// Maximum number of retry attempts after the initial request (default: 3).
872 pub max_retries: u32,
873 /// Base backoff in milliseconds for the first retry, doubled each attempt (default: 500).
874 pub initial_backoff_ms: u64,
875 /// Upper bound in milliseconds for the backoff delay (default: 10_000).
876 pub max_backoff_ms: u64,
877}
878
879impl Default for RetryConfig {
880 fn default() -> Self {
881 Self {
882 max_retries: 3,
883 initial_backoff_ms: 500,
884 max_backoff_ms: 10_000,
885 }
886 }
887}
888
889impl RetryConfig {
890 /// Calculate backoff duration with jitter for attempt N.
891 ///
892 /// Uses `fastrand` for uniform jitter (75%-125% of base delay) to avoid
893 /// thundering herd when multiple clients retry simultaneously.
894 pub fn backoff(&self, attempt: u32) -> Duration {
895 let base = self
896 .initial_backoff_ms
897 .saturating_mul(1u64 << attempt.min(10));
898 let capped = base.min(self.max_backoff_ms);
899 // Uniform jitter in 0.75..1.25 range
900 let jitter_factor = 0.75 + (fastrand::f64() * 0.5);
901 let ms = (capped as f64 * jitter_factor) as u64;
902 Duration::from_millis(ms.max(1))
903 }
904
905 /// The delay before the attempt after `attempt`: its [`backoff`](Self::backoff),
906 /// lengthened by the server's `Retry-After`.
907 ///
908 /// `Retry-After` can only *extend* the wait, never shorten it. A server
909 /// asking for longer than the client-computed backoff is obeyed (clamped to
910 /// `max_backoff_ms`); one asking for less — including the zero that
911 /// Cloudflare returns alongside `error code: 1015` — leaves the exponential
912 /// backoff in place. Taking the header verbatim made a tripped Cloudflare
913 /// limit self-perpetuating: `Duration::from_millis(0)` is not a backoff, and
914 /// the three retries landed inside 65ms, extending the ban they were waiting
915 /// on. Values that do not parse as a float (e.g. the HTTP-date form) are
916 /// ignored the same way.
917 ///
918 /// The header is read by [`polyoxide_venue::parse_retry_after`], the one
919 /// parser every crate uses, clamped to `max_backoff_ms`, and the longer
920 /// wait is taken by [`polyoxide_venue::retry_delay`].
921 ///
922 /// The send loop's floor and a policy's hold both come from here, so a
923 /// response cannot hold the client by one rule and pace its own retry by
924 /// another.
925 pub fn retry_delay(&self, attempt: u32, retry_after: Option<&str>) -> Duration {
926 let clamp = Duration::from_millis(self.max_backoff_ms);
927 let requested = retry_after.and_then(|v| polyoxide_venue::parse_retry_after(v, clamp));
928 polyoxide_venue::retry_delay(requested, self.backoff(attempt))
929 }
930
931 /// What [`HttpClient::send`](crate::HttpClient::send) tells its hooks
932 /// about `attempt`: the attempt, from 0, and the retries this schedule
933 /// still allows after it.
934 pub fn attempt_info(&self, attempt: u32) -> AttemptInfo {
935 AttemptInfo {
936 attempt,
937 retries_left: self.max_retries.saturating_sub(attempt),
938 }
939 }
940}
941
942#[cfg(test)]
943mod tests {
944 use super::*;
945
946 // ── RetryConfig ──────────────────────────────────────────────
947
948 #[test]
949 fn test_retry_config_default() {
950 let cfg = RetryConfig::default();
951 assert_eq!(cfg.max_retries, 3);
952 assert_eq!(cfg.initial_backoff_ms, 500);
953 assert_eq!(cfg.max_backoff_ms, 10_000);
954 }
955
956 #[test]
957 fn test_backoff_attempt_zero() {
958 let cfg = RetryConfig::default();
959 let d = cfg.backoff(0);
960 // base = 500 * 2^0 = 500, capped = 500, jitter in [0.75, 1.25]
961 // ms in [375, 625]
962 let ms = d.as_millis() as u64;
963 assert!(
964 (375..=625).contains(&ms),
965 "attempt 0: {ms}ms not in [375, 625]"
966 );
967 }
968
969 #[test]
970 fn test_backoff_exponential_growth() {
971 let cfg = RetryConfig::default();
972 let d0 = cfg.backoff(0);
973 let d1 = cfg.backoff(1);
974 let d2 = cfg.backoff(2);
975 assert!(d0 < d1, "d0={d0:?} should be < d1={d1:?}");
976 assert!(d1 < d2, "d1={d1:?} should be < d2={d2:?}");
977 }
978
979 #[test]
980 fn test_backoff_jitter_bounds() {
981 let cfg = RetryConfig::default();
982 for attempt in 0..20 {
983 let d = cfg.backoff(attempt);
984 let base = cfg
985 .initial_backoff_ms
986 .saturating_mul(1u64 << attempt.min(10));
987 let capped = base.min(cfg.max_backoff_ms);
988 let lower = (capped as f64 * 0.75) as u64;
989 let upper = (capped as f64 * 1.25) as u64;
990 let ms = d.as_millis() as u64;
991 assert!(
992 ms >= lower.max(1) && ms <= upper,
993 "attempt {attempt}: {ms}ms not in [{lower}, {upper}]"
994 );
995 }
996 }
997
998 #[test]
999 fn test_backoff_max_capping() {
1000 let cfg = RetryConfig::default();
1001 for attempt in 5..=10 {
1002 let d = cfg.backoff(attempt);
1003 let ceiling = (cfg.max_backoff_ms as f64 * 1.25) as u64;
1004 assert!(
1005 d.as_millis() as u64 <= ceiling,
1006 "attempt {attempt}: {:?} exceeded ceiling {ceiling}ms",
1007 d
1008 );
1009 }
1010 }
1011
1012 #[test]
1013 fn test_backoff_very_high_attempt() {
1014 let cfg = RetryConfig::default();
1015 let d = cfg.backoff(100);
1016 let ceiling = (cfg.max_backoff_ms as f64 * 1.25) as u64;
1017 assert!(d.as_millis() as u64 <= ceiling);
1018 assert!(d.as_millis() >= 1);
1019 }
1020
1021 #[test]
1022 fn test_backoff_jitter_distribution() {
1023 // Verify jitter isn't degenerate (all clustering at one end).
1024 // Sample 200 values and check both halves of the range are hit.
1025 let cfg = RetryConfig::default();
1026 let midpoint = cfg.initial_backoff_ms; // 500ms (center of 375..625 range)
1027 let (mut below, mut above) = (0u32, 0u32);
1028 for _ in 0..200 {
1029 let ms = cfg.backoff(0).as_millis() as u64;
1030 if ms < midpoint {
1031 below += 1;
1032 } else {
1033 above += 1;
1034 }
1035 }
1036 assert!(
1037 below >= 20 && above >= 20,
1038 "jitter looks degenerate: {below} below midpoint, {above} above"
1039 );
1040 }
1041
1042 #[test]
1043 fn the_one_retry_after_parser_settles_the_old_disagreements() {
1044 // DRIFT R4. Core read the header untrimmed, so a padded value was
1045 // ignored and the client fell back to its own backoff.
1046 let cfg = RetryConfig::default();
1047 assert_eq!(cfg.retry_delay(0, Some(" 2 ")), Duration::from_secs(2));
1048 // Unchanged: the clamp is `max_backoff_ms`, and junk is no wait.
1049 assert_eq!(
1050 cfg.retry_delay(0, Some("604800")),
1051 Duration::from_millis(cfg.max_backoff_ms)
1052 );
1053 for junk in [
1054 "Wed, 21 Oct 2026 07:28:00 GMT",
1055 "abc",
1056 "NaN",
1057 "inf",
1058 "-1",
1059 "",
1060 ] {
1061 let ms = cfg.retry_delay(0, Some(junk)).as_millis();
1062 assert!((375..=625).contains(&ms), "{junk:?} waited {ms}ms");
1063 }
1064 }
1065
1066 // ── quota() ──────────────────────────────────────────────────
1067
1068 #[test]
1069 fn test_quota_creation() {
1070 // Should not panic for representative values
1071 let _ = quota(100, Duration::from_secs(10));
1072 let _ = quota(1, Duration::from_secs(60));
1073 let _ = quota(9_000, Duration::from_secs(10));
1074 }
1075
1076 #[test]
1077 fn test_quota_edge_zero_count() {
1078 // The sustained rate is count-1, so 0 and 1 both have to be clamped or
1079 // the period is divided by zero. Neither appears in any table.
1080 let _ = quota(0, Duration::from_secs(10));
1081 let _ = quota(1, Duration::from_secs(10));
1082 }
1083
1084 #[test]
1085 fn test_match_mode_prefix_segment_boundary() {
1086 // Verify the Prefix matching logic directly
1087 let pattern = "/price";
1088
1089 let check = |path: &str| -> bool {
1090 match path.strip_prefix(pattern) {
1091 Some(rest) => rest.is_empty() || rest.starts_with('/') || rest.starts_with('?'),
1092 None => false,
1093 }
1094 };
1095
1096 // Should match: exact, sub-path, query params
1097 assert!(check("/price"), "exact match");
1098 assert!(check("/price/foo"), "sub-path");
1099 assert!(check("/price?token=abc"), "query params");
1100
1101 // Should NOT match: partial word overlap
1102 assert!(!check("/prices-history"), "partial word /prices-history");
1103 assert!(!check("/pricelist"), "partial word /pricelist");
1104 assert!(!check("/pricing"), "partial word /pricing");
1105
1106 // Should NOT match: different prefix
1107 assert!(!check("/midpoint"), "different prefix");
1108 }
1109
1110 #[test]
1111 fn test_match_mode_exact() {
1112 // Verify the Exact matching logic
1113 let pattern = "/trades";
1114
1115 let check = |path: &str| -> bool { path == pattern };
1116
1117 assert!(check("/trades"), "exact match");
1118 assert!(!check("/trades/123"), "sub-path should not match");
1119 assert!(!check("/trades?limit=10"), "query params should not match");
1120 assert!(!check("/traded"), "different word should not match");
1121 }
1122
1123 // ── should_retry edge cases ─────────────────────────────────
1124 //
1125 // Driven through Polymarket's policy, which the send loop asks, since the
1126 // hand-written loops that called `HttpClient::should_retry` are gone.
1127
1128 /// Whether Polymarket's policy retries a 429 on `attempt` under `config`.
1129 fn retries_a_429(config: &RetryConfig, attempt: u32) -> bool {
1130 use crate::hooks::{Outcome, ResponseMeta, RetryPolicy};
1131
1132 let headers = reqwest::header::HeaderMap::new();
1133 let response = ResponseMeta {
1134 status: reqwest::StatusCode::TOO_MANY_REQUESTS,
1135 headers: &headers,
1136 };
1137 matches!(
1138 crate::polymarket::PolymarketRetryPolicy
1139 .decide(&response, &config.attempt_info(attempt), config)
1140 .outcome,
1141 Outcome::Retry(_)
1142 )
1143 }
1144
1145 #[test]
1146 fn test_should_retry_exhaustion() {
1147 // After max_retries, the policy must not retry
1148 let config = RetryConfig {
1149 max_retries: 3,
1150 ..RetryConfig::default()
1151 };
1152
1153 // Attempts 0, 1, 2 should succeed
1154 for attempt in 0..3 {
1155 assert!(
1156 retries_a_429(&config, attempt),
1157 "attempt {attempt} should allow retry"
1158 );
1159 }
1160 // Attempt 3 should give up
1161 assert!(
1162 !retries_a_429(&config, 3),
1163 "attempt 3 should exhaust retries"
1164 );
1165 }
1166
1167 #[test]
1168 fn test_should_retry_zero_max_retries_never_retries() {
1169 let config = RetryConfig {
1170 max_retries: 0,
1171 ..RetryConfig::default()
1172 };
1173
1174 assert!(
1175 !retries_a_429(&config, 0),
1176 "max_retries=0 should never retry"
1177 );
1178 }
1179}