Skip to main content

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}