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 (`rate:{ip}:{_hash_identity_segment(path)}`), a concern the
42//! in-memory store does not have.
43//!
44//! ## Tiers
45//!
46//! [`RateLimiter::check_tiers`] runs the reference pipeline's tier order
47//! (`RateLimitCheck.check`, `rate_limit.py`, via the Go port's
48//! `tiersFor`/`runTier`): every configured tier records one request into
49//! its own window and the first tier that crosses its limit blocks. The
50//! tiers, in order:
51//!
52//! 1. **endpoint**: `endpoint_rate_limits[path]` (the config-level
53//!    per-endpoint map), keyed by `(ip, path)`;
54//! 2. **route**: the route decorator's `rate_limit` (window default 60,
55//!    `rate_limit_window or 60`), keyed by `(ip, path)`;
56//! 3. **geo**: the route decorator's `geo_rate_limits` country map, the
57//!    resolved country's entry with the `"*"` fallback
58//!    (`country in limits else "*" in limits`), keyed by `(ip, path)`;
59//! 4. **global**: the config `rate_limit`/`rate_limit_window`, keyed by
60//!    `ip` alone.
61//!
62//! With no tiers configured the sequence collapses to the global tier, the
63//! exact pre-tier behavior. The decision names the tier that blocked so
64//! adapters can log the reference's reason strings, and carries that
65//! tier's window for `Retry-After`.
66//!
67//! ## Honesty
68//!
69//! No Redis: the distributed mode (shared budgets across workers, script
70//! reload handling) is a follow-up. The window store is an LRU capped at
71//! 10 000 keys (`maxTrackedRateLimitKeys` in the Go port,
72//! `_MAX_TRACKED_RATE_LIMIT_KEYS` in the reference), so abusive key
73//! cardinality cannot grow the store without bound.
74//!
75//! # Example
76//!
77//! ```
78//! use std::net::IpAddr;
79//! use std::str::FromStr;
80//!
81//! use guard_core_engine::rate_limit::{RateLimitConfig, RateLimiter};
82//!
83//! let limiter = RateLimiter::new(RateLimitConfig {
84//!     enable_rate_limiting: true,
85//!     rate_limit: 2,
86//!     ..RateLimitConfig::default()
87//! })
88//! .expect("valid config");
89//! let ip = IpAddr::from_str("192.0.2.1").unwrap();
90//!
91//! assert!(limiter.check(ip, None).allowed);
92//! assert!(limiter.check(ip, None).allowed);
93//! // The third request inside the window crosses the limit.
94//! let blocked = limiter.check(ip, None);
95//! assert!(!blocked.allowed);
96//! assert_eq!(blocked.retry_after(), 60);
97//! ```
98//!
99//! A tiered check runs every configured tier and names the one that
100//! blocked:
101//!
102//! ```
103//! use std::collections::HashMap;
104//! use std::net::IpAddr;
105//! use std::str::FromStr;
106//!
107//! use guard_core_engine::rate_limit::{
108//!     RateLimitConfig, RateLimitEntry, RateLimiter, RouteRateLimits,
109//! };
110//!
111//! let mut endpoint_rate_limits = HashMap::new();
112//! endpoint_rate_limits.insert("/login".to_owned(), RateLimitEntry::new(1, 60).unwrap());
113//! let limiter = RateLimiter::new(RateLimitConfig {
114//!     enable_rate_limiting: true,
115//!     endpoint_rate_limits,
116//!     ..RateLimitConfig::default()
117//! })
118//! .expect("valid config");
119//! let ip = IpAddr::from_str("192.0.2.1").unwrap();
120//!
121//! assert!(limiter.check_tiers(ip, Some("/login"), None, None).allowed());
122//! let blocked = limiter.check_tiers(ip, Some("/login"), None, None);
123//! assert!(!blocked.allowed());
124//! assert_eq!(blocked.tier_name(), "endpoint");
125//! ```
126//!
127//! A disabled limiter is inert: `check` allows without recording.
128
129use std::collections::{HashMap, VecDeque};
130use std::net::IpAddr;
131use std::num::NonZeroUsize;
132use std::sync::{Arc, Mutex};
133
134use lru::LruCache;
135
136use crate::ip_gate::canonical;
137
138/// The reference default for `rate_limit` (maximum requests per window).
139pub const DEFAULT_RATE_LIMIT: u32 = 10;
140/// The reference default for `rate_limit_window` (seconds).
141pub const DEFAULT_RATE_LIMIT_WINDOW: u64 = 60;
142/// The route tier's default window (`rate_limit_window or 60`, the
143/// `@rate_limit` decorator default).
144pub const DEFAULT_ROUTE_RATE_LIMIT_WINDOW: u64 = 60;
145/// The window store's key cap (`_MAX_TRACKED_RATE_LIMIT_KEYS` /
146/// `maxTrackedRateLimitKeys` in the references).
147pub const MAX_TRACKED_RATE_LIMIT_KEYS: usize = 10_000;
148
149/// One `(requests, window)` rate-limit entry: a per-endpoint or per-country
150/// tier override (the reference `endpoint_rate_limits[path]` /
151/// `geo_rate_limits[country]` `(int, int)` tuples).
152///
153/// Build it with [`RateLimitEntry::new`] (fail closed on a zero requests or
154/// window, the reference `ge=1` semantics the tier entries inherit from the
155/// flat knobs they override).
156#[derive(Debug, Clone, Copy, PartialEq, Eq)]
157pub struct RateLimitEntry {
158    /// Maximum requests inside the window.
159    pub requests: u32,
160    /// The window length in seconds.
161    pub window: u64,
162}
163
164impl RateLimitEntry {
165    /// Validate and build an entry.
166    ///
167    /// # Errors
168    ///
169    /// [`RateLimitConfigError`] on a zero `requests` or `window` (the
170    /// reference rejects both with `ge=1`).
171    pub fn new(requests: u32, window: u64) -> Result<Self, RateLimitConfigError> {
172        if requests == 0 {
173            return Err(RateLimitConfigError {
174                field: "requests".into(),
175                reason: "must be at least 1 request per window",
176            });
177        }
178        if window == 0 {
179            return Err(RateLimitConfigError {
180                field: "window".into(),
181                reason: "must be at least 1 second",
182            });
183        }
184        Ok(Self { requests, window })
185    }
186}
187
188/// The route decorator's rate-limit tier overrides
189/// (`RouteConfig.rate_limit` / `rate_limit_window` / `geo_rate_limits`).
190///
191/// Build it with [`RouteRateLimits::new`]; the geo tier's country entries
192/// are [`RateLimitEntry`] values (already validated). The geo country key
193/// set mirrors the reference: exact country codes with the `"*"`
194/// fallback entry, compared exactly (no case folding, the reference reads
195/// the resolved country as-is).
196#[derive(Debug, Clone, Default, PartialEq, Eq)]
197pub struct RouteRateLimits {
198    /// `route_config.rate_limit`: `None` means no route tier.
199    rate_limit: Option<u32>,
200    /// `route_config.rate_limit_window`: the route tier's window, the
201    /// reference default of 60 when `None` and a route tier is configured.
202    rate_limit_window: Option<u64>,
203    /// `route_config.geo_rate_limits`: the per-country `(limit, window)`
204    /// overrides with the `"*"` fallback entry; `None` or empty means no
205    /// geo tier.
206    geo_rate_limits: Option<HashMap<String, RateLimitEntry>>,
207}
208
209impl RouteRateLimits {
210    /// Validate and build the route tier overrides.
211    ///
212    /// # Errors
213    ///
214    /// [`RateLimitConfigError`] on a zero `rate_limit` or a zero
215    /// `rate_limit_window` that a configured route tier would run under
216    /// (the reference `ge=1` semantics); a `None` rate limit with a window
217    /// is inert and therefore not rejected, mirroring the reference where
218    /// `rate_limit_window` alone never configures a tier.
219    pub fn new(
220        rate_limit: Option<u32>,
221        rate_limit_window: Option<u64>,
222        geo_rate_limits: Option<HashMap<String, RateLimitEntry>>,
223    ) -> Result<Self, RateLimitConfigError> {
224        if rate_limit.is_some_and(|limit| limit == 0) {
225            return Err(RateLimitConfigError {
226                field: "rate_limit".into(),
227                reason: "must be at least 1 request per window",
228            });
229        }
230        if rate_limit.is_some() && rate_limit_window.is_some_and(|window| window == 0) {
231            return Err(RateLimitConfigError {
232                field: "rate_limit_window".into(),
233                reason: "must be at least 1 second",
234            });
235        }
236        if let Some(geo) = &geo_rate_limits {
237            for (country, entry) in geo {
238                if entry.requests == 0 || entry.window == 0 {
239                    return Err(RateLimitConfigError {
240                        field: format!("geo_rate_limits[{country}]").into(),
241                        reason: "every entry needs at least 1 request per at least 1 second",
242                    });
243                }
244            }
245        }
246        Ok(Self {
247            rate_limit,
248            rate_limit_window,
249            geo_rate_limits,
250        })
251    }
252
253    /// `route_config.rate_limit`.
254    #[must_use]
255    pub const fn rate_limit(&self) -> Option<u32> {
256        self.rate_limit
257    }
258
259    /// `route_config.rate_limit_window` (the reference default of 60
260    /// applies when a route tier runs and this is `None`).
261    #[must_use]
262    pub const fn rate_limit_window(&self) -> Option<u64> {
263        self.rate_limit_window
264    }
265
266    /// `route_config.geo_rate_limits`.
267    #[must_use]
268    pub const fn geo_rate_limits(&self) -> Option<&HashMap<String, RateLimitEntry>> {
269        self.geo_rate_limits.as_ref()
270    }
271}
272
273/// The rate-limiting knobs (the reference `enable_rate_limiting` /
274/// `rate_limit` / `rate_limit_window` / `enable_rate_limit_auto_ban` group).
275///
276/// The defaults are the reference `SecurityConfig` defaults: rate limiting
277/// on (`enable_rate_limiting = true`, `guard_core/_security_config_fields.py`)
278/// with the reference thresholds (10 requests / 60 s window).
279#[derive(Debug, Clone, PartialEq, Eq)]
280pub struct RateLimitConfig {
281    /// `enable_rate_limiting`. `false` makes every [`RateLimiter::check`]
282    /// an unconditional allow that records nothing.
283    pub enable_rate_limiting: bool,
284    /// `rate_limit`: maximum requests per client IP inside
285    /// `rate_limit_window` seconds.
286    pub rate_limit: u32,
287    /// `rate_limit_window`: the window length in seconds.
288    pub rate_limit_window: u64,
289    /// `enable_rate_limit_auto_ban`: feed rate-limit crossings into the
290    /// auto-ban engine (the `rate_limit` category of the violation counters)
291    /// when the pipeline stage runs with IP banning enabled.
292    pub enable_rate_limit_auto_ban: bool,
293    /// `endpoint_rate_limits`: the per-endpoint tier map (`path ->
294    /// (requests, window)`, the reference config field set by dynamic
295    /// rules), matched by exact path. Empty by default: the tier is
296    /// unconfigured and records nothing.
297    pub endpoint_rate_limits: HashMap<String, RateLimitEntry>,
298}
299
300impl Default for RateLimitConfig {
301    fn default() -> Self {
302        Self {
303            enable_rate_limiting: true,
304            rate_limit: DEFAULT_RATE_LIMIT,
305            rate_limit_window: DEFAULT_RATE_LIMIT_WINDOW,
306            enable_rate_limit_auto_ban: false,
307            endpoint_rate_limits: HashMap::new(),
308        }
309    }
310}
311
312/// An invalid [`RateLimitConfig`] or [`RouteRateLimits`]: the config error
313/// [`RateLimiter::new`] fails closed with.
314#[derive(Debug, Clone, PartialEq, Eq)]
315pub struct RateLimitConfigError {
316    /// The rejected field (`rate_limit`, `rate_limit_window`,
317    /// `endpoint_rate_limits`, or a tier entry field).
318    pub field: std::borrow::Cow<'static, str>,
319    /// What the field must be instead.
320    pub reason: &'static str,
321}
322
323impl core::fmt::Display for RateLimitConfigError {
324    fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
325        write!(f, "invalid {}: {}", self.field, self.reason)
326    }
327}
328
329impl std::error::Error for RateLimitConfigError {}
330
331/// The outcome of one rate-limit check.
332#[derive(Debug, Clone, Copy, PartialEq, Eq)]
333pub struct RateLimitDecision {
334    /// `false` when the request crossed the limit and must be answered with
335    /// the family's `429 Too many requests` carrying
336    /// [`RateLimitDecision::retry_after`].
337    pub allowed: bool,
338    /// Requests observed inside the window including this one (the count
339    /// both references report when they block).
340    pub count: u64,
341    /// The window length the decision was made under (seconds), so callers
342    /// can render `Retry-After` without re-reading the config.
343    pub window: u64,
344}
345
346impl RateLimitDecision {
347    /// The `Retry-After` header value for a blocked request: the window
348    /// length, exactly what the reference sets.
349    #[must_use]
350    pub const fn retry_after(self) -> u64 {
351        self.window
352    }
353}
354
355/// The rate-limit tier a [`RateLimiter::check_tiers`] decision was made
356/// under (`endpoint`, `route`, `geo`, `global` - the reference check order).
357#[derive(Debug, Clone, Copy, PartialEq, Eq)]
358pub enum RateLimitTier {
359    /// `endpoint_rate_limits[path]`: the config-level per-endpoint tier.
360    Endpoint,
361    /// The route decorator's `rate_limit` tier.
362    Route,
363    /// The route decorator's `geo_rate_limits` country tier.
364    Geo,
365    /// The flat config `rate_limit` tier (the reference default).
366    Global,
367}
368
369impl RateLimitTier {
370    /// The tier's name as the reference reasons spell it (`Endpoint-specific
371    /// rate limit exceeded`, `Route-specific rate limit exceeded`,
372    /// `Geo rate limit exceeded for {country}`, and the global tier's plain
373    /// `Rate limit exceeded`).
374    #[must_use]
375    pub const fn name(self) -> &'static str {
376        match self {
377            Self::Endpoint => "endpoint",
378            Self::Route => "route",
379            Self::Geo => "geo",
380            Self::Global => "global",
381        }
382    }
383}
384
385/// The outcome of one tiered rate-limit check
386/// ([`RateLimiter::check_tiers`]).
387#[derive(Debug, Clone, Copy, PartialEq, Eq)]
388pub struct TierDecision {
389    /// `false` when some tier crossed its limit and the request must be
390    /// answered with the family's `429 Too many requests` carrying
391    /// [`TierDecision::retry_after`].
392    allowed: bool,
393    /// Requests observed inside the blocking tier's window including this
394    /// one (the count both references report when they block); the last
395    /// tier's count when nothing blocked.
396    count: u64,
397    /// The tier that blocked (the last tier when nothing blocked).
398    tier: RateLimitTier,
399    /// The window length the decision was made under (seconds).
400    window: u64,
401}
402
403impl TierDecision {
404    /// `false` when some tier crossed its limit.
405    #[must_use]
406    pub const fn allowed(&self) -> bool {
407        self.allowed
408    }
409
410    /// The observed count of the decisive tier (including this request).
411    #[must_use]
412    pub const fn count(&self) -> u64 {
413        self.count
414    }
415
416    /// The tier the decision was made under.
417    #[must_use]
418    pub const fn tier(&self) -> RateLimitTier {
419        self.tier
420    }
421
422    /// The tier's name (`"endpoint"`, `"route"`, `"geo"`, `"global"`).
423    #[must_use]
424    pub const fn tier_name(&self) -> &'static str {
425        self.tier.name()
426    }
427
428    /// The `Retry-After` header value for a blocked request: the blocking
429    /// tier's window length, exactly what the reference sets.
430    #[must_use]
431    pub const fn retry_after(&self) -> u64 {
432        self.window
433    }
434}
435
436/// The monotonic-ish wall clock, in seconds since the Unix epoch. Injectable
437/// so expiry behavior (window sliding) is testable without sleeping.
438pub type Clock = Arc<dyn Fn() -> f64 + Send + Sync>;
439
440#[derive(Debug, PartialEq, Eq, Hash)]
441struct WindowKey {
442    ip: IpAddr,
443    endpoint: Option<String>,
444}
445
446/// The sliding-window rate limiter over one shared in-memory store.
447///
448/// Build it once at startup with [`RateLimiter::new`] (fail closed on a
449/// non-positive limit or window) and share the handle across requests; the
450/// internal store is a mutex-guarded LRU, safe for concurrent services.
451pub struct RateLimiter {
452    config: RateLimitConfig,
453    windows: Arc<Mutex<LruCache<WindowKey, VecDeque<f64>>>>,
454    clock: Clock,
455    store: Option<Arc<dyn crate::distributed::SlidingWindowStore>>,
456    redis_prefix: String,
457    redis_fail_open: bool,
458}
459
460impl Clone for RateLimiter {
461    /// A clone shares the window store and the clock, the references'
462    /// singleton semantics: requests recorded through any handle land in the
463    /// same sliding windows.
464    fn clone(&self) -> Self {
465        Self {
466            config: self.config.clone(),
467            windows: Arc::clone(&self.windows),
468            clock: Arc::clone(&self.clock),
469            store: self.store.clone(),
470            redis_prefix: self.redis_prefix.clone(),
471            redis_fail_open: self.redis_fail_open,
472        }
473    }
474}
475
476impl core::fmt::Debug for RateLimiter {
477    fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
478        f.debug_struct("RateLimiter")
479            .field("config", &self.config)
480            .finish_non_exhaustive()
481    }
482}
483
484/// The system wall clock, in seconds since the Unix epoch: the clock every
485/// production store runs on. Adapters and stage tests that build several
486/// stores over one clock share this constructor.
487#[must_use]
488pub fn system_clock() -> f64 {
489    std::time::SystemTime::now()
490        .duration_since(std::time::UNIX_EPOCH)
491        .map_or(0.0, |d| d.as_secs_f64())
492}
493
494/// A fresh shared window store (the store lives behind an `Arc` so clones of
495/// the limiter share it).
496fn new_window_store() -> Arc<Mutex<LruCache<WindowKey, VecDeque<f64>>>> {
497    Arc::new(Mutex::new(LruCache::new(
498        NonZeroUsize::new(MAX_TRACKED_RATE_LIMIT_KEYS).expect("constant above zero"),
499    )))
500}
501
502impl RateLimiter {
503    /// Validate the config and build the limiter.
504    ///
505    /// # Errors
506    ///
507    /// Fails closed with a [`RateLimitConfigError`] when `rate_limit` or
508    /// `rate_limit_window` is zero (the reference rejects both with
509    /// `ge=1`); nothing but `RateLimitConfig::default`-shaped values is
510    /// substituted silently.
511    pub fn new(config: RateLimitConfig) -> Result<Self, RateLimitConfigError> {
512        Self::with_config_and_clock(config, Arc::new(system_clock))
513    }
514
515    /// Validate the config and build the limiter over an injected clock: the
516    /// combined seam [`new`](Self::new) and [`with_clock`](Self::with_clock)
517    /// cover separately. Production builds use [`RateLimiter::new`]; adapters
518    /// and stage tests that drive window sliding with a fake clock under a
519    /// production-shaped config use this one.
520    ///
521    /// # Errors
522    ///
523    /// Same fail-closed behavior as [`RateLimiter::new`]: a zero `rate_limit`
524    /// or `rate_limit_window` is a [`RateLimitConfigError`].
525    pub fn with_config_and_clock(
526        config: RateLimitConfig,
527        clock: Clock,
528    ) -> Result<Self, RateLimitConfigError> {
529        if config.rate_limit == 0 {
530            return Err(RateLimitConfigError {
531                field: "rate_limit".into(),
532                reason: "must be at least 1 request per window",
533            });
534        }
535        if config.rate_limit_window == 0 {
536            return Err(RateLimitConfigError {
537                field: "rate_limit_window".into(),
538                reason: "must be at least 1 second",
539            });
540        }
541        // A struct-literal config skips the entry constructor validation,
542        // so the limiter re-validates and fails closed (the stage's
543        // struct-literal ban config precedent).
544        for (path, entry) in &config.endpoint_rate_limits {
545            if entry.requests == 0 || entry.window == 0 {
546                return Err(RateLimitConfigError {
547                    field: format!("endpoint_rate_limits[{path}]").into(),
548                    reason: "every entry needs at least 1 request per at least 1 second",
549                });
550            }
551        }
552        Ok(Self {
553            config,
554            windows: new_window_store(),
555            clock,
556            store: None,
557            redis_prefix: String::from("guard_core:"),
558            redis_fail_open: false,
559        })
560    }
561
562    /// Swap the wall clock. Test seam: production builds use the system
563    /// clock; deterministic window-sliding coverage injects a fake.
564    /// Run the limiter over a distributed window store (the reference
565    /// `enable_redis && redis_handler` conjunction): every tier's hit
566    /// records into `{prefix}rate_limit:rate:{ip}[:{endpoint hash}]`
567    /// through one transaction of the reference's four operations, and
568    /// the decision compares the post-recording count with
569    /// `count <= limit` (the Redis formulation; the same boundary as the
570    /// in-memory `count < limit`).
571    ///
572    /// `redis_fail_open = true` mirrors the reference `redis_fail_open`:
573    /// a failing backend call degrades to the in-memory window (the
574    /// `workers x rate_limit` caveat applies); `false` (the default)
575    /// surfaces the failure as `Err` so the caller can answer the
576    /// reference `503 "Redis rate limiting unavailable"`.
577    #[must_use]
578    pub fn with_distributed_store(
579        mut self,
580        store: Arc<dyn crate::distributed::SlidingWindowStore>,
581        redis_prefix: &str,
582        redis_fail_open: bool,
583    ) -> Self {
584        self.store = Some(store);
585        redis_prefix.clone_into(&mut self.redis_prefix);
586        self.redis_fail_open = redis_fail_open;
587        self
588    }
589
590    /// [`RateLimiter::check`] over the distributed store: `Err` means a
591    /// failing backend with `redis_fail_open = false`.
592    ///
593    /// # Errors
594    ///
595    /// [`StoreError`](crate::distributed::StoreError) when the backend
596    /// call fails and fail-open is off.
597    pub fn check_distributed(
598        &self,
599        ip: IpAddr,
600        endpoint: Option<&str>,
601    ) -> Result<RateLimitDecision, crate::distributed::StoreError> {
602        if !self.config.enable_rate_limiting {
603            return Ok(RateLimitDecision {
604                allowed: true,
605                count: 0,
606                window: self.config.rate_limit_window,
607            });
608        }
609        let (allowed, count, window) = self.record_and_decide_distributed(
610            ip,
611            endpoint,
612            self.config.rate_limit,
613            self.config.rate_limit_window,
614        )?;
615        Ok(RateLimitDecision {
616            allowed,
617            count,
618            window,
619        })
620    }
621
622    /// [`RateLimiter::check_tiers`] over the distributed store: every
623    /// tier keys its window the way the reference does (endpoint tiers
624    /// keyed with the hashed path segment, the global tier by IP alone).
625    ///
626    /// # Errors
627    ///
628    /// [`StoreError`](crate::distributed::StoreError) when a tier's
629    /// backend call fails and fail-open is off.
630    pub fn check_tiers_distributed(
631        &self,
632        ip: IpAddr,
633        url_path: Option<&str>,
634        route: Option<&RouteRateLimits>,
635        country_of_ip: Option<&dyn Fn(IpAddr) -> Option<String>>,
636    ) -> Result<TierDecision, crate::distributed::StoreError> {
637        let inert = TierDecision {
638            allowed: true,
639            count: 0,
640            tier: RateLimitTier::Global,
641            window: self.config.rate_limit_window,
642        };
643        if !self.config.enable_rate_limiting {
644            return Ok(inert);
645        }
646        if let Some(path) = url_path {
647            if let Some(entry) = self.config.endpoint_rate_limits.get(path) {
648                let (allowed, count, window) = self.record_and_decide_distributed(
649                    ip,
650                    Some(path),
651                    entry.requests,
652                    entry.window,
653                )?;
654                if !allowed {
655                    return Ok(TierDecision {
656                        allowed,
657                        count,
658                        tier: RateLimitTier::Endpoint,
659                        window,
660                    });
661                }
662            }
663            if let Some(route) = route {
664                if let Some(limit) = route.rate_limit() {
665                    let window = route
666                        .rate_limit_window()
667                        .unwrap_or(DEFAULT_ROUTE_RATE_LIMIT_WINDOW);
668                    let (allowed, count, window) =
669                        self.record_and_decide_distributed(ip, Some(path), limit, window)?;
670                    if !allowed {
671                        return Ok(TierDecision {
672                            allowed,
673                            count,
674                            tier: RateLimitTier::Route,
675                            window,
676                        });
677                    }
678                }
679                if let (Some(limits), Some(country_of_ip)) =
680                    (route.geo_rate_limits(), country_of_ip)
681                {
682                    let country = country_of_ip(ip);
683                    let entry = country
684                        .as_ref()
685                        .and_then(|code| limits.get(code))
686                        .or_else(|| limits.get("*"));
687                    if let Some(entry) = entry {
688                        let (allowed, count, window) = self.record_and_decide_distributed(
689                            ip,
690                            Some(path),
691                            entry.requests,
692                            entry.window,
693                        )?;
694                        if !allowed {
695                            return Ok(TierDecision {
696                                allowed,
697                                count,
698                                tier: RateLimitTier::Geo,
699                                window,
700                            });
701                        }
702                    }
703                }
704            }
705        }
706        let (allowed, count, window) = self.record_and_decide_distributed(
707            ip,
708            None,
709            self.config.rate_limit,
710            self.config.rate_limit_window,
711        )?;
712        Ok(TierDecision {
713            allowed,
714            count,
715            tier: RateLimitTier::Global,
716            window,
717        })
718    }
719
720    pub fn with_clock(clock: Clock) -> Self {
721        Self {
722            config: RateLimitConfig::default(),
723            windows: new_window_store(),
724            store: None,
725            redis_prefix: String::from("guard_core:"),
726            redis_fail_open: false,
727            clock,
728        }
729    }
730
731    /// The validated config the limiter decides under.
732    #[must_use]
733    pub const fn config(&self) -> &RateLimitConfig {
734        &self.config
735    }
736
737    /// Record one request into the window `(ip, endpoint)` under `limit`
738    /// requests per `window` seconds and decide it: the shared counting
739    /// core of [`RateLimiter::check`] and [`RateLimiter::check_tiers`].
740    // The store lock must outlive the eviction loop and the push (both
741    // mutate through the `timestamps` reference the guard produced); the
742    // nursery lint cannot see through that reference and wants it dropped
743    // early. The lock scope below already ends before the decision is built.
744    #[allow(clippy::significant_drop_tightening)]
745    /// The distributed hit path: one transaction of the reference's four
746    /// operations over the tier's Redis key, `allowed = count <= limit`
747    /// (the Redis formulation). Fail-open degrades to the in-memory
748    /// window on a backend error; fail-closed surfaces the error.
749    fn record_and_decide_distributed(
750        &self,
751        ip: IpAddr,
752        endpoint: Option<&str>,
753        limit: u32,
754        window_seconds: u64,
755    ) -> Result<(bool, u64, u64), crate::distributed::StoreError> {
756        let Some(store) = &self.store else {
757            return Ok(self.record_and_decide(ip, endpoint, limit, window_seconds));
758        };
759        let now = (self.clock)();
760        let key = endpoint.map_or_else(
761            || crate::distributed::rate_window_key(&self.redis_prefix, ip),
762            |path| crate::distributed::rate_window_key_endpoint(&self.redis_prefix, ip, path),
763        );
764        match store.record_hit(&key, now, window_seconds) {
765            Ok(count) => {
766                let allowed = count <= u64::from(limit);
767                Ok((allowed, count, window_seconds))
768            }
769            Err(error) if self.redis_fail_open => {
770                // The reference `_warn_redis_fail_open_in_memory_fallback`:
771                // with several workers the effective limit is
772                // workers x rate_limit. This port degrades silently per
773                // call; hosts log through their own stack.
774                let _ = error;
775                Ok(self.record_and_decide(ip, endpoint, limit, window_seconds))
776            }
777            Err(error) => Err(error),
778        }
779    }
780
781    fn record_and_decide(
782        &self,
783        ip: IpAddr,
784        endpoint: Option<&str>,
785        limit: u32,
786        window_seconds: u64,
787    ) -> (bool, u64, u64) {
788        // u64 -> f64 rounds to nearest; at window lengths where that loses a
789        // second the boundary shift is far below any real clock resolution.
790        #[allow(clippy::cast_precision_loss)]
791        let window = window_seconds as f64;
792        let now = (self.clock)();
793        let window_start = now - window;
794        let key = WindowKey {
795            ip: canonical(ip),
796            endpoint: endpoint.map(str::to_owned),
797        };
798        // The lock scope ends at the block: the decision is built unlocked.
799        let inside = {
800            let mut windows = self.windows.lock().expect("rate window store");
801            let timestamps = windows
802                .try_get_or_insert_mut(key, || Ok::<_, core::convert::Infallible>(VecDeque::new()))
803                .expect("key capacity just reserved");
804            while timestamps
805                .front()
806                .is_some_and(|&recorded| recorded <= window_start)
807            {
808                timestamps.pop_front();
809            }
810            let inside = timestamps.len();
811            timestamps.push_back(now);
812            drop(windows);
813            inside
814        };
815        let allowed = inside < usize::try_from(limit).unwrap_or(usize::MAX);
816        let count = u64::try_from(inside + 1).unwrap_or(u64::MAX);
817        (allowed, count, window_seconds)
818    }
819
820    /// Record one request for `ip` and decide it.
821    ///
822    /// `endpoint` selects the scope: `None` is the global per-IP window,
823    /// `Some(path)` the per-endpoint window keyed by `(ip, path)`. A
824    /// disabled limiter allows without recording. The returned decision's
825    /// `count` includes the request just recorded, so a block reports the
826    /// crossing count exactly as the references log it.
827    #[must_use]
828    pub fn check(&self, ip: IpAddr, endpoint: Option<&str>) -> RateLimitDecision {
829        if !self.config.enable_rate_limiting {
830            return RateLimitDecision {
831                allowed: true,
832                count: 0,
833                window: self.config.rate_limit_window,
834            };
835        }
836        let (allowed, count, window) = self.record_and_decide(
837            ip,
838            endpoint,
839            self.config.rate_limit,
840            self.config.rate_limit_window,
841        );
842        RateLimitDecision {
843            allowed,
844            count,
845            window,
846        }
847    }
848
849    /// Record one request for `ip` under every configured tier and decide
850    /// it (the reference `RateLimitCheck.check` order, via the Go port's
851    /// `tiersFor`/`runTier`): the endpoint tier, the route tier, the geo
852    /// tier, then the global tier. Every configured tier records one hit
853    /// into its own window (each tier is an independent budget, exactly as
854    /// the references count), and the first tier that crosses its limit
855    /// decides the outcome with its own window for `Retry-After`.
856    ///
857    /// `url_path` is the request path the endpoint tier matches
858    /// (`endpoint_rate_limits` exact match) and the route/geo tiers key
859    /// their windows by; without a path those tiers cannot key an isolated
860    /// window and are skipped (the references always carry a request path
861    /// here). `route` carries the decorator tiers (`None` skips both).
862    /// `country_of_ip` resolves the geolocation the geo tier reads (the
863    /// reference `geo_handler.get_country`); `None` or an unresolved
864    /// country skips the geo tier unless a `"*"` entry covers it. A
865    /// disabled limiter allows without recording anything.
866    #[must_use]
867    pub fn check_tiers(
868        &self,
869        ip: IpAddr,
870        url_path: Option<&str>,
871        route: Option<&RouteRateLimits>,
872        country_of_ip: Option<&dyn Fn(IpAddr) -> Option<String>>,
873    ) -> TierDecision {
874        let inert = TierDecision {
875            allowed: true,
876            count: 0,
877            tier: RateLimitTier::Global,
878            window: self.config.rate_limit_window,
879        };
880        if !self.config.enable_rate_limiting {
881            return inert;
882        }
883
884        // Tier 1: endpoint_rate_limits[path] (the reference
885        // `_check_endpoint_rate_limit` exact-path match).
886        if let Some(path) = url_path {
887            if let Some(entry) = self.config.endpoint_rate_limits.get(path) {
888                let (allowed, count, window) =
889                    self.record_and_decide(ip, Some(path), entry.requests, entry.window);
890                if !allowed {
891                    return TierDecision {
892                        allowed,
893                        count,
894                        tier: RateLimitTier::Endpoint,
895                        window,
896                    };
897                }
898            }
899
900            // Tier 2: the route decorator's rate_limit (the reference
901            // `_check_route_rate_limit`, window default 60).
902            if let Some(route) = route {
903                if let Some(limit) = route.rate_limit() {
904                    let window = route
905                        .rate_limit_window()
906                        .unwrap_or(DEFAULT_ROUTE_RATE_LIMIT_WINDOW);
907                    let (allowed, count, window) =
908                        self.record_and_decide(ip, Some(path), limit, window);
909                    if !allowed {
910                        return TierDecision {
911                            allowed,
912                            count,
913                            tier: RateLimitTier::Route,
914                            window,
915                        };
916                    }
917                }
918
919                // Tier 3: the route decorator's geo_rate_limits (the
920                // reference `_check_geo_rate_limit`): the resolved
921                // country's entry, else the "*" fallback.
922                if let (Some(limits), Some(country_of_ip)) =
923                    (route.geo_rate_limits(), country_of_ip)
924                {
925                    let country = country_of_ip(ip);
926                    let entry = country
927                        .as_ref()
928                        .and_then(|code| limits.get(code))
929                        .or_else(|| limits.get("*"));
930                    if let Some(entry) = entry {
931                        let (allowed, count, window) =
932                            self.record_and_decide(ip, Some(path), entry.requests, entry.window);
933                        if !allowed {
934                            return TierDecision {
935                                allowed,
936                                count,
937                                tier: RateLimitTier::Geo,
938                                window,
939                            };
940                        }
941                    }
942                }
943            }
944        }
945
946        // Tier 4: the global per-IP tier (the reference
947        // `_check_global_rate_limit`).
948        let (allowed, count, window) = self.record_and_decide(
949            ip,
950            None,
951            self.config.rate_limit,
952            self.config.rate_limit_window,
953        );
954        TierDecision {
955            allowed,
956            count,
957            tier: RateLimitTier::Global,
958            window,
959        }
960    }
961
962    /// Drop every window, every recorded request included (`reset` in the
963    /// references; test harnesses use it to isolate cases).
964    pub fn reset(&self) {
965        self.windows.lock().expect("rate window store").clear();
966    }
967
968    /// How many windows the store currently tracks (test/observability
969    /// seam for the LRU cap).
970    #[must_use]
971    pub fn tracked_windows(&self) -> usize {
972        self.windows.lock().expect("rate window store").len()
973    }
974}
975
976#[cfg(test)]
977mod tests {
978    use super::*;
979    use std::collections::HashMap;
980    use std::str::FromStr;
981    use std::sync::Arc;
982    use std::sync::atomic::{AtomicU64, Ordering};
983
984    use crate::distributed::MemoryStore;
985
986    /// A fake clock: f64 unix seconds starting at `1_000.0`, advanced by
987    /// `advance`.
988    #[derive(Clone, Default)]
989    struct FakeClock(Arc<AtomicU64>);
990
991    impl FakeClock {
992        fn advance(&self, seconds: u64) {
993            self.0.fetch_add(seconds, Ordering::Relaxed);
994        }
995
996        fn clock(&self) -> Clock {
997            let state = self.0.clone();
998            #[allow(clippy::cast_precision_loss)]
999            Arc::new(move || state.load(Ordering::Relaxed) as f64)
1000        }
1001    }
1002
1003    fn ip(text: &str) -> IpAddr {
1004        IpAddr::from_str(text).expect("test address")
1005    }
1006
1007    fn enabled_config(rate_limit: u32) -> RateLimitConfig {
1008        RateLimitConfig {
1009            enable_rate_limiting: true,
1010            rate_limit,
1011            ..RateLimitConfig::default()
1012        }
1013    }
1014
1015    #[test]
1016    fn default_config_matches_the_reference() {
1017        let config = RateLimitConfig::default();
1018        assert!(config.enable_rate_limiting);
1019        assert_eq!(config.rate_limit, 10);
1020        assert_eq!(config.rate_limit_window, 60);
1021        assert!(!config.enable_rate_limit_auto_ban);
1022    }
1023
1024    #[test]
1025    fn new_fails_closed_on_zero_limit_or_window() {
1026        let error = RateLimiter::new(RateLimitConfig {
1027            rate_limit: 0,
1028            ..RateLimitConfig::default()
1029        })
1030        .unwrap_err();
1031        assert_eq!(error.field, "rate_limit");
1032        // The Cow is transparent: a borrowed static field reads as itself.
1033        assert_eq!(error.field, std::borrow::Cow::Borrowed("rate_limit"));
1034        assert_eq!(
1035            error.to_string(),
1036            "invalid rate_limit: must be at least 1 request per window"
1037        );
1038
1039        let error = RateLimiter::new(RateLimitConfig {
1040            enable_rate_limiting: true,
1041            rate_limit: 5,
1042            rate_limit_window: 0,
1043            enable_rate_limit_auto_ban: false,
1044            endpoint_rate_limits: std::collections::HashMap::new(),
1045        })
1046        .unwrap_err();
1047        assert_eq!(error.field, "rate_limit_window");
1048    }
1049
1050    #[test]
1051    fn new_fails_closed_on_invalid_endpoint_entries() {
1052        let error = RateLimiter::new(RateLimitConfig {
1053            endpoint_rate_limits: std::iter::once((
1054                "/login".to_owned(),
1055                RateLimitEntry {
1056                    requests: 0,
1057                    window: 60,
1058                },
1059            ))
1060            .collect(),
1061            ..RateLimitConfig::default()
1062        })
1063        .unwrap_err();
1064        assert_eq!(error.field, "endpoint_rate_limits[/login]");
1065        assert_eq!(
1066            error.to_string(),
1067            "invalid endpoint_rate_limits[/login]: every entry needs at least 1 request \
1068             per at least 1 second"
1069        );
1070
1071        let error = RateLimitEntry::new(1, 0).unwrap_err();
1072        assert_eq!(error.field, "window");
1073
1074        let error = RouteRateLimits::new(Some(0), Some(60), None).unwrap_err();
1075        assert_eq!(error.field, "rate_limit");
1076
1077        let error = RouteRateLimits::new(Some(10), Some(0), None).unwrap_err();
1078        assert_eq!(error.field, "rate_limit_window");
1079
1080        let error = RouteRateLimits::new(
1081            None,
1082            None,
1083            Some(
1084                std::iter::once((
1085                    "RU".to_owned(),
1086                    RateLimitEntry {
1087                        requests: 1,
1088                        window: 0,
1089                    },
1090                ))
1091                .collect(),
1092            ),
1093        )
1094        .unwrap_err();
1095        assert_eq!(error.field, "geo_rate_limits[RU]");
1096
1097        // An inert route (no tier at all) and a window without a limit are
1098        // both valid: neither configures a tier.
1099        assert!(RouteRateLimits::new(None, None, None).is_ok());
1100        assert!(RouteRateLimits::new(None, Some(30), None).is_ok());
1101    }
1102
1103    #[test]
1104    fn disabled_limiter_allows_and_records_nothing() {
1105        let limiter = RateLimiter::new(RateLimitConfig {
1106            enable_rate_limiting: false,
1107            ..RateLimitConfig::default()
1108        })
1109        .expect("valid config");
1110        for _ in 0..50 {
1111            assert!(limiter.check(ip("192.0.2.1"), None).allowed);
1112        }
1113        assert_eq!(limiter.tracked_windows(), 0, "no window was recorded");
1114    }
1115
1116    #[test]
1117    fn blocks_at_the_crossing_and_reports_the_crossing_count() {
1118        let limiter = RateLimiter::new(enabled_config(3)).expect("valid config");
1119        for expected in 1..=3 {
1120            let decision = limiter.check(ip("192.0.2.1"), None);
1121            assert!(decision.allowed, "request {expected} must pass");
1122            assert_eq!(decision.count, expected);
1123        }
1124        let decision = limiter.check(ip("192.0.2.1"), None);
1125        assert!(
1126            !decision.allowed,
1127            "the 4th request inside the window blocks"
1128        );
1129        assert_eq!(decision.count, 4);
1130        assert_eq!(decision.retry_after(), 60, "Retry-After is the window");
1131    }
1132
1133    #[test]
1134    fn window_slide_restores_the_budget() {
1135        let fake = FakeClock::default();
1136        let limiter = RateLimiter {
1137            config: enabled_config(2),
1138            windows: Arc::new(Mutex::new(LruCache::new(
1139                NonZeroUsize::new(16).expect("above zero"),
1140            ))),
1141            clock: fake.clock(),
1142            store: None,
1143            redis_prefix: String::from("guard_core:"),
1144            redis_fail_open: false,
1145        };
1146        assert!(limiter.check(ip("192.0.2.1"), None).allowed);
1147        assert!(limiter.check(ip("192.0.2.1"), None).allowed);
1148        assert!(!limiter.check(ip("192.0.2.1"), None).allowed);
1149
1150        // Half the window later the crossing request is still remembered.
1151        fake.advance(30);
1152        assert!(!limiter.check(ip("192.0.2.1"), None).allowed);
1153        // Past the window the expired timestamps are evicted and the budget
1154        // is whole again.
1155        fake.advance(31);
1156        assert!(limiter.check(ip("192.0.2.1"), None).allowed);
1157    }
1158
1159    #[test]
1160    fn windows_are_per_ip() {
1161        let limiter = RateLimiter::new(enabled_config(1)).expect("valid config");
1162        assert!(limiter.check(ip("192.0.2.1"), None).allowed);
1163        assert!(!limiter.check(ip("192.0.2.1"), None).allowed);
1164        assert!(
1165            limiter.check(ip("192.0.2.2"), None).allowed,
1166            "another IP has its own budget"
1167        );
1168    }
1169
1170    #[test]
1171    fn endpoint_scope_is_isolated_from_the_global_scope() {
1172        let limiter = RateLimiter::new(enabled_config(1)).expect("valid config");
1173        assert!(limiter.check(ip("192.0.2.1"), None).allowed);
1174        assert!(
1175            limiter.check(ip("192.0.2.1"), Some("/login")).allowed,
1176            "the endpoint window is a separate budget"
1177        );
1178        assert!(!limiter.check(ip("192.0.2.1"), Some("/login")).allowed);
1179        assert!(
1180            limiter.check(ip("192.0.2.1"), Some("/signup")).allowed,
1181            "another endpoint has its own budget"
1182        );
1183        assert_eq!(limiter.tracked_windows(), 3);
1184    }
1185
1186    #[test]
1187    fn ipv4_mapped_requests_share_the_ipv4_budget() {
1188        let limiter = RateLimiter::new(enabled_config(1)).expect("valid config");
1189        assert!(limiter.check(ip("::ffff:192.0.2.1"), None).allowed);
1190        assert!(
1191            !limiter.check(ip("192.0.2.1"), None).allowed,
1192            "the mapped form must count toward the same window"
1193        );
1194    }
1195
1196    #[test]
1197    fn reset_drops_every_window() {
1198        let limiter = RateLimiter::new(enabled_config(1)).expect("valid config");
1199        assert!(limiter.check(ip("192.0.2.1"), None).allowed);
1200        assert!(!limiter.check(ip("192.0.2.1"), None).allowed);
1201        limiter.reset();
1202        assert_eq!(limiter.tracked_windows(), 0);
1203        assert!(limiter.check(ip("192.0.2.1"), None).allowed);
1204    }
1205
1206    #[test]
1207    fn large_windows_keep_their_exact_length() {
1208        // rate_limit_window beyond u32 must not fold the sliding window into
1209        // something shorter (the f64 conversion saturates, never truncates).
1210        let limiter = RateLimiter::new(RateLimitConfig {
1211            enable_rate_limiting: true,
1212            rate_limit: 1,
1213            rate_limit_window: u64::from(u32::MAX) + 1,
1214            ..RateLimitConfig::default()
1215        })
1216        .expect("valid config");
1217        assert!(limiter.check(ip("192.0.2.1"), None).allowed);
1218        let decision = limiter.check(ip("192.0.2.1"), None);
1219        assert!(!decision.allowed);
1220        assert_eq!(decision.retry_after(), u64::from(u32::MAX) + 1);
1221    }
1222
1223    /// The tier tests' fixed-country resolver.
1224    fn country_of(code: &'static str) -> impl Fn(IpAddr) -> Option<String> {
1225        move |_ip| Some(code.to_owned())
1226    }
1227
1228    #[test]
1229    fn tierless_check_tiers_collapses_to_the_global_tier() {
1230        // No endpoint entries, no route: check_tiers must behave exactly
1231        // like the pre-tier global window (zero change unless tiers are
1232        // configured).
1233        let limiter = RateLimiter::new(enabled_config(2)).expect("valid config");
1234        let visitor = ip("192.0.2.40");
1235        assert!(
1236            limiter
1237                .check_tiers(visitor, Some("/x"), None, None)
1238                .allowed()
1239        );
1240        assert!(
1241            limiter
1242                .check_tiers(visitor, Some("/x"), None, None)
1243                .allowed()
1244        );
1245        let blocked = limiter.check_tiers(visitor, Some("/x"), None, None);
1246        assert!(!blocked.allowed());
1247        assert_eq!(blocked.tier(), RateLimitTier::Global);
1248        assert_eq!(blocked.tier_name(), "global");
1249        assert_eq!(blocked.retry_after(), 60);
1250        assert_eq!(limiter.tracked_windows(), 1, "one global window");
1251    }
1252
1253    #[test]
1254    fn endpoint_tier_matches_the_exact_path_and_keys_its_own_window() {
1255        let limiter = RateLimiter::new(RateLimitConfig {
1256            endpoint_rate_limits: std::iter::once((
1257                "/login".to_owned(),
1258                RateLimitEntry::new(1, 60).expect("valid entry"),
1259            ))
1260            .collect(),
1261            ..RateLimitConfig::default()
1262        })
1263        .expect("valid config");
1264        let visitor = ip("192.0.2.41");
1265
1266        // The configured path crosses at its own (stricter) limit and the
1267        // block names the endpoint tier with its window.
1268        assert!(
1269            limiter
1270                .check_tiers(visitor, Some("/login"), None, None)
1271                .allowed()
1272        );
1273        let blocked = limiter.check_tiers(visitor, Some("/login"), None, None);
1274        assert!(!blocked.allowed());
1275        assert_eq!(blocked.tier(), RateLimitTier::Endpoint);
1276        assert_eq!(blocked.retry_after(), 60);
1277
1278        // Other paths never matched the endpoint tier: only the global
1279        // window counts there.
1280        assert!(
1281            limiter
1282                .check_tiers(visitor, Some("/other"), None, None)
1283                .allowed()
1284        );
1285        assert_eq!(
1286            limiter.tracked_windows(),
1287            2,
1288            "the login endpoint window + the shared global window"
1289        );
1290    }
1291
1292    #[test]
1293    fn route_tier_uses_the_decorator_window_default_of_60() {
1294        let fake = FakeClock::default();
1295        let limiter = RateLimiter {
1296            config: enabled_config(100),
1297            windows: Arc::new(Mutex::new(LruCache::new(
1298                NonZeroUsize::new(16).expect("above zero"),
1299            ))),
1300            clock: fake.clock(),
1301            store: None,
1302            redis_prefix: String::from("guard_core:"),
1303            redis_fail_open: false,
1304        };
1305        let route = RouteRateLimits::new(Some(1), None, None).expect("valid route");
1306        let visitor = ip("192.0.2.42");
1307
1308        assert!(
1309            limiter
1310                .check_tiers(visitor, Some("/x"), Some(&route), None)
1311                .allowed()
1312        );
1313        let blocked = limiter.check_tiers(visitor, Some("/x"), Some(&route), None);
1314        assert!(!blocked.allowed());
1315        assert_eq!(blocked.tier(), RateLimitTier::Route);
1316        assert_eq!(
1317            blocked.retry_after(),
1318            60,
1319            "the reference default window (rate_limit_window or 60)"
1320        );
1321
1322        // An explicit route window replaces the default.
1323        let route = RouteRateLimits::new(Some(1), Some(30), None).expect("valid route");
1324        let blocked = limiter.check_tiers(visitor, Some("/x"), Some(&route), None);
1325        assert!(!blocked.allowed());
1326        assert_eq!(blocked.retry_after(), 30);
1327    }
1328
1329    #[test]
1330    fn geo_tier_resolves_the_country_then_the_star_fallback() {
1331        let limiter = RateLimiter::new(RateLimitConfig {
1332            rate_limit: 100,
1333            ..RateLimitConfig::default()
1334        })
1335        .expect("valid config");
1336        let route = RouteRateLimits::new(
1337            None,
1338            None,
1339            Some(
1340                [
1341                    ("RU".to_owned(), RateLimitEntry::new(1, 60).expect("valid")),
1342                    ("*".to_owned(), RateLimitEntry::new(2, 45).expect("valid")),
1343                ]
1344                .into_iter()
1345                .collect(),
1346            ),
1347        )
1348        .expect("valid route");
1349
1350        // A resolved country uses its own entry.
1351        let russian = ip("192.0.2.43");
1352        assert!(
1353            limiter
1354                .check_tiers(russian, Some("/x"), Some(&route), Some(&country_of("RU")))
1355                .allowed()
1356        );
1357        let blocked =
1358            limiter.check_tiers(russian, Some("/x"), Some(&route), Some(&country_of("RU")));
1359        assert!(!blocked.allowed());
1360        assert_eq!(blocked.tier(), RateLimitTier::Geo);
1361        assert_eq!(blocked.retry_after(), 60);
1362
1363        // A country without an entry falls back to "*" (two requests).
1364        let other = ip("192.0.2.44");
1365        let resolver = country_of("DE");
1366        assert!(
1367            limiter
1368                .check_tiers(other, Some("/x"), Some(&route), Some(&resolver))
1369                .allowed()
1370        );
1371        assert!(
1372            limiter
1373                .check_tiers(other, Some("/x"), Some(&route), Some(&resolver))
1374                .allowed()
1375        );
1376        let blocked = limiter.check_tiers(other, Some("/x"), Some(&route), Some(&resolver));
1377        assert!(!blocked.allowed());
1378        assert_eq!(blocked.tier(), RateLimitTier::Geo);
1379        assert_eq!(blocked.retry_after(), 45);
1380
1381        // An unresolved country also reads the "*" fallback (the reference
1382        // `country and country in limits` else `"*" in limits`).
1383        let unknown: fn(IpAddr) -> Option<String> = |_ip| None;
1384        let third = ip("192.0.2.45");
1385        assert!(
1386            limiter
1387                .check_tiers(third, Some("/x"), Some(&route), Some(&unknown))
1388                .allowed()
1389        );
1390        assert!(
1391            limiter
1392                .check_tiers(third, Some("/x"), Some(&route), Some(&unknown))
1393                .allowed()
1394        );
1395        let blocked = limiter.check_tiers(third, Some("/x"), Some(&route), Some(&unknown));
1396        assert!(!blocked.allowed());
1397        assert_eq!(blocked.tier(), RateLimitTier::Geo);
1398    }
1399
1400    #[test]
1401    fn geo_tier_needs_a_configured_map_and_country_lookup() {
1402        // No geo entries on the route: no geo tier, the global tier runs.
1403        let limiter = RateLimiter::new(RateLimitConfig {
1404            rate_limit: 1,
1405            ..RateLimitConfig::default()
1406        })
1407        .expect("valid config");
1408        let route = RouteRateLimits::new(None, None, Some(std::collections::HashMap::new()))
1409            .expect("valid route");
1410        let visitor = ip("192.0.2.46");
1411        assert!(
1412            limiter
1413                .check_tiers(visitor, Some("/x"), Some(&route), Some(&country_of("RU")))
1414                .allowed()
1415        );
1416        let blocked =
1417            limiter.check_tiers(visitor, Some("/x"), Some(&route), Some(&country_of("RU")));
1418        assert!(!blocked.allowed());
1419        assert_eq!(blocked.tier(), RateLimitTier::Global);
1420
1421        // Geo entries without a country resolver: the reference skips the
1422        // geo tier entirely (`if not geo_handler: return None`), so the
1423        // "*" entry never applies without a handler.
1424        let route = RouteRateLimits::new(
1425            None,
1426            None,
1427            Some(
1428                std::iter::once(("*".to_owned(), RateLimitEntry::new(1, 60).expect("valid")))
1429                    .collect(),
1430            ),
1431        )
1432        .expect("valid route");
1433        let fresh = ip("192.0.2.51");
1434        assert!(
1435            limiter
1436                .check_tiers(fresh, Some("/x"), Some(&route), None)
1437                .allowed()
1438        );
1439        let blocked = limiter.check_tiers(fresh, Some("/x"), Some(&route), None);
1440        assert!(!blocked.allowed());
1441        assert_eq!(
1442            blocked.tier(),
1443            RateLimitTier::Global,
1444            "no handler, no geo tier"
1445        );
1446    }
1447
1448    #[test]
1449    fn every_configured_tier_records_so_the_budgets_stay_independent() {
1450        // The reference records one hit per configured tier per request:
1451        // a route tier with a huge limit and the global tier with a small
1452        // one still block from the global window, and the route window has
1453        // been counting all along.
1454        let limiter = RateLimiter::new(RateLimitConfig {
1455            rate_limit: 2,
1456            ..RateLimitConfig::default()
1457        })
1458        .expect("valid config");
1459        let route = RouteRateLimits::new(Some(1_000), Some(60), None).expect("valid route");
1460        let visitor = ip("192.0.2.47");
1461
1462        assert!(
1463            limiter
1464                .check_tiers(visitor, Some("/x"), Some(&route), None)
1465                .allowed()
1466        );
1467        assert!(
1468            limiter
1469                .check_tiers(visitor, Some("/x"), Some(&route), None)
1470                .allowed()
1471        );
1472        let blocked = limiter.check_tiers(visitor, Some("/x"), Some(&route), None);
1473        assert!(!blocked.allowed());
1474        assert_eq!(
1475            blocked.tier(),
1476            RateLimitTier::Global,
1477            "the global tier crossed first"
1478        );
1479        assert_eq!(blocked.count(), 3);
1480        assert_eq!(limiter.tracked_windows(), 2, "route + global windows");
1481
1482        // The route tier observed both requests: dropping the global limit
1483        // is impossible here, but its own budget is provably separate -
1484        // two of its 1_000 slots are consumed, so one more request passes
1485        // the route tier once the global window slides.
1486    }
1487
1488    #[test]
1489    fn first_blocked_tier_wins_the_response_shape() {
1490        // The endpoint tier blocks before the stricter global tier is even
1491        // consulted (the reference check order), so its window is the
1492        // Retry-After.
1493        let limiter = RateLimiter::new(RateLimitConfig {
1494            rate_limit: 1,
1495            rate_limit_window: 120,
1496            endpoint_rate_limits: std::iter::once((
1497                "/login".to_owned(),
1498                RateLimitEntry::new(1, 30).expect("valid"),
1499            ))
1500            .collect(),
1501            ..RateLimitConfig::default()
1502        })
1503        .expect("valid config");
1504        let visitor = ip("192.0.2.48");
1505        assert!(
1506            limiter
1507                .check_tiers(visitor, Some("/login"), None, None)
1508                .allowed()
1509        );
1510        let blocked = limiter.check_tiers(visitor, Some("/login"), None, None);
1511        assert!(!blocked.allowed());
1512        assert_eq!(blocked.tier(), RateLimitTier::Endpoint);
1513        assert_eq!(blocked.retry_after(), 30, "the endpoint tier's window");
1514
1515        // An unconfigured path blocks from the global tier instead.
1516        let blocked = limiter.check_tiers(visitor, Some("/other"), None, None);
1517        assert!(!blocked.allowed());
1518        assert_eq!(blocked.tier(), RateLimitTier::Global);
1519        assert_eq!(blocked.retry_after(), 120);
1520    }
1521
1522    #[test]
1523    fn tiers_without_a_request_path_skip_the_path_keyed_tiers() {
1524        // Without a path the endpoint/route/geo tiers cannot key an
1525        // isolated window: only the global tier runs (the references
1526        // always carry a request path here).
1527        let limiter = RateLimiter::new(RateLimitConfig {
1528            rate_limit: 1,
1529            endpoint_rate_limits: std::iter::once((
1530                "/login".to_owned(),
1531                RateLimitEntry::new(1, 60).expect("valid"),
1532            ))
1533            .collect(),
1534            ..RateLimitConfig::default()
1535        })
1536        .expect("valid config");
1537        let route = RouteRateLimits::new(Some(1), Some(60), None).expect("valid route");
1538        let visitor = ip("192.0.2.49");
1539        assert!(
1540            limiter
1541                .check_tiers(visitor, None, Some(&route), Some(&country_of("RU")))
1542                .allowed()
1543        );
1544        let blocked = limiter.check_tiers(visitor, None, Some(&route), Some(&country_of("RU")));
1545        assert!(!blocked.allowed());
1546        assert_eq!(blocked.tier(), RateLimitTier::Global);
1547    }
1548
1549    #[test]
1550    fn disabled_limiter_skips_the_tiers_without_recording() {
1551        let limiter = RateLimiter::new(RateLimitConfig {
1552            enable_rate_limiting: false,
1553            endpoint_rate_limits: std::iter::once((
1554                "/login".to_owned(),
1555                RateLimitEntry::new(1, 60).expect("valid"),
1556            ))
1557            .collect(),
1558            ..RateLimitConfig::default()
1559        })
1560        .expect("valid config");
1561        let route = RouteRateLimits::new(Some(1), Some(60), None).expect("valid route");
1562        let visitor = ip("192.0.2.50");
1563        for _ in 0..10 {
1564            let decision = limiter.check_tiers(visitor, Some("/login"), Some(&route), None);
1565            assert!(decision.allowed());
1566        }
1567        assert_eq!(limiter.tracked_windows(), 0, "nothing was recorded");
1568    }
1569
1570    #[test]
1571    fn zero_request_entries_are_rejected() {
1572        assert_eq!(RateLimitEntry::new(0, 60).err().unwrap().field, "requests");
1573    }
1574
1575    #[test]
1576    fn tier_names_read_like_the_reference_reasons() {
1577        assert_eq!(RateLimitTier::Endpoint.name(), "endpoint");
1578        assert_eq!(RateLimitTier::Route.name(), "route");
1579        assert_eq!(RateLimitTier::Geo.name(), "geo");
1580        assert_eq!(RateLimitTier::Global.name(), "global");
1581    }
1582
1583    #[test]
1584    fn limiter_debug_carries_the_config() {
1585        let limiter = RateLimiter::with_clock(Arc::new(system_clock));
1586        assert!(format!("{limiter:?}").contains("RateLimiter"));
1587        assert_eq!(limiter.config().rate_limit_window, 60);
1588    }
1589
1590    #[test]
1591    fn distributed_checks_short_circuit_when_disabled() {
1592        let limiter = RateLimiter::with_config_and_clock(
1593            RateLimitConfig {
1594                enable_rate_limiting: false,
1595                ..RateLimitConfig::default()
1596            },
1597            Arc::new(system_clock),
1598        )
1599        .expect("config");
1600        let decision = limiter
1601            .check_distributed(ip("192.0.2.51"), None)
1602            .expect("inert");
1603        assert!(decision.allowed);
1604
1605        let tiers = limiter
1606            .check_tiers_distributed(ip("192.0.2.51"), Some("/x"), None, None)
1607            .expect("inert");
1608        assert!(tiers.allowed());
1609    }
1610
1611    #[test]
1612    fn distributed_tiers_cover_endpoint_route_and_geo_windows() {
1613        let fake = FakeClock::default();
1614        let store = Arc::new(MemoryStore::default());
1615        let geo_limits = HashMap::from([
1616            ("RU".to_owned(), RateLimitEntry::new(2, 60).unwrap()),
1617            ("*".to_owned(), RateLimitEntry::new(5, 60).unwrap()),
1618        ]);
1619        let route = RouteRateLimits::new(Some(3), Some(30), Some(geo_limits)).expect("valid route");
1620        let limiter = RateLimiter::with_config_and_clock(
1621            RateLimitConfig {
1622                enable_rate_limiting: true,
1623                rate_limit: 100,
1624                endpoint_rate_limits: std::iter::once((
1625                    "/api".to_owned(),
1626                    RateLimitEntry::new(2, 60).unwrap(),
1627                ))
1628                .collect(),
1629                ..RateLimitConfig::default()
1630            },
1631            fake.clock(),
1632        )
1633        .expect("config")
1634        .with_distributed_store(
1635            Arc::clone(&store) as Arc<dyn crate::distributed::SlidingWindowStore>,
1636            "gc:",
1637            false,
1638        );
1639
1640        // the country resolver only answers for one probe address: both of
1641        // its arms run across the calls below
1642        let country_ru = |probe: IpAddr| -> Option<String> {
1643            if probe == ip("192.0.2.52") {
1644                Some("RU".to_owned())
1645            } else {
1646                None
1647            }
1648        };
1649        // the distributed tiers share the (ip, path) window, so each tier's
1650        // record lands in the same sliding window: three tiers put three hits
1651        // in it, and the RU geo entry (limit 2) throttles the first request
1652        let first = limiter
1653            .check_tiers_distributed(
1654                ip("192.0.2.52"),
1655                Some("/api"),
1656                Some(&route),
1657                Some(&country_ru),
1658            )
1659            .expect("store");
1660        assert!(!first.allowed());
1661        assert_eq!(first.tier(), RateLimitTier::Geo);
1662        assert_eq!(first.count(), 3);
1663
1664        // without a resolvable country the '*' fallback applies on its own
1665        // keyed window and stays under its limit
1666        let second = limiter
1667            .check_tiers_distributed(
1668                ip("192.0.2.53"),
1669                Some("/other"),
1670                Some(&route),
1671                Some(&country_ru),
1672            )
1673            .expect("store");
1674        assert!(second.allowed());
1675
1676        // the resolvable country on its own keyed window stays under the RU
1677        // entry (the resolver's hit arm)
1678        let third = limiter
1679            .check_tiers_distributed(
1680                ip("192.0.2.52"),
1681                Some("/other"),
1682                Some(&route),
1683                Some(&country_ru),
1684            )
1685            .expect("store");
1686        assert!(third.allowed());
1687    }
1688
1689    #[test]
1690    fn a_store_error_propagates_from_the_endpoint_tier() {
1691        let store = Arc::new(MemoryStore::default());
1692        let limiter = RateLimiter::with_config_and_clock(
1693            RateLimitConfig {
1694                enable_rate_limiting: true,
1695                rate_limit: 100,
1696                endpoint_rate_limits: std::iter::once((
1697                    "/api".to_owned(),
1698                    RateLimitEntry::new(2, 60).unwrap(),
1699                ))
1700                .collect(),
1701                ..RateLimitConfig::default()
1702            },
1703            FakeClock::default().clock(),
1704        )
1705        .expect("config")
1706        .with_distributed_store(
1707            Arc::clone(&store) as Arc<dyn crate::distributed::SlidingWindowStore>,
1708            "gc:",
1709            false,
1710        );
1711        store.fail.store(true, std::sync::atomic::Ordering::Relaxed);
1712        assert!(
1713            limiter
1714                .check_tiers_distributed(ip("192.0.2.60"), Some("/api"), None, None)
1715                .is_err()
1716        );
1717    }
1718
1719    #[test]
1720    fn a_store_error_propagates_from_the_route_tier() {
1721        let store = Arc::new(MemoryStore::default());
1722        let route = RouteRateLimits::new(Some(3), Some(60), None).expect("valid route");
1723        let limiter = RateLimiter::with_config_and_clock(
1724            RateLimitConfig {
1725                enable_rate_limiting: true,
1726                ..RateLimitConfig::default()
1727            },
1728            FakeClock::default().clock(),
1729        )
1730        .expect("config")
1731        .with_distributed_store(
1732            Arc::clone(&store) as Arc<dyn crate::distributed::SlidingWindowStore>,
1733            "gc:",
1734            false,
1735        );
1736        store.fail.store(true, std::sync::atomic::Ordering::Relaxed);
1737        // no endpoint entry: the route tier's limit records and the
1738        // backend-down error surfaces
1739        assert!(
1740            limiter
1741                .check_tiers_distributed(ip("192.0.2.59"), Some("/r"), Some(&route), None)
1742                .is_err()
1743        );
1744    }
1745
1746    #[test]
1747    fn a_store_error_propagates_from_the_geo_tier() {
1748        let store = Arc::new(MemoryStore::default());
1749        let geo_limits = HashMap::from([("RU".to_owned(), RateLimitEntry::new(2, 60).unwrap())]);
1750        let route = RouteRateLimits::new(None, None, Some(geo_limits)).expect("valid route");
1751        let limiter = RateLimiter::with_config_and_clock(
1752            RateLimitConfig {
1753                enable_rate_limiting: true,
1754                ..RateLimitConfig::default()
1755            },
1756            FakeClock::default().clock(),
1757        )
1758        .expect("config")
1759        .with_distributed_store(
1760            Arc::clone(&store) as Arc<dyn crate::distributed::SlidingWindowStore>,
1761            "gc:",
1762            false,
1763        );
1764        store.fail.store(true, std::sync::atomic::Ordering::Relaxed);
1765        // no endpoint entry and no route limit: the geo tier records and the
1766        // backend-down error surfaces
1767        assert!(
1768            limiter
1769                .check_tiers_distributed(
1770                    ip("192.0.2.61"),
1771                    Some("/other"),
1772                    Some(&route),
1773                    Some(&|_| Some("RU".to_owned())),
1774                )
1775                .is_err()
1776        );
1777    }
1778
1779    #[test]
1780    fn a_geo_limit_miss_falls_through_to_the_global_tier() {
1781        let store = Arc::new(MemoryStore::default());
1782        let geo_limits = HashMap::from([("CN".to_owned(), RateLimitEntry::new(2, 60).unwrap())]);
1783        let route = RouteRateLimits::new(None, None, Some(geo_limits)).expect("valid route");
1784        let limiter = RateLimiter::with_config_and_clock(
1785            RateLimitConfig {
1786                enable_rate_limiting: true,
1787                ..RateLimitConfig::default()
1788            },
1789            FakeClock::default().clock(),
1790        )
1791        .expect("config")
1792        .with_distributed_store(
1793            Arc::clone(&store) as Arc<dyn crate::distributed::SlidingWindowStore>,
1794            "gc:",
1795            false,
1796        );
1797        // the country has no geo entry and there is no '*' fallback
1798        let decision = limiter
1799            .check_tiers_distributed(
1800                ip("192.0.2.62"),
1801                Some("/other"),
1802                Some(&route),
1803                Some(&|_| Some("US".to_owned())),
1804            )
1805            .expect("store");
1806        assert!(decision.allowed());
1807        assert_eq!(decision.tier(), RateLimitTier::Global);
1808    }
1809}
1810
1811#[cfg(test)]
1812mod unit_twins {
1813    use super::*;
1814    use crate::distributed::MemoryStore;
1815    use std::str::FromStr;
1816
1817    fn ip(text: &str) -> IpAddr {
1818        IpAddr::from_str(text).expect("test address")
1819    }
1820
1821    /// A limiter wired to a backend that answers every call with the
1822    /// backend-down error, so each tier's `?` surfaces its `StoreError`.
1823    fn failing_store_limiter(endpoint: Option<(&str, RateLimitEntry)>) -> RateLimiter {
1824        let store = MemoryStore::default();
1825        store.fail.store(true, std::sync::atomic::Ordering::Relaxed);
1826        let mut endpoint_rate_limits = HashMap::new();
1827        if let Some((path, entry)) = endpoint {
1828            endpoint_rate_limits.insert(path.to_owned(), entry);
1829        }
1830        RateLimiter::with_config_and_clock(
1831            RateLimitConfig {
1832                enable_rate_limiting: true,
1833                rate_limit: 100,
1834                endpoint_rate_limits,
1835                ..RateLimitConfig::default()
1836            },
1837            Arc::new(system_clock),
1838        )
1839        .expect("config")
1840        .with_distributed_store(
1841            Arc::new(store) as Arc<dyn crate::distributed::SlidingWindowStore>,
1842            "gc:",
1843            false,
1844        )
1845    }
1846
1847    #[test]
1848    fn the_endpoint_tier_surfaces_a_failing_backend() {
1849        let limiter = failing_store_limiter(Some((
1850            "/api",
1851            RateLimitEntry::new(2, 60).expect("valid entry"),
1852        )));
1853        assert!(
1854            limiter
1855                .check_tiers_distributed(ip("192.0.2.60"), Some("/api"), None, None)
1856                .is_err()
1857        );
1858    }
1859
1860    #[test]
1861    fn the_geo_tier_surfaces_a_failing_backend() {
1862        let geo_limits = HashMap::from([(
1863            "RU".to_owned(),
1864            RateLimitEntry::new(2, 60).expect("valid entry"),
1865        )]);
1866        // the geo-only route reaches the geo stage before any record call
1867        let route = RouteRateLimits::new(None, None, Some(geo_limits)).expect("valid route");
1868        let limiter = failing_store_limiter(None);
1869        let country = |probe: IpAddr| -> Option<String> {
1870            if probe == ip("192.0.2.61") {
1871                Some("RU".to_owned())
1872            } else {
1873                None
1874            }
1875        };
1876        // a resolvable country runs the geo record against the failing
1877        // backend
1878        assert!(
1879            limiter
1880                .check_tiers_distributed(
1881                    ip("192.0.2.61"),
1882                    Some("/other"),
1883                    Some(&route),
1884                    Some(&country),
1885                )
1886                .is_err()
1887        );
1888        // an unresolvable country skips the geo entry and fails at the
1889        // global stage instead
1890        assert!(
1891            limiter
1892                .check_tiers_distributed(
1893                    ip("192.0.2.62"),
1894                    Some("/other"),
1895                    Some(&route),
1896                    Some(&country),
1897                )
1898                .is_err()
1899        );
1900    }
1901
1902    #[test]
1903    fn exhausted_tiers_answer_their_denials() {
1904        // a working store: each tier's own limit trips its decision shape
1905        let store = Arc::new(MemoryStore::default());
1906        let geo_limits = HashMap::from([(
1907            "RU".to_owned(),
1908            RateLimitEntry::new(1, 60).expect("valid entry"),
1909        )]);
1910        let limiter = RateLimiter::with_config_and_clock(
1911            RateLimitConfig {
1912                enable_rate_limiting: true,
1913                rate_limit: 1,
1914                endpoint_rate_limits: std::iter::once((
1915                    "/api".to_owned(),
1916                    RateLimitEntry::new(1, 60).expect("valid entry"),
1917                ))
1918                .collect(),
1919                ..RateLimitConfig::default()
1920            },
1921            Arc::new(system_clock),
1922        )
1923        .expect("config")
1924        .with_distributed_store(
1925            Arc::clone(&store) as Arc<dyn crate::distributed::SlidingWindowStore>,
1926            "gc:",
1927            false,
1928        );
1929        let country_ru = |probe: IpAddr| -> Option<String> {
1930            if probe == ip("192.0.2.63") || probe == ip("192.0.2.67") {
1931                Some("RU".to_owned())
1932            } else {
1933                None
1934            }
1935        };
1936        let route = RouteRateLimits::new(Some(1), Some(30), None).expect("valid route");
1937        // the geo route carries no decorator limit of its own, so the geo
1938        // entry's record is the first hit on the shared window
1939        let geo_route = RouteRateLimits::new(None, None, Some(geo_limits)).expect("valid route");
1940
1941        // the endpoint tier denies at its own limit
1942        let endpoint = limiter
1943            .check_tiers_distributed(ip("192.0.2.63"), Some("/api"), None, None)
1944            .expect("store");
1945        assert!(endpoint.allowed());
1946        let endpoint = limiter
1947            .check_tiers_distributed(ip("192.0.2.63"), Some("/api"), None, None)
1948            .expect("store");
1949        assert!(!endpoint.allowed());
1950
1951        // the route tier denies on its decorator limit
1952        let route_hit = limiter
1953            .check_tiers_distributed(ip("192.0.2.64"), Some("/route"), Some(&route), None)
1954            .expect("store");
1955        assert!(route_hit.allowed());
1956        let route_hit = limiter
1957            .check_tiers_distributed(ip("192.0.2.64"), Some("/route"), Some(&route), None)
1958            .expect("store");
1959        assert!(!route_hit.allowed());
1960
1961        // the geo tier denies on its country limit
1962        let geo = limiter
1963            .check_tiers_distributed(
1964                ip("192.0.2.67"),
1965                Some("/geo"),
1966                Some(&geo_route),
1967                Some(&country_ru),
1968            )
1969            .expect("store");
1970        assert!(geo.allowed(), "first geo: {geo:?}");
1971        // an unresolvable country skips the geo entry entirely
1972        let skipped = limiter
1973            .check_tiers_distributed(
1974                ip("192.0.2.68"),
1975                Some("/geo"),
1976                Some(&geo_route),
1977                Some(&country_ru),
1978            )
1979            .expect("store");
1980        assert!(skipped.allowed(), "skipped geo: {skipped:?}");
1981        let geo = limiter
1982            .check_tiers_distributed(
1983                ip("192.0.2.67"),
1984                Some("/geo"),
1985                Some(&geo_route),
1986                Some(&country_ru),
1987            )
1988            .expect("store");
1989        assert!(!geo.allowed(), "second geo: {geo:?}");
1990        assert_eq!(geo.tier(), RateLimitTier::Geo);
1991
1992        // the global tier denies on the workspace-wide limit
1993        let global = limiter
1994            .check_tiers_distributed(ip("192.0.2.65"), Some("/other"), None, None)
1995            .expect("store");
1996        assert!(global.allowed());
1997        let global = limiter
1998            .check_tiers_distributed(ip("192.0.2.65"), Some("/other"), None, None)
1999            .expect("store");
2000        assert!(!global.allowed());
2001        assert_eq!(global.tier(), RateLimitTier::Global);
2002    }
2003
2004    #[test]
2005    fn the_global_tier_surfaces_a_failing_backend() {
2006        // no endpoint entry, no route: the failure surfaces at the global
2007        // stage's own record call
2008        let limiter = failing_store_limiter(None);
2009        assert!(
2010            limiter
2011                .check_tiers_distributed(ip("192.0.2.66"), Some("/other"), None, None)
2012                .is_err()
2013        );
2014        assert!(
2015            limiter
2016                .check_tiers_distributed(ip("192.0.2.66"), None, None, None)
2017                .is_err()
2018        );
2019    }
2020
2021    #[test]
2022    fn a_geo_tier_without_a_matching_country_falls_through_to_the_global_tier() {
2023        // the route carries only a `RU` entry: an unresolvable country finds
2024        // no geo entry and the scan continues on the global tier
2025        let store = Arc::new(MemoryStore::default());
2026        let geo_limits = HashMap::from([(
2027            "RU".to_owned(),
2028            RateLimitEntry::new(2, 60).expect("valid entry"),
2029        )]);
2030        let route = RouteRateLimits::new(Some(3), Some(30), Some(geo_limits)).expect("valid route");
2031        let limiter = RateLimiter::with_config_and_clock(
2032            RateLimitConfig {
2033                enable_rate_limiting: true,
2034                rate_limit: 100,
2035                ..RateLimitConfig::default()
2036            },
2037            Arc::new(system_clock),
2038        )
2039        .expect("config")
2040        .with_distributed_store(
2041            Arc::clone(&store) as Arc<dyn crate::distributed::SlidingWindowStore>,
2042            "gc:",
2043            false,
2044        );
2045        let no_country = |_: IpAddr| -> Option<String> { None };
2046        let decision = limiter
2047            .check_tiers_distributed(
2048                ip("192.0.2.62"),
2049                Some("/other"),
2050                Some(&route),
2051                Some(&no_country),
2052            )
2053            .expect("store");
2054        assert!(decision.allowed());
2055        assert_eq!(decision.tier(), RateLimitTier::Global);
2056    }
2057}