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}