umbral_core/ratelimit.rs
1//! A dependency-light, in-memory **sliding-window rate limiter**.
2//!
3//! The single sliding-window limiter in the tree: it backs umbral-rest's
4//! API throttles ([`umbral_rest::throttle`]) AND umbral-auth's
5//! login/register brute-force throttle (`plugins/umbral-auth/src/throttle.rs`,
6//! consolidated onto this primitive — see the note below). It
7//! tracks per-key timestamps in a `Mutex<HashMap<String, VecDeque<Instant>>>`
8//! and answers one question: *is this key under its rate right now?*
9//!
10//! ```ignore
11//! use std::time::Duration;
12//! use umbral::ratelimit::{Rate, RateLimiter};
13//!
14//! let limiter = RateLimiter::new(Rate::parse("100/hour").unwrap());
15//! let decision = limiter.check("203.0.113.7");
16//! if !decision.allowed {
17//! // 429; tell the client when to come back
18//! let secs = decision.retry_after.map(|d| d.as_secs()).unwrap_or(0);
19//! }
20//! ```
21//!
22//! ## The window
23//!
24//! "Sliding window" means each `check` first prunes every recorded
25//! timestamp older than `rate.period` from now, then counts what's left.
26//! If the count is below `rate.num`, the call is allowed *and recorded*;
27//! otherwise it's denied and the limiter computes `retry_after` as the
28//! time until the oldest still-in-window entry ages out (the moment a
29//! slot frees up). There's no fixed-window edge burst: the window moves
30//! continuously with the clock.
31//!
32//! ## Scope and limits
33//!
34//! - **In-memory, single-process.** State lives in this process's heap.
35//! A multi-instance deployment behind a load balancer gives each
36//! replica its own counters; the effective limit is `num × replicas`.
37//! A Redis-backed store is the multi-instance follow-up (mirrors the
38//! same gap `umbral-auth`'s throttle has).
39//! - **Unbounded key set.** The `HashMap` grows one entry per distinct
40//! key and entries are pruned lazily on next `check` of that key, never
41//! swept globally. For IP/user keys on a normal app this is bounded by
42//! the active client set; an adversarial key explosion is a known edge
43//! (the same shape `umbral-auth`'s throttle has) — a periodic sweep is a
44//! future hardening.
45//!
46//! ## Consolidated: `umbral-auth::throttle` adopts this primitive
47//!
48//! `umbral-auth` once shipped its own bespoke login/register throttle
49//! (`plugins/umbral-auth/src/throttle.rs`) written before this primitive
50//! existed, with a hand-rolled copy of the same sliding-window-per-key idea.
51//! That duplicate is gone: `umbral-auth::throttle::Throttle` is now a thin
52//! wrapper over [`RateLimiter`], so there's a single limiter implementation
53//! in the tree. The "success forgives" path (clear a login counter after a
54//! successful login) drove the [`RateLimiter::clear`] method added here.
55//! Done in `planning/gaps2.md` (#90).
56
57use std::collections::{HashMap, VecDeque};
58use std::sync::Mutex;
59use std::time::{Duration, Instant};
60
61/// How many `check` calls trigger one automatic global sweep of the key map.
62///
63/// The per-key pruning in [`RateLimiter::check_at`] only reclaims a key the
64/// moment it is checked again; a key that fires once and is never seen again
65/// keeps its stale timestamps forever, so an adversary rotating keys/IPs grows
66/// the map without bound (audit_2 core-web #4). Every `SWEEP_EVERY` checks the
67/// limiter runs [`RateLimiter::sweep_at`] over the WHOLE map, dropping every
68/// out-of-window timestamp and removing keys left empty. This bounds the map
69/// to roughly `active_keys + SWEEP_EVERY` entries regardless of key churn.
70const SWEEP_EVERY: usize = 1000;
71
72/// The mutex-guarded interior of a [`RateLimiter`]: the per-key timestamp
73/// deques plus the op counter that drives the periodic global sweep.
74#[derive(Debug, Default)]
75struct Buckets {
76 map: HashMap<String, VecDeque<Instant>>,
77 /// Checks since the last automatic sweep; reset to 0 when a sweep runs.
78 ops_since_sweep: usize,
79}
80
81/// A rate: `num` events per `period`. Build by hand or parse the
82/// `"<num>/<period>"` string with [`Rate::parse`].
83#[derive(Debug, Clone, Copy, PartialEq, Eq)]
84pub struct Rate {
85 /// Maximum number of events allowed within one `period`.
86 pub num: u32,
87 /// The sliding window length.
88 pub period: Duration,
89}
90
91impl Rate {
92 /// Construct directly from a count and a window.
93 pub fn new(num: u32, period: Duration) -> Self {
94 Self { num, period }
95 }
96
97 /// Parse a rate string: `"<num>/<period>"`.
98 ///
99 /// `num` is a positive integer; `period` is one of (case-insensitive):
100 ///
101 /// | period token | window |
102 /// |---|---|
103 /// | `sec`, `s`, `second` | 1 second |
104 /// | `min`, `m`, `minute` | 60 seconds |
105 /// | `hour`, `h` | 3600 seconds |
106 /// | `day`, `d` | 86400 seconds |
107 ///
108 /// A bare number with no separator is also accepted as a per-second
109 /// rate (the `"<num>"` shorthand), e.g. `"5"` ≡ `"5/sec"`. Anything
110 /// else — empty string, non-numeric count, zero count, unknown period
111 /// — returns `Err` with a short message.
112 ///
113 /// ```
114 /// # use std::time::Duration;
115 /// # use umbral_core::ratelimit::Rate;
116 /// assert_eq!(Rate::parse("100/hour").unwrap().num, 100);
117 /// assert_eq!(Rate::parse("10/min").unwrap().period, Duration::from_secs(60));
118 /// assert!(Rate::parse("oops").is_err());
119 /// ```
120 pub fn parse(s: &str) -> Result<Self, String> {
121 let s = s.trim();
122 if s.is_empty() {
123 return Err("empty rate string".to_string());
124 }
125 let (num_part, period_part) = match s.split_once('/') {
126 Some((n, p)) => (n.trim(), p.trim()),
127 // Bare number → per-second (shorthand).
128 None => (s, "sec"),
129 };
130 let num: u32 = num_part
131 .parse()
132 .map_err(|_| format!("invalid rate count `{num_part}` in `{s}`"))?;
133 if num == 0 {
134 return Err(format!("rate count must be positive in `{s}`"));
135 }
136 let period = match period_part.to_ascii_lowercase().as_str() {
137 "sec" | "s" | "second" => Duration::from_secs(1),
138 "min" | "m" | "minute" => Duration::from_secs(60),
139 "hour" | "h" => Duration::from_secs(3600),
140 "day" | "d" => Duration::from_secs(86_400),
141 other => return Err(format!("unknown rate period `{other}` in `{s}`")),
142 };
143 Ok(Self { num, period })
144 }
145}
146
147/// The verdict for one [`RateLimiter::check`].
148#[derive(Debug, Clone, Copy, PartialEq, Eq)]
149pub struct RateDecision {
150 /// `true` when the request is under the limit (and was recorded);
151 /// `false` when it's over (and was NOT recorded).
152 pub allowed: bool,
153 /// On a denial, how long until a slot frees up — the time until the
154 /// oldest in-window entry ages out. `None` when `allowed` is `true`.
155 pub retry_after: Option<Duration>,
156 /// The configured ceiling (`Rate::num`). Useful for an
157 /// `X-RateLimit-Limit` header.
158 pub limit: u32,
159 /// How many requests remain in the current window AFTER this one.
160 /// `0` on a denial.
161 pub remaining: u32,
162}
163
164/// An in-memory sliding-window rate limiter, keyed by an arbitrary
165/// string (IP, user id, scope-qualified key — the caller decides).
166///
167/// Cheap to clone the configured [`Rate`]; the shared counter map sits
168/// behind a `Mutex` so a single `RateLimiter` can back many concurrent
169/// requests. Wrap in an `Arc` to share across handlers.
170#[derive(Debug)]
171pub struct RateLimiter {
172 rate: Rate,
173 buckets: Mutex<Buckets>,
174}
175
176impl RateLimiter {
177 /// Build a limiter enforcing `rate`.
178 pub fn new(rate: Rate) -> Self {
179 Self {
180 rate,
181 buckets: Mutex::new(Buckets::default()),
182 }
183 }
184
185 /// The configured rate.
186 pub fn rate(&self) -> Rate {
187 self.rate
188 }
189
190 /// Check (and, if allowed, record) one request for `key` against the
191 /// configured rate, using the real wall clock.
192 ///
193 /// See [`Self::check_at`] for the deterministic, clock-injectable
194 /// variant the tests drive.
195 pub fn check(&self, key: &str) -> RateDecision {
196 self.check_at(key, Instant::now())
197 }
198
199 /// Clock-injectable core: identical to [`Self::check`] but the caller
200 /// supplies `now`. Private-ish (crate-visible) so deterministic tests
201 /// can advance time without sleeping; production always routes through
202 /// [`Self::check`] with `Instant::now()`.
203 pub fn check_at(&self, key: &str, now: Instant) -> RateDecision {
204 let window = self.rate.period;
205 let mut buckets = self.buckets.lock().unwrap_or_else(|e| e.into_inner());
206
207 // Periodic global sweep: bound the key map against key-churn memory
208 // growth (audit_2 core-web #4). Runs BEFORE this check so the current
209 // key is re-inserted fresh below even if the sweep just dropped it.
210 buckets.ops_since_sweep += 1;
211 if buckets.ops_since_sweep >= SWEEP_EVERY {
212 buckets.ops_since_sweep = 0;
213 sweep_map(&mut buckets.map, now, window);
214 }
215
216 let entries = buckets.map.entry(key.to_string()).or_default();
217
218 // Prune everything older than the window — the "sliding" step.
219 // `now.checked_duration_since` guards against a clock that didn't
220 // advance (or a stamp in the future); treat un-orderable stamps
221 // as in-window (conservative: never silently drop a recent hit).
222 while let Some(front) = entries.front() {
223 match now.checked_duration_since(*front) {
224 Some(age) if age >= window => {
225 entries.pop_front();
226 }
227 _ => break,
228 }
229 }
230
231 let count = entries.len() as u32;
232 if count < self.rate.num {
233 entries.push_back(now);
234 RateDecision {
235 allowed: true,
236 retry_after: None,
237 limit: self.rate.num,
238 remaining: self.rate.num - count - 1,
239 }
240 } else {
241 // Over the limit. A slot frees when the OLDEST in-window entry
242 // ages out: that's `window - (now - oldest)`. The prune above
243 // guarantees the front is still within the window, so the
244 // subtraction is non-negative; saturate to be safe.
245 let retry_after = entries
246 .front()
247 .and_then(|oldest| now.checked_duration_since(*oldest))
248 .map(|age| window.saturating_sub(age))
249 .unwrap_or(window);
250 RateDecision {
251 allowed: false,
252 retry_after: Some(retry_after),
253 limit: self.rate.num,
254 remaining: 0,
255 }
256 }
257 }
258
259 /// Forget every recorded request for `key`, resetting its window so the
260 /// next [`check`](Self::check) starts from a clean budget.
261 ///
262 /// The "success forgives" primitive: a caller that wants a prior burst of
263 /// denied attempts to stop counting after some positive outcome (e.g.
264 /// umbral-auth clears the login counter on a SUCCESSFUL login so a user who
265 /// fat-fingered their password isn't locked out) calls this to drop the
266 /// key's history. A no-op if the key was never seen.
267 pub fn clear(&self, key: &str) {
268 let mut buckets = self.buckets.lock().unwrap_or_else(|e| e.into_inner());
269 buckets.map.remove(key);
270 }
271
272 /// Reclaim memory: prune every recorded timestamp older than the window
273 /// and drop keys left with no in-window entries. Uses the real clock; see
274 /// [`Self::sweep_at`] for the deterministic, clock-injectable variant.
275 ///
276 /// Runs automatically once every [`SWEEP_EVERY`] checks, so most callers
277 /// never need it; exposed for a caller that wants to force a reclaim (e.g.
278 /// a periodic background task on a bursty, high-cardinality key space).
279 pub fn sweep(&self) {
280 self.sweep_at(Instant::now());
281 }
282
283 /// Clock-injectable core of [`Self::sweep`]: prune out-of-window
284 /// timestamps and remove now-empty keys, using the caller-supplied `now`.
285 pub fn sweep_at(&self, now: Instant) {
286 let window = self.rate.period;
287 let mut buckets = self.buckets.lock().unwrap_or_else(|e| e.into_inner());
288 buckets.ops_since_sweep = 0;
289 sweep_map(&mut buckets.map, now, window);
290 }
291
292 /// Number of keys currently tracked in the map. A diagnostic accessor
293 /// (also what the memory-bounding tests assert against); production code
294 /// rarely needs it.
295 pub fn tracked_keys(&self) -> usize {
296 self.buckets
297 .lock()
298 .unwrap_or_else(|e| e.into_inner())
299 .map
300 .len()
301 }
302}
303
304/// Prune every timestamp older than `window` from each key and drop any key
305/// left with an empty deque. Shared by the automatic (in-`check_at`) sweep and
306/// the explicit [`RateLimiter::sweep_at`] entry point.
307fn sweep_map(map: &mut HashMap<String, VecDeque<Instant>>, now: Instant, window: Duration) {
308 map.retain(|_key, entries| {
309 while let Some(front) = entries.front() {
310 match now.checked_duration_since(*front) {
311 Some(age) if age >= window => {
312 entries.pop_front();
313 }
314 _ => break,
315 }
316 }
317 !entries.is_empty()
318 });
319}
320
321#[cfg(test)]
322mod tests {
323 use super::*;
324
325 #[test]
326 fn parse_each_period() {
327 assert_eq!(Rate::parse("1/sec").unwrap().period, Duration::from_secs(1));
328 assert_eq!(Rate::parse("1/s").unwrap().period, Duration::from_secs(1));
329 assert_eq!(
330 Rate::parse("1/second").unwrap().period,
331 Duration::from_secs(1)
332 );
333 assert_eq!(
334 Rate::parse("1/min").unwrap().period,
335 Duration::from_secs(60)
336 );
337 assert_eq!(
338 Rate::parse("1/hour").unwrap().period,
339 Duration::from_secs(3600)
340 );
341 assert_eq!(
342 Rate::parse("1/day").unwrap().period,
343 Duration::from_secs(86_400)
344 );
345 }
346
347 #[test]
348 fn parse_rejects_garbage() {
349 assert!(Rate::parse("").is_err());
350 assert!(Rate::parse("oops").is_err());
351 assert!(Rate::parse("10/fortnight").is_err());
352 assert!(Rate::parse("0/sec").is_err());
353 assert!(Rate::parse("abc/min").is_err());
354 }
355
356 #[test]
357 fn third_request_in_window_denied() {
358 let limiter = RateLimiter::new(Rate::parse("2/min").unwrap());
359 let t0 = Instant::now();
360 let d1 = limiter.check_at("a", t0);
361 assert!(d1.allowed);
362 assert_eq!(d1.remaining, 1);
363 let d2 = limiter.check_at("a", t0 + Duration::from_secs(1));
364 assert!(d2.allowed);
365 assert_eq!(d2.remaining, 0);
366 let d3 = limiter.check_at("a", t0 + Duration::from_secs(2));
367 assert!(!d3.allowed);
368 assert!(d3.retry_after.is_some());
369 // Slot frees 60s after the FIRST hit, i.e. 58s from t0+2s.
370 assert_eq!(d3.retry_after.unwrap(), Duration::from_secs(58));
371 }
372
373 #[test]
374 fn distinct_keys_are_independent() {
375 let limiter = RateLimiter::new(Rate::parse("1/min").unwrap());
376 let t0 = Instant::now();
377 assert!(limiter.check_at("a", t0).allowed);
378 // Key "b" has its own bucket — not affected by "a" being full.
379 assert!(limiter.check_at("b", t0).allowed);
380 // "a" is now over its 1/min.
381 assert!(!limiter.check_at("a", t0).allowed);
382 }
383
384 #[test]
385 fn allowed_again_after_window_elapses() {
386 let limiter = RateLimiter::new(Rate::parse("1/min").unwrap());
387 let t0 = Instant::now();
388 assert!(limiter.check_at("a", t0).allowed);
389 assert!(!limiter.check_at("a", t0 + Duration::from_secs(30)).allowed);
390 // 61s later the original hit has aged out of the 60s window.
391 assert!(limiter.check_at("a", t0 + Duration::from_secs(61)).allowed);
392 }
393
394 #[test]
395 fn sweep_reclaims_stale_keys() {
396 let limiter = RateLimiter::new(Rate::parse("1/min").unwrap());
397 let t0 = Instant::now();
398 for i in 0..50 {
399 limiter.check_at(&format!("k{i}"), t0);
400 }
401 assert_eq!(limiter.tracked_keys(), 50);
402 // Two minutes on, every recorded hit is outside the 60s window, so a
403 // sweep drops all of them and reclaims the keys.
404 limiter.sweep_at(t0 + Duration::from_secs(120));
405 assert_eq!(limiter.tracked_keys(), 0, "stale keys reclaimed");
406 }
407
408 #[test]
409 fn sweep_keeps_in_window_keys() {
410 let limiter = RateLimiter::new(Rate::parse("5/min").unwrap());
411 let t0 = Instant::now();
412 limiter.check_at("live", t0);
413 // Sweep 1s later — still inside the 60s window, so the key survives.
414 limiter.sweep_at(t0 + Duration::from_secs(1));
415 assert_eq!(limiter.tracked_keys(), 1, "in-window key kept");
416 }
417
418 #[test]
419 fn automatic_sweep_bounds_the_map() {
420 // An adversary rotating keys can't grow the map without bound: the
421 // periodic auto-sweep reclaims keys whose only hits have aged out.
422 let limiter = RateLimiter::new(Rate::parse("1/min").unwrap());
423 let t0 = Instant::now();
424 for i in 0..SWEEP_EVERY {
425 limiter.check_at(&format!("k{i}"), t0);
426 }
427 // A second wave two minutes later: once the op counter crosses
428 // SWEEP_EVERY again the auto-sweep runs with the newer clock and
429 // drops the now-stale first wave.
430 let later = t0 + Duration::from_secs(120);
431 for i in 0..SWEEP_EVERY {
432 limiter.check_at(&format!("l{i}"), later);
433 }
434 assert!(
435 limiter.tracked_keys() <= SWEEP_EVERY + 1,
436 "auto-sweep must bound the map; got {}",
437 limiter.tracked_keys()
438 );
439 }
440
441 #[test]
442 fn clear_forgets_a_key() {
443 let limiter = RateLimiter::new(Rate::parse("1/min").unwrap());
444 let t0 = Instant::now();
445 assert!(limiter.check_at("a", t0).allowed);
446 // Over budget within the window.
447 assert!(!limiter.check_at("a", t0).allowed);
448 // Clearing the key drops its history, so the next check is allowed.
449 limiter.clear("a");
450 assert!(limiter.check_at("a", t0).allowed);
451 // A clear on an unknown key is a harmless no-op.
452 limiter.clear("never-seen");
453 }
454}