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}