Skip to main content

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(&quota(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(&quota(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(&quotas),
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}