Skip to main content

guard_core_engine/
rate_limit.rs

1//! The sliding-window rate limiter: per client IP, global scope or per
2//! endpoint.
3//!
4//! This is the Rust family's in-memory port of the reference engine's rate
5//! limiter (`guard_core/handlers/ratelimit_handler.py`, via the Go port's
6//! `ratelimit.go`). One call, [`RateLimiter::check`], records one request
7//! into the window for `(client IP, scope)` and decides it:
8//!
9//! ```text
10//! evict every recorded timestamp at or before (now - window)
11//! count   = requests still inside the window (before this one is recorded)
12//! record  now
13//! allowed = count < rate_limit
14//! ```
15//!
16//! ## Counting semantics
17//!
18//! The reference keeps a sliding log of request timestamps per key. The
19//! in-memory store compares the pre-recording count against the limit
20//! (`allowed = count < limit`), the Redis store compares the post-recording
21//! rank against it (`allowed = count <= limit`, one Lua/ZSET pipeline keyed
22//! `rate_limit:rate:{ip}[:{endpoint hash}]`); the boundary is the same, this
23//! port mirrors the in-memory formulation because Redis is out of scope here:
24//! the store is process-local, exactly what the reference falls back to when
25//! Redis is off, and the same `workers x rate_limit` caveat applies to
26//! multi-process deployments. The count reported on a block includes the
27//! current request (`count + 1`), matching what both references log.
28//!
29//! Blocked callers retry after the configured window: the reference attaches
30//! `Retry-After: <window seconds>` to its `429 Too many requests`, and
31//! [`RateLimitDecision::retry_after`] carries the same value.
32//!
33//! ## Scope
34//!
35//! The global scope keys the window by client IP alone (every endpoint
36//! shares one budget per IP, the reference's default pipeline tier). The
37//! endpoint scope keys it by `(client IP, endpoint path)` - an isolated
38//! budget per endpoint, the reference's `endpoint_path`-keyed tier. Keys are
39//! structured (IP + path fields), so no separator collision exists by
40//! construction; the reference hashes the endpoint segment for its joined
41//! Redis keys, a concern the in-memory store does not have.
42//!
43//! ## Honesty
44//!
45//! No Redis: the distributed mode (shared budgets across workers, script
46//! reload handling) is a follow-up. The window store is an LRU capped at
47//! 10 000 keys (`maxTrackedRateLimitKeys` in the Go port,
48//! `_MAX_TRACKED_RATE_LIMIT_KEYS` in the reference), so abusive key
49//! cardinality cannot grow the store without bound.
50//!
51//! # Example
52//!
53//! ```
54//! use std::net::IpAddr;
55//! use std::str::FromStr;
56//!
57//! use guard_core_engine::rate_limit::{RateLimitConfig, RateLimiter};
58//!
59//! let limiter = RateLimiter::new(RateLimitConfig {
60//!     enable_rate_limiting: true,
61//!     rate_limit: 2,
62//!     ..RateLimitConfig::default()
63//! })
64//! .expect("valid config");
65//! let ip = IpAddr::from_str("192.0.2.1").unwrap();
66//!
67//! assert!(limiter.check(ip, None).allowed);
68//! assert!(limiter.check(ip, None).allowed);
69//! // The third request inside the window crosses the limit.
70//! let blocked = limiter.check(ip, None);
71//! assert!(!blocked.allowed);
72//! assert_eq!(blocked.retry_after(), 60);
73//! ```
74//!
75//! A disabled limiter is inert: `check` allows without recording.
76
77use std::collections::VecDeque;
78use std::net::IpAddr;
79use std::num::NonZeroUsize;
80use std::sync::{Arc, Mutex};
81
82use lru::LruCache;
83
84use crate::ip_gate::canonical;
85
86/// The reference default for `rate_limit` (maximum requests per window).
87pub const DEFAULT_RATE_LIMIT: u32 = 10;
88/// The reference default for `rate_limit_window` (seconds).
89pub const DEFAULT_RATE_LIMIT_WINDOW: u64 = 60;
90/// The window store's key cap (`_MAX_TRACKED_RATE_LIMIT_KEYS` /
91/// `maxTrackedRateLimitKeys` in the references).
92pub const MAX_TRACKED_RATE_LIMIT_KEYS: usize = 10_000;
93
94/// The rate-limiting knobs (the reference `enable_rate_limiting` /
95/// `rate_limit` / `rate_limit_window` / `enable_rate_limit_auto_ban` group).
96///
97/// The engine defaults are the opt-in pair: rate limiting off (`false`,
98/// zero behavior change unless enabled - the adapters only install the stage
99/// when the config asks for it) with the reference thresholds for when it is
100/// turned on. Note the Python `SecurityConfig` defaults `enable_rate_limiting`
101/// to `true`; the Rust family pins the conservative `false` so an adapter
102/// upgrade never starts shedding traffic uninvited.
103#[derive(Debug, Clone, PartialEq, Eq)]
104pub struct RateLimitConfig {
105    /// `enable_rate_limiting`. `false` makes every [`RateLimiter::check`]
106    /// an unconditional allow that records nothing.
107    pub enable_rate_limiting: bool,
108    /// `rate_limit`: maximum requests per client IP inside
109    /// `rate_limit_window` seconds.
110    pub rate_limit: u32,
111    /// `rate_limit_window`: the window length in seconds.
112    pub rate_limit_window: u64,
113    /// `enable_rate_limit_auto_ban`: feed rate-limit crossings into the
114    /// auto-ban engine (the `rate_limit` category of the violation counters)
115    /// when the pipeline stage runs with IP banning enabled.
116    pub enable_rate_limit_auto_ban: bool,
117}
118
119impl Default for RateLimitConfig {
120    fn default() -> Self {
121        Self {
122            enable_rate_limiting: false,
123            rate_limit: DEFAULT_RATE_LIMIT,
124            rate_limit_window: DEFAULT_RATE_LIMIT_WINDOW,
125            enable_rate_limit_auto_ban: false,
126        }
127    }
128}
129
130/// An invalid [`RateLimitConfig`]: the config error
131/// [`RateLimiter::new`] fails closed with.
132#[derive(Debug, Clone, PartialEq, Eq)]
133pub struct RateLimitConfigError {
134    /// The rejected field (`rate_limit` or `rate_limit_window`).
135    pub field: &'static str,
136    /// What the field must be instead.
137    pub reason: &'static str,
138}
139
140impl core::fmt::Display for RateLimitConfigError {
141    fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
142        write!(f, "invalid {}: {}", self.field, self.reason)
143    }
144}
145
146impl std::error::Error for RateLimitConfigError {}
147
148/// The outcome of one rate-limit check.
149#[derive(Debug, Clone, Copy, PartialEq, Eq)]
150pub struct RateLimitDecision {
151    /// `false` when the request crossed the limit and must be answered with
152    /// the family's `429 Too many requests` carrying
153    /// [`RateLimitDecision::retry_after`].
154    pub allowed: bool,
155    /// Requests observed inside the window including this one (the count
156    /// both references report when they block).
157    pub count: u64,
158    /// The window length the decision was made under (seconds), so callers
159    /// can render `Retry-After` without re-reading the config.
160    pub window: u64,
161}
162
163impl RateLimitDecision {
164    /// The `Retry-After` header value for a blocked request: the window
165    /// length, exactly what the reference sets.
166    #[must_use]
167    pub const fn retry_after(self) -> u64 {
168        self.window
169    }
170}
171
172/// The monotonic-ish wall clock, in seconds since the Unix epoch. Injectable
173/// so expiry behavior (window sliding) is testable without sleeping.
174pub type Clock = Arc<dyn Fn() -> f64 + Send + Sync>;
175
176#[derive(Debug, PartialEq, Eq, Hash)]
177struct WindowKey {
178    ip: IpAddr,
179    endpoint: Option<String>,
180}
181
182/// The sliding-window rate limiter over one shared in-memory store.
183///
184/// Build it once at startup with [`RateLimiter::new`] (fail closed on a
185/// non-positive limit or window) and share the handle across requests; the
186/// internal store is a mutex-guarded LRU, safe for concurrent services.
187pub struct RateLimiter {
188    config: RateLimitConfig,
189    windows: Arc<Mutex<LruCache<WindowKey, VecDeque<f64>>>>,
190    clock: Clock,
191}
192
193impl Clone for RateLimiter {
194    /// A clone shares the window store and the clock, the references'
195    /// singleton semantics: requests recorded through any handle land in the
196    /// same sliding windows.
197    fn clone(&self) -> Self {
198        Self {
199            config: self.config.clone(),
200            windows: Arc::clone(&self.windows),
201            clock: Arc::clone(&self.clock),
202        }
203    }
204}
205
206impl core::fmt::Debug for RateLimiter {
207    fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
208        f.debug_struct("RateLimiter")
209            .field("config", &self.config)
210            .finish_non_exhaustive()
211    }
212}
213
214/// The system wall clock, in seconds since the Unix epoch: the clock every
215/// production store runs on. Adapters and stage tests that build several
216/// stores over one clock share this constructor.
217#[must_use]
218pub fn system_clock() -> f64 {
219    std::time::SystemTime::now()
220        .duration_since(std::time::UNIX_EPOCH)
221        .map_or(0.0, |d| d.as_secs_f64())
222}
223
224/// A fresh shared window store (the store lives behind an `Arc` so clones of
225/// the limiter share it).
226fn new_window_store() -> Arc<Mutex<LruCache<WindowKey, VecDeque<f64>>>> {
227    Arc::new(Mutex::new(LruCache::new(
228        NonZeroUsize::new(MAX_TRACKED_RATE_LIMIT_KEYS).expect("constant above zero"),
229    )))
230}
231
232impl RateLimiter {
233    /// Validate the config and build the limiter.
234    ///
235    /// # Errors
236    ///
237    /// Fails closed with a [`RateLimitConfigError`] when `rate_limit` or
238    /// `rate_limit_window` is zero (the reference rejects both with
239    /// `ge=1`); nothing but `RateLimitConfig::default`-shaped values is
240    /// substituted silently.
241    pub fn new(config: RateLimitConfig) -> Result<Self, RateLimitConfigError> {
242        Self::with_config_and_clock(config, Arc::new(system_clock))
243    }
244
245    /// Validate the config and build the limiter over an injected clock: the
246    /// combined seam [`new`](Self::new) and [`with_clock`](Self::with_clock)
247    /// cover separately. Production builds use [`RateLimiter::new`]; adapters
248    /// and stage tests that drive window sliding with a fake clock under a
249    /// production-shaped config use this one.
250    ///
251    /// # Errors
252    ///
253    /// Same fail-closed behavior as [`RateLimiter::new`]: a zero `rate_limit`
254    /// or `rate_limit_window` is a [`RateLimitConfigError`].
255    pub fn with_config_and_clock(
256        config: RateLimitConfig,
257        clock: Clock,
258    ) -> Result<Self, RateLimitConfigError> {
259        if config.rate_limit == 0 {
260            return Err(RateLimitConfigError {
261                field: "rate_limit",
262                reason: "must be at least 1 request per window",
263            });
264        }
265        if config.rate_limit_window == 0 {
266            return Err(RateLimitConfigError {
267                field: "rate_limit_window",
268                reason: "must be at least 1 second",
269            });
270        }
271        Ok(Self {
272            config,
273            windows: new_window_store(),
274            clock,
275        })
276    }
277
278    /// Swap the wall clock. Test seam: production builds use the system
279    /// clock; deterministic window-sliding coverage injects a fake.
280    #[must_use]
281    pub fn with_clock(clock: Clock) -> Self {
282        Self {
283            config: RateLimitConfig::default(),
284            windows: new_window_store(),
285            clock,
286        }
287    }
288
289    /// The validated config the limiter decides under.
290    #[must_use]
291    pub const fn config(&self) -> &RateLimitConfig {
292        &self.config
293    }
294
295    /// Record one request for `ip` and decide it.
296    ///
297    /// `endpoint` selects the scope: `None` is the global per-IP window,
298    /// `Some(path)` the per-endpoint window keyed by `(ip, path)`. A
299    /// disabled limiter allows without recording. The returned decision's
300    /// `count` includes the request just recorded, so a block reports the
301    /// crossing count exactly as the references log it.
302    // The store lock must outlive the eviction loop and the push (both
303    // mutate through the `timestamps` reference the guard produced); the
304    // nursery lint cannot see through that reference and wants it dropped
305    // early. The lock scope below already ends before the decision is built.
306    #[allow(clippy::significant_drop_tightening)]
307    #[must_use]
308    pub fn check(&self, ip: IpAddr, endpoint: Option<&str>) -> RateLimitDecision {
309        if !self.config.enable_rate_limiting {
310            return RateLimitDecision {
311                allowed: true,
312                count: 0,
313                window: self.config.rate_limit_window,
314            };
315        }
316        // u64 -> f64 rounds to nearest; at window lengths where that loses a
317        // second the boundary shift is far below any real clock resolution.
318        #[allow(clippy::cast_precision_loss)]
319        let window = self.config.rate_limit_window as f64;
320        let now = (self.clock)();
321        let window_start = now - window;
322        let key = WindowKey {
323            ip: canonical(ip),
324            endpoint: endpoint.map(str::to_owned),
325        };
326        // The lock scope ends at the block: the decision is built unlocked.
327        let inside = {
328            let mut windows = self.windows.lock().expect("rate window store");
329            let timestamps = windows
330                .try_get_or_insert_mut(key, || Ok::<_, core::convert::Infallible>(VecDeque::new()))
331                .expect("key capacity just reserved");
332            while timestamps
333                .front()
334                .is_some_and(|&recorded| recorded <= window_start)
335            {
336                timestamps.pop_front();
337            }
338            let inside = timestamps.len();
339            timestamps.push_back(now);
340            inside
341        };
342        RateLimitDecision {
343            allowed: inside < usize::try_from(self.config.rate_limit).unwrap_or(usize::MAX),
344            count: u64::try_from(inside + 1).unwrap_or(u64::MAX),
345            window: self.config.rate_limit_window,
346        }
347    }
348
349    /// Drop every window, every recorded request included (`reset` in the
350    /// references; test harnesses use it to isolate cases).
351    pub fn reset(&self) {
352        self.windows.lock().expect("rate window store").clear();
353    }
354
355    /// How many windows the store currently tracks (test/observability
356    /// seam for the LRU cap).
357    #[must_use]
358    pub fn tracked_windows(&self) -> usize {
359        self.windows.lock().expect("rate window store").len()
360    }
361}
362
363#[cfg(test)]
364mod tests {
365    use super::*;
366    use std::str::FromStr;
367    use std::sync::atomic::{AtomicU64, Ordering};
368
369    /// A fake clock: f64 unix seconds starting at `1_000.0`, advanced by
370    /// `advance`.
371    #[derive(Clone, Default)]
372    struct FakeClock(Arc<AtomicU64>);
373
374    impl FakeClock {
375        fn advance(&self, seconds: u64) {
376            self.0.fetch_add(seconds, Ordering::Relaxed);
377        }
378
379        fn clock(&self) -> Clock {
380            let state = self.0.clone();
381            #[allow(clippy::cast_precision_loss)]
382            Arc::new(move || state.load(Ordering::Relaxed) as f64)
383        }
384    }
385
386    fn ip(text: &str) -> IpAddr {
387        IpAddr::from_str(text).expect("test address")
388    }
389
390    fn enabled_config(rate_limit: u32) -> RateLimitConfig {
391        RateLimitConfig {
392            enable_rate_limiting: true,
393            rate_limit,
394            ..RateLimitConfig::default()
395        }
396    }
397
398    #[test]
399    fn default_config_is_off_with_reference_thresholds() {
400        let config = RateLimitConfig::default();
401        assert!(!config.enable_rate_limiting);
402        assert_eq!(config.rate_limit, 10);
403        assert_eq!(config.rate_limit_window, 60);
404        assert!(!config.enable_rate_limit_auto_ban);
405    }
406
407    #[test]
408    fn new_fails_closed_on_zero_limit_or_window() {
409        let error = RateLimiter::new(RateLimitConfig {
410            rate_limit: 0,
411            ..RateLimitConfig::default()
412        })
413        .unwrap_err();
414        assert_eq!(error.field, "rate_limit");
415        assert_eq!(
416            error.to_string(),
417            "invalid rate_limit: must be at least 1 request per window"
418        );
419
420        let error = RateLimiter::new(RateLimitConfig {
421            enable_rate_limiting: true,
422            rate_limit: 5,
423            rate_limit_window: 0,
424            enable_rate_limit_auto_ban: false,
425        })
426        .unwrap_err();
427        assert_eq!(error.field, "rate_limit_window");
428    }
429
430    #[test]
431    fn disabled_limiter_allows_and_records_nothing() {
432        let limiter = RateLimiter::new(RateLimitConfig::default()).expect("default config");
433        for _ in 0..50 {
434            assert!(limiter.check(ip("192.0.2.1"), None).allowed);
435        }
436        assert_eq!(limiter.tracked_windows(), 0, "no window was recorded");
437    }
438
439    #[test]
440    fn blocks_at_the_crossing_and_reports_the_crossing_count() {
441        let limiter = RateLimiter::new(enabled_config(3)).expect("valid config");
442        for expected in 1..=3 {
443            let decision = limiter.check(ip("192.0.2.1"), None);
444            assert!(decision.allowed, "request {expected} must pass");
445            assert_eq!(decision.count, expected);
446        }
447        let decision = limiter.check(ip("192.0.2.1"), None);
448        assert!(
449            !decision.allowed,
450            "the 4th request inside the window blocks"
451        );
452        assert_eq!(decision.count, 4);
453        assert_eq!(decision.retry_after(), 60, "Retry-After is the window");
454    }
455
456    #[test]
457    fn window_slide_restores_the_budget() {
458        let fake = FakeClock::default();
459        let limiter = RateLimiter {
460            config: enabled_config(2),
461            windows: Arc::new(Mutex::new(LruCache::new(
462                NonZeroUsize::new(16).expect("above zero"),
463            ))),
464            clock: fake.clock(),
465        };
466        assert!(limiter.check(ip("192.0.2.1"), None).allowed);
467        assert!(limiter.check(ip("192.0.2.1"), None).allowed);
468        assert!(!limiter.check(ip("192.0.2.1"), None).allowed);
469
470        // Half the window later the crossing request is still remembered.
471        fake.advance(30);
472        assert!(!limiter.check(ip("192.0.2.1"), None).allowed);
473        // Past the window the expired timestamps are evicted and the budget
474        // is whole again.
475        fake.advance(31);
476        assert!(limiter.check(ip("192.0.2.1"), None).allowed);
477    }
478
479    #[test]
480    fn windows_are_per_ip() {
481        let limiter = RateLimiter::new(enabled_config(1)).expect("valid config");
482        assert!(limiter.check(ip("192.0.2.1"), None).allowed);
483        assert!(!limiter.check(ip("192.0.2.1"), None).allowed);
484        assert!(
485            limiter.check(ip("192.0.2.2"), None).allowed,
486            "another IP has its own budget"
487        );
488    }
489
490    #[test]
491    fn endpoint_scope_is_isolated_from_the_global_scope() {
492        let limiter = RateLimiter::new(enabled_config(1)).expect("valid config");
493        assert!(limiter.check(ip("192.0.2.1"), None).allowed);
494        assert!(
495            limiter.check(ip("192.0.2.1"), Some("/login")).allowed,
496            "the endpoint window is a separate budget"
497        );
498        assert!(!limiter.check(ip("192.0.2.1"), Some("/login")).allowed);
499        assert!(
500            limiter.check(ip("192.0.2.1"), Some("/signup")).allowed,
501            "another endpoint has its own budget"
502        );
503        assert_eq!(limiter.tracked_windows(), 3);
504    }
505
506    #[test]
507    fn ipv4_mapped_requests_share_the_ipv4_budget() {
508        let limiter = RateLimiter::new(enabled_config(1)).expect("valid config");
509        assert!(limiter.check(ip("::ffff:192.0.2.1"), None).allowed);
510        assert!(
511            !limiter.check(ip("192.0.2.1"), None).allowed,
512            "the mapped form must count toward the same window"
513        );
514    }
515
516    #[test]
517    fn reset_drops_every_window() {
518        let limiter = RateLimiter::new(enabled_config(1)).expect("valid config");
519        assert!(limiter.check(ip("192.0.2.1"), None).allowed);
520        assert!(!limiter.check(ip("192.0.2.1"), None).allowed);
521        limiter.reset();
522        assert_eq!(limiter.tracked_windows(), 0);
523        assert!(limiter.check(ip("192.0.2.1"), None).allowed);
524    }
525
526    #[test]
527    fn large_windows_keep_their_exact_length() {
528        // rate_limit_window beyond u32 must not fold the sliding window into
529        // something shorter (the f64 conversion saturates, never truncates).
530        let limiter = RateLimiter::new(RateLimitConfig {
531            enable_rate_limiting: true,
532            rate_limit: 1,
533            rate_limit_window: u64::from(u32::MAX) + 1,
534            enable_rate_limit_auto_ban: false,
535        })
536        .expect("valid config");
537        assert!(limiter.check(ip("192.0.2.1"), None).allowed);
538        let decision = limiter.check(ip("192.0.2.1"), None);
539        assert!(!decision.allowed);
540        assert_eq!(decision.retry_after(), u64::from(u32::MAX) + 1);
541    }
542}