Skip to main content

protocol_droid/adapters/primary/transport/http/
limits.rs

1//! Per-subject request accounting, and the map that holds it.
2//!
3//! The decision this makes — [`check`] from `domain::transport::rate_limit` —
4//! is a pure function. What lives here is the part that is not: the counters,
5//! the clock, and the arithmetic of keeping the table from growing without
6//! bound.
7//!
8//! ## Why the table is bounded
9//!
10//! The obvious implementation is a `HashMap<subject, Window>` that only ever
11//! grows, and it is a slow memory leak with a remote trigger: a principal name
12//! is attacker-influenced on an unauthenticated endpoint, so every rejected
13//! `Authorization` header is another entry that never expires on its own. An
14//! attacker who can make a server mint tokens they will not reuse gets a
15//! process to grow without limit, one small string at a time.
16//!
17//! So the table is capped. When it fills, entries whose window has already
18//! elapsed are dropped first — they carry no state worth keeping — and only if
19//! that is not enough is the whole table cleared. Clearing under pressure is
20//! deliberately harsher than it needs to be: it hands every subject a fresh
21//! window, so the cost is a rate limit that is briefly more generous. That is
22//! the right side to err on. Refusing requests that should have been allowed is
23//! a bug a client reports; serving more than configured is the thing this
24//! whole module exists to prevent.
25
26use std::collections::HashMap;
27use std::sync::{Arc, Mutex};
28use std::time::Instant;
29
30use crate::domain::{RateLimit, Verdict, check_rate_limit as check};
31
32/// Entries held before the table is swept.
33const DEFAULT_CAPACITY: usize = 4096;
34
35/// One subject's current window.
36#[derive(Debug)]
37struct Window {
38    /// When this window opened. `elapsed` for [`check`] is measured from here.
39    opened: Instant,
40    /// Requests counted in this window, **including** the one being judged.
41    seen: u32,
42}
43
44/// Per-subject rate limiting.
45///
46/// Cheap to clone into an axum layer: the counters are behind an [`Arc`], and
47/// two clones are one table rather than two.
48#[derive(Clone, Debug)]
49pub struct RateLimiter {
50    policy: RateLimit,
51    capacity: usize,
52    windows: Arc<Mutex<HashMap<Arc<str>, Window>>>,
53}
54
55impl RateLimiter {
56    /// A limiter applying `policy`, holding `DEFAULT_CAPACITY` subjects.
57    #[must_use]
58    pub fn new(policy: RateLimit) -> Self {
59        Self::with_capacity(policy, DEFAULT_CAPACITY)
60    }
61
62    /// A limiter holding at most `capacity` subjects before it sweeps.
63    ///
64    /// `capacity` is never read as zero: a table that cannot hold a subject
65    /// would clear itself on every request and limit nobody.
66    #[must_use]
67    pub fn with_capacity(policy: RateLimit, capacity: usize) -> Self {
68        Self {
69            policy,
70            capacity: capacity.max(1),
71            windows: Arc::new(Mutex::new(HashMap::new())),
72        }
73    }
74
75    /// The policy this limiter applies.
76    #[must_use]
77    pub const fn policy(&self) -> &RateLimit {
78        &self.policy
79    }
80
81    /// Counts one request from `subject` and says whether it may proceed.
82    ///
83    /// The clock is read here rather than passed in so a caller cannot
84    /// accidentally supply a stale `Instant`; [`check_at`](Self::check_at) is
85    /// the same function with the clock supplied, and exists for tests.
86    #[must_use]
87    pub fn check(&self, subject: &str) -> Verdict {
88        self.check_at(subject, Instant::now())
89    }
90
91    /// [`check`](Self::check) with the observation time supplied.
92    ///
93    /// Public because a test that has to sleep for a wall-clock window is a
94    /// test that is skipped in CI.
95    #[must_use]
96    pub fn check_at(&self, subject: &str, now: Instant) -> Verdict {
97        // A poisoned lock must not become an outage. The map holds only
98        // counters, so recovering the guard and treating the subject as unseen
99        // is both safe and the conservative direction: it costs the caller one
100        // request's worth of allowance, and it never lets a request through
101        // that would otherwise have been refused.
102        let Ok(mut windows) = self.windows.lock() else {
103            return Verdict::Allowed {
104                remaining: self.policy.limit(),
105            };
106        };
107
108        if windows.len() >= self.capacity {
109            self.sweep(&mut windows, now);
110        }
111
112        let entry = windows.entry(Arc::from(subject)).or_insert(Window {
113            opened: now,
114            seen: 0,
115        });
116
117        // A window that has elapsed starts again, and the elapsed time is
118        // measured from the new opening rather than from the old one.
119        let elapsed = now.saturating_duration_since(entry.opened);
120        if elapsed >= self.policy.window() {
121            entry.opened = now;
122            entry.seen = 0;
123        }
124
125        let verdict = check(
126            &self.policy,
127            entry.seen,
128            now.saturating_duration_since(entry.opened),
129        );
130
131        // `seen` counts *including* this request. Only an allowed request
132        // consumes allowance: a refusal must not count the caller twice for
133        // the attempt that was already turned away, or a client that backs off
134        // correctly is punished faster than one that does not.
135        if matches!(verdict, Verdict::Allowed { .. }) {
136            entry.seen = entry.seen.saturating_add(1);
137        }
138
139        verdict
140    }
141
142    /// Drops subjects whose window has elapsed, and the lot if that is not
143    /// enough room.
144    fn sweep(&self, windows: &mut HashMap<Arc<str>, Window>, now: Instant) {
145        windows.retain(|_, window| {
146            now.saturating_duration_since(window.opened) < self.policy.window()
147        });
148        if windows.len() >= self.capacity {
149            windows.clear();
150        }
151    }
152
153    /// How many subjects are currently tracked.
154    ///
155    /// Public so a test can assert the bound is actually a bound.
156    #[must_use]
157    pub fn tracked(&self) -> usize {
158        self.windows.lock().map_or(0, |windows| windows.len())
159    }
160}
161
162#[cfg(test)]
163mod tests {
164    use core::num::NonZeroU32;
165    use core::time::Duration;
166    use std::time::Instant;
167
168    use crate::domain::{RateLimit, Verdict};
169
170    use super::RateLimiter;
171
172    type TestError = Box<dyn std::error::Error>;
173
174    fn limiter_policy(limit: u32, window: Duration) -> Result<RateLimit, TestError> {
175        Ok(RateLimit::new(
176            NonZeroU32::new(limit).ok_or("a fixture asked for a zero limit")?,
177            window,
178        ))
179    }
180
181    fn limiter(limit: u32, window: Duration) -> Result<RateLimiter, TestError> {
182        Ok(RateLimiter::new(RateLimit::new(
183            NonZeroU32::new(limit).ok_or("a fixture asked for a zero limit")?,
184            window,
185        )))
186    }
187
188    /// The limiter counts per subject: one client hammering must not spend
189    /// another's allowance. This is the whole reason the key exists.
190    #[test]
191    fn subjects_do_not_spend_each_others_allowance() -> Result<(), TestError> {
192        let limiter = limiter(2, Duration::from_secs(60))?;
193        let now = Instant::now();
194
195        for _ in 0..2 {
196            assert!(
197                matches!(limiter.check_at("alice", now), Verdict::Allowed { .. }),
198                "alice should be within her own allowance"
199            );
200        }
201        assert_matches::assert_matches!(limiter.check_at("alice", now), Verdict::Limited { .. });
202
203        assert!(
204            matches!(limiter.check_at("bob", now), Verdict::Allowed { .. }),
205            "bob must not inherit alice's spent window"
206        );
207        Ok(())
208    }
209
210    /// A refusal must not itself consume allowance. If it did, a client that
211    /// backs off correctly would be punished faster than one that hammers.
212    #[test]
213    fn a_refusal_does_not_consume_allowance() -> Result<(), TestError> {
214        let limiter = limiter(1, Duration::from_secs(60))?;
215        let now = Instant::now();
216
217        assert_matches::assert_matches!(limiter.check_at("alice", now), Verdict::Allowed { .. });
218        // Three refusals in a row must not deepen the hole.
219        for _ in 0..3 {
220            assert_matches::assert_matches!(
221                limiter.check_at("alice", now),
222                Verdict::Limited { .. }
223            );
224        }
225        Ok(())
226    }
227
228    /// The window reopens when it elapses, without the caller resetting
229    /// anything.
230    #[test]
231    fn the_allowance_comes_back_after_the_window() -> Result<(), TestError> {
232        let limiter = limiter(1, Duration::from_secs(60))?;
233        let start = Instant::now();
234
235        assert_matches::assert_matches!(limiter.check_at("alice", start), Verdict::Allowed { .. });
236        assert_matches::assert_matches!(
237            limiter.check_at("alice", start + Duration::from_secs(30)),
238            Verdict::Limited { .. }
239        );
240        assert!(
241            matches!(
242                limiter.check_at("alice", start + Duration::from_secs(61)),
243                Verdict::Allowed { .. }
244            ),
245            "a spent window must reopen on its own"
246        );
247        Ok(())
248    }
249
250    /// A subject name is attacker-influenced, so the table must not be a slow
251    /// remote-triggered leak. This is the test that says the bound is real.
252    #[test]
253    fn the_table_stays_bounded_under_distinct_subjects() -> Result<(), TestError> {
254        let limiter =
255            RateLimiter::with_capacity(limiter_policy(10, Duration::from_secs(3600))?, 16);
256        let now = Instant::now();
257
258        for index in 0..5000u32 {
259            let _ = limiter.check_at(&format!("subject-{index}"), now);
260        }
261
262        assert!(
263            limiter.tracked() <= 16,
264            "the table grew to {} subjects under 5000 distinct names",
265            limiter.tracked()
266        );
267        Ok(())
268    }
269
270    /// A sweep drops what has expired *before* dropping anything, so a full
271    /// table does not reset subjects that are still inside their window.
272    ///
273    /// The previous version of this test made every subject expire at once,
274    /// which meant it passed for any implementation that swept at all — it
275    /// could not tell "preferred the expired ones" from "threw the lot away".
276    #[test]
277    fn sweeping_drops_expired_subjects_and_keeps_live_ones() -> Result<(), TestError> {
278        let limiter = RateLimiter::with_capacity(limiter_policy(5, Duration::from_secs(60))?, 4);
279        let start = Instant::now();
280
281        // Three subjects whose window closes while we are not looking.
282        for index in 0..3 {
283            let _ = limiter.check_at(&format!("old-{index}"), start);
284        }
285        assert_eq!(limiter.tracked(), 3, "three subjects so far");
286
287        // A live one, which brings the table to its capacity of four.
288        let _ = limiter.check_at("live", start + Duration::from_secs(90));
289        assert_eq!(limiter.tracked(), 4, "the table is now full");
290
291        // The next request finds a full table and sweeps. The three expired
292        // subjects go; the live one must stay, or every subject is being
293        // cleared the moment the table fills.
294        let _ = limiter.check_at("trigger", start + Duration::from_secs(90));
295
296        assert_eq!(
297            limiter.tracked(),
298            2,
299            "the sweep should have kept only the two live subjects"
300        );
301        Ok(())
302    }
303}