Skip to main content

fraiseql_server/middleware/rate_limit/
dispatch.rs

1//! Rate limiter enum dispatch — routes calls to the active backend.
2//!
3//! `RateLimiter` is the public handle used by the rest of the server.
4//! It wraps either the in-memory or the Redis backend behind a uniform
5//! async API so callers never need to know which backend is active.
6
7#[cfg(feature = "redis-rate-limiting")]
8use super::redis::RedisRateLimiter;
9use super::{
10    config::{CheckResult, RateLimitConfig, RateLimitingSecurityConfig},
11    in_memory::InMemoryRateLimiter,
12};
13
14/// Rate limiter that dispatches to either an in-memory or Redis backend.
15///
16/// Construct via [`RateLimiter::new`] (in-memory, default) or
17/// `RateLimiter::new_redis` (distributed Redis, requires the
18/// `redis-rate-limiting` Cargo feature).
19#[non_exhaustive]
20pub enum RateLimiter {
21    /// Single-node token-bucket limiter backed by `HashMap` with `RwLock`.
22    InMemory(InMemoryRateLimiter),
23    /// Distributed token-bucket limiter backed by Redis Lua scripts.
24    #[cfg(feature = "redis-rate-limiting")]
25    Redis(RedisRateLimiter),
26}
27
28impl RateLimiter {
29    /// Create an in-memory rate limiter.
30    #[must_use]
31    pub fn new(config: RateLimitConfig) -> Self {
32        Self::InMemory(InMemoryRateLimiter::new(config))
33    }
34
35    /// Create a Redis-backed distributed rate limiter.
36    ///
37    /// # Errors
38    ///
39    /// Returns an error if the Redis URL is invalid or the initial connection
40    /// attempt fails.
41    #[cfg(feature = "redis-rate-limiting")]
42    pub async fn new_redis(url: &str, config: RateLimitConfig) -> Result<Self, redis::RedisError> {
43        let rl = RedisRateLimiter::new(url, config).await?;
44        Ok(Self::Redis(rl))
45    }
46
47    /// Attach per-path rules from `[security.rate_limiting]` auth endpoint fields.
48    #[must_use]
49    pub fn with_path_rules_from_security(self, sec: &RateLimitingSecurityConfig) -> Self {
50        match self {
51            Self::InMemory(rl) => Self::InMemory(rl.with_path_rules_from_security(sec)),
52            #[cfg(feature = "redis-rate-limiting")]
53            Self::Redis(rl) => Self::Redis(rl.with_path_rules_from_security(sec)),
54        }
55    }
56
57    /// Return the active rate limit configuration.
58    #[must_use]
59    pub const fn config(&self) -> &RateLimitConfig {
60        match self {
61            Self::InMemory(rl) => rl.config(),
62            #[cfg(feature = "redis-rate-limiting")]
63            Self::Redis(rl) => rl.config(),
64        }
65    }
66
67    /// Number of per-path rate limit rules registered.
68    #[must_use]
69    pub const fn path_rule_count(&self) -> usize {
70        match self {
71            Self::InMemory(rl) => rl.path_rule_count(),
72            #[cfg(feature = "redis-rate-limiting")]
73            Self::Redis(rl) => rl.path_rule_count(),
74        }
75    }
76
77    /// Seconds a client should wait before retrying after a per-path rate limit rejection.
78    ///
79    /// Returns the window duration for the matching path rule (e.g. 60s for an
80    /// auth/start rule with 5 req/60s), not the IP token-bucket interval.
81    #[must_use]
82    pub fn retry_after_for_path(&self, path: &str) -> u32 {
83        match self {
84            Self::InMemory(rl) => rl.retry_after_for_path(path),
85            #[cfg(feature = "redis-rate-limiting")]
86            Self::Redis(rl) => rl.retry_after_for_path(path),
87        }
88    }
89
90    /// Check whether a request from `ip` is within the global IP rate limit.
91    #[doc(hidden)] // Internal-pub: invoked by rate_limit_middleware; downstream configures rate limiting via TOML, not by calling this directly.
92    pub async fn check_ip_limit(&self, ip: &str, tenant_id: Option<&str>) -> CheckResult {
93        match self {
94            Self::InMemory(rl) => rl.check_ip_limit(ip, tenant_id).await,
95            #[cfg(feature = "redis-rate-limiting")]
96            Self::Redis(rl) => rl.check_ip_limit(ip).await,
97        }
98    }
99
100    /// Check whether a request from `user_id` is within the per-user limit.
101    #[doc(hidden)] // Internal-pub: invoked by rate_limit_middleware; downstream configures rate limiting via TOML, not by calling this directly.
102    pub async fn check_user_limit(&self, user_id: &str, tenant_id: Option<&str>) -> CheckResult {
103        match self {
104            Self::InMemory(rl) => rl.check_user_limit(user_id, tenant_id).await,
105            #[cfg(feature = "redis-rate-limiting")]
106            Self::Redis(rl) => rl.check_user_limit(user_id).await,
107        }
108    }
109
110    /// Check the per-path rate limit for a request from `ip` to `path`.
111    ///
112    /// Returns an allowed [`CheckResult`] when no rule governs the path.
113    /// `CheckResult::retry_after_secs` reflects the actual per-path window, not
114    /// the global IP rate.
115    pub async fn check_path_limit(&self, path: &str, ip: &str) -> CheckResult {
116        match self {
117            Self::InMemory(rl) => rl.check_path_limit(path, ip).await,
118            #[cfg(feature = "redis-rate-limiting")]
119            Self::Redis(rl) => rl.check_path_limit(path, ip).await,
120        }
121    }
122
123    /// Check the per-tenant rate limit for a request to tenant `key`.
124    ///
125    /// Each tenant gets its own token bucket with the specified `rps` and `burst`
126    /// from the tenant's quota configuration.
127    ///
128    /// # Note
129    ///
130    /// Redis backend does not support per-tenant limits (falls back to allow-all).
131    pub async fn check_tenant_limit(&self, tenant_key: &str, rps: u32, burst: u32) -> CheckResult {
132        match self {
133            Self::InMemory(rl) => rl.check_tenant_limit(tenant_key, rps, burst).await,
134            #[cfg(feature = "redis-rate-limiting")]
135            Self::Redis(_) => CheckResult::allow(f64::from(burst)),
136        }
137    }
138
139    /// Evict stale in-memory buckets.
140    ///
141    /// No-op for the Redis backend — Redis handles expiry via `PEXPIRE`.
142    pub async fn cleanup(&self) {
143        match self {
144            Self::InMemory(rl) => rl.cleanup().await,
145            #[cfg(feature = "redis-rate-limiting")]
146            Self::Redis(_) => {},
147        }
148    }
149
150    /// Conservative static estimate of how long (in seconds) a client must wait
151    /// before the IP-level bucket refills one token: `ceil(1 / rps_per_ip)`.
152    ///
153    /// Used when no backend-computed `retry_after_ms` is available (e.g., the
154    /// in-memory backend before the precise value is plumbed end-to-end, or as
155    /// a fallback on Redis errors).  Minimum 1 second.
156    #[must_use]
157    pub fn retry_after_secs(&self) -> u32 {
158        let rps = self.config().rps_per_ip;
159        if rps == 0 {
160            return 1;
161        }
162        #[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
163        // Reason: ceil(1/rps) is always a small positive integer
164        {
165            ((1.0_f64 / f64::from(rps)).ceil() as u32).max(1)
166        }
167    }
168}