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