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}