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}