Skip to main content

shared_framework/middleware/
rate.rs

1//! Fixed-window rate limiting keyed by caller and route.
2//!
3//! The [`RateLimiter`] trait answers whether a `key` is still within `limit` requests
4//! for the current minute. [`InMemoryRateLimiter`] keeps per-minute buckets in process
5//! memory, [`RedisBackedRateLimiter`] shares buckets in Redis via `INCR` plus a 60s
6//! expiry (falling back to memory on Redis errors), and [`create_from_env`] picks the
7//! Redis variant when `REDIS_URL` or `REDIS_URI` is set, else the in-memory one.
8//! Routers build keys as `<user id or x-forwarded-for>:<route path>`.
9//! ```ignore
10//! if !limiter.is_allowed("user-1:/v1/users/list", 100).await { return Err(too_many()); }
11//! ```
12
13use std::collections::HashMap;
14use std::sync::{Arc, Mutex};
15use std::time::{Duration, Instant};
16
17use crate::data::cache::RedisStorage;
18
19/// Per-minute request gate: returns `true` when `key` is still within `limit`
20/// requests for the current minute, `false` when the caller must be rejected.
21#[async_trait::async_trait]
22pub trait RateLimiter: Send + Sync {
23    /// Returns whether `key` may proceed under `limit` requests for the current minute.
24    async fn is_allowed(&self, key: &str, limit: u32) -> bool;
25}
26
27// ── In-memory ────────────────────────────────────────────────────────────────
28
29/// In-process per-minute limiter: buckets are keyed by `"{key}:{minute}"` with a
30/// 60-second window and expired buckets are pruned by periodic cleanup.
31pub struct InMemoryRateLimiter {
32    inner: Mutex<HashMap<String, (u32, Instant)>>,
33    window: Duration,
34}
35
36impl InMemoryRateLimiter {
37    /// Creates a limiter with a 60-second window and no background cleanup task.
38    pub fn new() -> Self {
39        Self {
40            inner: Mutex::new(HashMap::new()),
41            window: Duration::from_secs(60),
42        }
43    }
44
45    /// Creates a limiter wrapped in `Arc` with a background task pruning expired buckets every 60s.
46    /// Hold the returned `Arc`; the task exits when the last clone is dropped.
47    pub fn new_with_cleanup() -> Arc<Self> {
48        let arc = Arc::new(Self {
49            inner: Mutex::new(HashMap::new()),
50            window: Duration::from_secs(60),
51        });
52        let weak = Arc::downgrade(&arc);
53        tokio::spawn(async move {
54            let mut interval = tokio::time::interval(Duration::from_secs(60));
55            loop {
56                interval.tick().await;
57                if let Some(limiter) = weak.upgrade() {
58                    limiter.cleanup();
59                } else {
60                    break;
61                }
62            }
63        });
64        arc
65    }
66
67    /// Synchronous check-and-increment for `key` against `limit` in the current minute bucket.
68    pub fn is_allowed_sync(&self, key: &str, limit: u32) -> bool {
69        let minute = chrono::Utc::now().timestamp() / 60;
70        let bucket_key = format!("{}:{}", key, minute);
71        let mut map = self.inner.lock().unwrap();
72        let now = Instant::now();
73        let entry = map.entry(bucket_key).or_insert((0, now + self.window));
74        if now > entry.1 {
75            *entry = (1, now + self.window);
76            return true;
77        }
78        if entry.0 < limit {
79            entry.0 += 1;
80            true
81        } else {
82            false
83        }
84    }
85
86    fn cleanup(&self) {
87        let now = Instant::now();
88        let mut map = self.inner.lock().unwrap();
89        map.retain(|_, (_, expiry)| *expiry > now);
90    }
91}
92
93impl Default for InMemoryRateLimiter {
94    fn default() -> Self {
95        Self::new()
96    }
97}
98
99#[async_trait::async_trait]
100impl RateLimiter for InMemoryRateLimiter {
101    async fn is_allowed(&self, key: &str, limit: u32) -> bool {
102        self.is_allowed_sync(key, limit)
103    }
104}
105
106// ── Redis-backed ─────────────────────────────────────────────────────────────
107
108/// Redis-shared per-minute limiter: increments `rate_limit:{key}:{minute}` with a 60s
109/// expiry on first use. Uses the in-memory limiter whenever Redis is unconfigured or fails.
110pub struct RedisBackedRateLimiter {
111    storage: Option<RedisStorage>,
112    fallback: Arc<InMemoryRateLimiter>,
113}
114
115impl RedisBackedRateLimiter {
116    /// Builds a limiter using Redis configuration from the environment when available,
117    /// otherwise operating on the in-memory fallback only.
118    pub fn new() -> Self {
119        let storage = RedisStorage::from_env().ok();
120        Self {
121            storage,
122            fallback: InMemoryRateLimiter::new_with_cleanup(),
123        }
124    }
125
126    /// Builds a limiter backed by the given Redis storage plus an in-memory fallback.
127    pub fn with_storage(storage: RedisStorage) -> Self {
128        Self {
129            storage: Some(storage),
130            fallback: InMemoryRateLimiter::new_with_cleanup(),
131        }
132    }
133
134    /// Builds a limiter connecting to `url`, falling back to memory when unreachable or on errors.
135    pub fn from_url(url: &str) -> Self {
136        let storage = RedisStorage::new(url).ok();
137        Self {
138            storage,
139            fallback: InMemoryRateLimiter::new_with_cleanup(),
140        }
141    }
142}
143
144impl Default for RedisBackedRateLimiter {
145    fn default() -> Self {
146        Self::new()
147    }
148}
149
150#[async_trait::async_trait]
151impl RateLimiter for RedisBackedRateLimiter {
152    async fn is_allowed(&self, key: &str, limit: u32) -> bool {
153        let Some(storage) = &self.storage else {
154            return self.fallback.is_allowed(key, limit).await;
155        };
156
157        let minute = chrono::Utc::now().timestamp() / 60;
158        let redis_key = format!("rate_limit:{}:{}", key, minute);
159
160        // INCR
161        match storage.increment_value(&redis_key).await {
162            Ok(count) => {
163                if count == 1 {
164                    // First request in this minute — set 60s TTL.
165                    let _ = storage.set_expiration(&redis_key, 60).await;
166                    return true;
167                }
168                count <= limit as i64
169            }
170            Err(e) => {
171                tracing::warn!(backend = "redis", fallback_backend = "in_memory", error = %e, "Rate limiter failed; using fallback");
172                self.fallback.is_allowed(key, limit).await
173            }
174        }
175    }
176}
177
178// ── Factory ──────────────────────────────────────────────────────────────────
179
180/// Selects which limiter backend [`create`] builds.
181pub enum LimiterKind {
182    /// In-process memory buckets.
183    InMemory,
184    /// Redis-shared buckets with an in-memory fallback.
185    Redis,
186}
187
188/// Builds the backend selected by `kind` as a shared trait object.
189pub fn create(kind: LimiterKind) -> Arc<dyn RateLimiter> {
190    match kind {
191        LimiterKind::InMemory => InMemoryRateLimiter::new_with_cleanup(),
192        LimiterKind::Redis => Arc::new(RedisBackedRateLimiter::new()),
193    }
194}
195
196/// Builds a shared limiter from the environment: Redis-backed when `REDIS_URL` or
197/// `REDIS_URI` is set, otherwise in-memory.
198pub fn create_from_env() -> Arc<dyn RateLimiter> {
199    if std::env::var("REDIS_URL").is_ok() || std::env::var("REDIS_URI").is_ok() {
200        tracing::info!(component = "rate_limiter", backend = "redis", "Configured rate limiter");
201        Arc::new(RedisBackedRateLimiter::new())
202    } else {
203        tracing::info!(component = "rate_limiter", backend = "in_memory", "Configured rate limiter");
204        InMemoryRateLimiter::new_with_cleanup()
205    }
206}