Skip to main content

protocol_droid/domain/transport/
rate_limit.rs

1//! How many requests one subject may make in a window, and what to say when it
2//! has made too many.
3//!
4//! The *policy* lives here and the *bookkeeping* does not, and that split is
5//! the whole design. Deciding whether a request is allowed is a function of
6//! three values — the limit, the window, and what the counter observed — and
7//! nothing else. It does not read a clock, it does not own the counters, and
8//! it cannot be surprised by a slow request or a second task. The adapter owns
9//! a map from subject to counter and supplies the elapsed time; this module
10//! turns that into a verdict.
11//!
12//! ## Why a fixed window rather than a token bucket
13//!
14//! A token bucket is smoother, and it is what `governor` implements. It is
15//! also continuous state that only makes sense if the reader already trusts the
16//! arithmetic, which makes it hard to pin down and hard to audit. A fixed
17//! window's worst case is well known and bounded: a caller may make `2 *
18//! limit` requests across a window boundary in a span as short as the time it
19//! takes to send them. For the abuse this defends against — one client
20//! hammering a tool — that is still a bound, and it is a bound whose proof is
21//! four lines long.
22//!
23//! If a deployment needs smoother behaviour, the right change is to swap the
24//! algorithm behind [`check`] and keep its signature.
25//!
26//! ## OWASP
27//!
28//! API Security Top 10:2023, API4:2023 (Unrestricted Resource Consumption).
29
30use core::num::{NonZeroU32, NonZeroU64};
31use core::time::Duration;
32
33/// A request allowance: `limit` requests per `window`.
34///
35/// `limit` is a [`NonZeroU32`] so that a misconfiguration which means "refuse
36/// everything" is not expressible by accident. The type has no "unlimited"
37/// state either: a caller that genuinely wants no limit should not put this
38/// type in the path at all, so "unlimited" can never be the accidental result
39/// of a zero that survived a round trip through configuration.
40#[derive(Clone, Copy, Debug, PartialEq, Eq)]
41pub struct RateLimit {
42    limit: NonZeroU32,
43    window: Duration,
44}
45
46impl RateLimit {
47    /// An allowance of `limit` requests per `window`.
48    ///
49    /// # Panics
50    ///
51    /// If `window` is zero. A window of zero is a server that refuses
52    /// everything, and that is not a condition worth discovering from traffic,
53    /// so it is rejected at construction. `limit` cannot be zero by type.
54    #[must_use]
55    pub const fn new(limit: NonZeroU32, window: Duration) -> Self {
56        assert!(
57            !window.is_zero(),
58            "a rate-limit window of zero refuses everything"
59        );
60        Self { limit, window }
61    }
62
63    /// Requests permitted per window.
64    #[must_use]
65    pub const fn limit(&self) -> NonZeroU32 {
66        self.limit
67    }
68
69    /// The width of the window.
70    #[must_use]
71    pub const fn window(&self) -> Duration {
72        self.window
73    }
74}
75
76/// What [`check`] concluded.
77///
78/// Not a [`bool`] because the caller needs the remainder to put a
79/// `X-RateLimit-Remaining` on the response, and needs the wait on the refusal.
80/// Collapsing to `bool` would mean either dropping both or recomputing them in
81/// the adapter, which is the same arithmetic in a second place.
82#[derive(Clone, Copy, Debug, PartialEq, Eq)]
83pub enum Verdict {
84    /// The request is permitted. `remaining` counts what is left in this
85    /// window *including this request*.
86    Allowed {
87        /// Requests still available in the current window.
88        remaining: NonZeroU32,
89    },
90    /// The window is spent. `retry_after` is how long until it resets.
91    Limited {
92        /// Whole seconds to wait. Never zero.
93        retry_after: NonZeroU64,
94    },
95}
96
97/// The outcome of one check.
98///
99/// # Errors
100///
101/// Never. It is a function of its three arguments and always answers.
102///
103/// Not `const`: `Duration`'s `Sub` is not a const trait on stable, and this
104/// function has to subtract one.
105#[must_use]
106pub fn check(limit: &RateLimit, seen: u32, elapsed: Duration) -> Verdict {
107    // A window that has already elapsed is a fresh one. Handled here rather
108    // than by the adapter resetting its counter, so the boundary is decided in
109    // one place: the adapter's map is a cache of this answer, and a cache that
110    // can disagree with the rule is worse than no cache.
111    if elapsed >= limit.window {
112        return Verdict::Allowed {
113            remaining: limit.limit,
114        };
115    }
116
117    // `seen` is a `u32` from outside this module. A value at or beyond the
118    // limit is refused rather than subtracted from, so a corrupted counter
119    // cannot wrap into a large allowance.
120    if seen >= limit.limit.get() {
121        // `checked_sub` because the arm above established `elapsed < window`
122        // and this is the only place that fact is used. A `Duration` underflow
123        // would panic, and the alternative — an unchecked `-` — makes a panic
124        // reachable if the two comparisons ever drift apart.
125        let left = limit
126            .window
127            .checked_sub(elapsed)
128            .unwrap_or(Duration::from_secs(0));
129        return Verdict::Limited {
130            retry_after: seconds_ceil(left),
131        };
132    }
133
134    // `limit - seen` is at least one because `seen < limit` is the arm we are
135    // in, so the subtraction cannot wrap.
136    Verdict::Allowed {
137        remaining: NonZeroU32::new(limit.limit.get() - seen).unwrap_or(limit.limit),
138    }
139}
140
141/// Whole seconds in `duration`, rounded up, and never zero.
142///
143/// Ceiling rather than truncating because a `Retry-After` of `0` tells the
144/// client to retry now, which is the one answer that defeats the backoff. A
145/// duration of 300ms becomes 1 second for the same reason.
146fn seconds_ceil(duration: Duration) -> NonZeroU64 {
147    if duration.is_zero() {
148        return NonZeroU64::MIN;
149    }
150    // `as_millis` is `u128`, and a duration measured in billions of years is
151    // not a backoff anyone will wait out. Saturating is the honest reading of
152    // "effectively never retry", and it keeps the arithmetic in `u64`.
153    let millis = u64::try_from(duration.as_millis()).unwrap_or(u64::MAX);
154    let seconds = millis / 1000;
155    let rounded = if millis.is_multiple_of(1000) {
156        seconds
157    } else {
158        seconds + 1
159    };
160    NonZeroU64::new(rounded).unwrap_or(NonZeroU64::MIN)
161}
162
163#[cfg(test)]
164mod tests {
165    use assert_matches::assert_matches;
166    use core::num::NonZeroU32;
167    use core::time::Duration;
168
169    use super::{RateLimit, Verdict, check, seconds_ceil};
170
171    type TestError = Box<dyn std::error::Error>;
172
173    /// `NonZeroU32::new` returns an `Option`, and a fixture that cannot build
174    /// a rate limit has failed the test rather than the program.
175    fn nz(value: u32) -> Result<NonZeroU32, TestError> {
176        NonZeroU32::new(value).ok_or_else(|| "a fixture asked for a zero limit".into())
177    }
178
179    fn limit() -> Result<RateLimit, TestError> {
180        Ok(RateLimit::new(nz(3)?, Duration::from_secs(60)))
181    }
182
183    /// The ordinary case: under the limit, allowed, with a remainder that
184    /// counts down.
185    #[test]
186    fn requests_under_the_limit_are_allowed_and_count_down() -> Result<(), TestError> {
187        let limit = limit()?;
188
189        assert_eq!(
190            check(&limit, 0, Duration::from_secs(1)),
191            Verdict::Allowed { remaining: nz(3)? }
192        );
193        assert_eq!(
194            check(&limit, 2, Duration::from_secs(1)),
195            Verdict::Allowed { remaining: nz(1)? }
196        );
197        Ok(())
198    }
199
200    /// The window boundary is the interesting case. `seen` at the limit is
201    /// refused *within* the window and allowed *after* it, and that decision
202    /// lives in one place so an adapter's cached counter cannot disagree.
203    #[test]
204    fn the_window_boundary_decides_both_ways() -> Result<(), TestError> {
205        let limit = limit()?;
206
207        assert_matches!(
208            check(&limit, 3, Duration::from_secs(59)),
209            Verdict::Limited { .. }
210        );
211        assert_eq!(
212            check(&limit, 3, Duration::from_secs(60)),
213            Verdict::Allowed { remaining: nz(3)? }
214        );
215        Ok(())
216    }
217
218    /// A `seen` from a corrupted or hand-written counter must not wrap the
219    /// subtraction into a large allowance.
220    #[test]
221    fn a_count_beyond_the_limit_is_refused_not_wrapped() -> Result<(), TestError> {
222        let limit = limit()?;
223
224        assert_matches!(
225            check(&limit, u32::MAX, Duration::from_secs(1)),
226            Verdict::Limited { .. }
227        );
228        Ok(())
229    }
230
231    /// The refusal has to tell the client when to come back, and "now" is not a
232    /// useful answer.
233    #[test]
234    fn a_refusal_always_asks_the_caller_to_wait() -> Result<(), TestError> {
235        let limit = limit()?;
236
237        for elapsed in [0, 1, 30, 59] {
238            let Verdict::Limited { retry_after } = check(&limit, 3, Duration::from_secs(elapsed))
239            else {
240                return Err("a spent window must refuse".into());
241            };
242            assert!(
243                retry_after.get() > 0,
244                "Retry-After: 0 tells the client to retry immediately"
245            );
246        }
247        Ok(())
248    }
249
250    /// A limit of one is the tightest legal configuration and must still work:
251    /// first request allowed, second refused.
252    #[test]
253    fn a_limit_of_one_permits_exactly_one() -> Result<(), TestError> {
254        let limit = RateLimit::new(nz(1)?, Duration::from_secs(10));
255
256        assert_eq!(
257            check(&limit, 0, Duration::from_secs(1)),
258            Verdict::Allowed { remaining: nz(1)? }
259        );
260        assert_matches!(
261            check(&limit, 1, Duration::from_secs(1)),
262            Verdict::Limited { .. }
263        );
264        Ok(())
265    }
266
267    #[test]
268    fn seconds_round_up_and_never_reach_zero() {
269        assert_eq!(seconds_ceil(Duration::from_millis(999)).get(), 1);
270        assert_eq!(seconds_ceil(Duration::from_millis(1000)).get(), 1);
271        assert_eq!(seconds_ceil(Duration::from_millis(1001)).get(), 2);
272        assert_eq!(seconds_ceil(Duration::from_secs(60)).get(), 60);
273        assert_eq!(seconds_ceil(Duration::ZERO).get(), 1);
274    }
275}