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