rust_web_server/rate_limit/mod.rs
1#[cfg(test)]
2mod tests;
3
4use std::collections::{HashMap, VecDeque};
5use std::sync::{Mutex, OnceLock};
6#[cfg(not(target_arch = "wasm32"))]
7use std::sync::Arc;
8use std::sync::atomic::{AtomicU32, AtomicU64, Ordering};
9use std::time::{Duration, Instant};
10
11/// A sliding-window per-key rate limiter.
12///
13/// Each call to [`RateLimiter::check`] records a timestamp for the given key
14/// and returns `true` when the number of calls within the current window is
15/// still below `max_requests`. Returns `false` once the limit is exceeded.
16///
17/// Thread-safe: the internal state is behind a `Mutex` so it can be shared
18/// across threads via [`global`] or wrapped in an `Arc`.
19///
20/// # Example
21///
22/// ```rust,no_run
23/// use rust_web_server::rate_limit::RateLimiter;
24///
25/// let limiter = RateLimiter::new(100, 60); // 100 req / 60 s
26///
27/// if limiter.check("192.168.1.1") {
28/// // process request
29/// } else {
30/// // return 429 Too Many Requests
31/// }
32/// ```
33pub struct RateLimiter {
34 state: Mutex<HashMap<String, VecDeque<Instant>>>,
35 max_requests: AtomicU32,
36 window_secs: AtomicU64,
37}
38
39impl RateLimiter {
40 /// Create a new limiter allowing `max_requests` per `window_secs`-second window.
41 pub fn new(max_requests: u32, window_secs: u64) -> Self {
42 RateLimiter {
43 state: Mutex::new(HashMap::new()),
44 max_requests: AtomicU32::new(max_requests),
45 window_secs: AtomicU64::new(window_secs),
46 }
47 }
48
49 /// Update the limits on a live limiter without restarting.
50 ///
51 /// Changes take effect on the next call to [`check`] or [`remaining`].
52 /// Called automatically by [`crate::config_reload::reload`] on SIGHUP.
53 pub fn set_limits(&self, max_requests: u32, window_secs: u64) {
54 self.max_requests.store(max_requests, Ordering::Relaxed);
55 self.window_secs.store(window_secs, Ordering::Relaxed);
56 }
57
58 fn window(&self) -> Duration {
59 Duration::from_secs(self.window_secs.load(Ordering::Relaxed))
60 }
61
62 fn max(&self) -> u32 {
63 self.max_requests.load(Ordering::Relaxed)
64 }
65
66 /// Returns `true` if `key` (typically a client IP) is within the rate limit,
67 /// or `false` if the limit has been exceeded.
68 ///
69 /// A permitted call is always recorded so it counts toward future limits.
70 pub fn check(&self, key: &str) -> bool {
71 let now = Instant::now();
72 let window = self.window();
73 let max = self.max();
74 let mut guard = self.state.lock().unwrap();
75 let timestamps = guard.entry(key.to_string()).or_default();
76
77 // Drop timestamps older than the window.
78 while timestamps.front().map(|t| now.duration_since(*t) > window).unwrap_or(false) {
79 timestamps.pop_front();
80 }
81
82 if (timestamps.len() as u32) < max {
83 timestamps.push_back(now);
84 true
85 } else {
86 false
87 }
88 }
89
90 /// Number of remaining requests `key` may make within the current window.
91 pub fn remaining(&self, key: &str) -> u32 {
92 let now = Instant::now();
93 let window = self.window();
94 let max = self.max();
95 let mut guard = self.state.lock().unwrap();
96 let timestamps = guard.entry(key.to_string()).or_default();
97 while timestamps.front().map(|t| now.duration_since(*t) > window).unwrap_or(false) {
98 timestamps.pop_front();
99 }
100 max.saturating_sub(timestamps.len() as u32)
101 }
102
103 /// Remove all tracked state for `key`. Useful in tests.
104 pub fn reset(&self, key: &str) {
105 self.state.lock().unwrap().remove(key);
106 }
107}
108
109// ── RedisRateLimiter ──────────────────────────────────────────────────────────
110
111#[cfg(not(target_arch = "wasm32"))]
112use crate::redis_protocol::{RespConn, RespReply};
113
114/// A distributed, fixed-window rate limiter backed by a Redis server.
115///
116/// [`RateLimiter`] only protects a single process: two `rws` instances behind
117/// a load balancer each track their own in-memory counters, so the effective
118/// limit for a client doubles (or worse) as replicas scale out. `RedisRateLimiter`
119/// keys the counter on a shared Redis server instead, so every instance enforces
120/// the same budget.
121///
122/// Unlike [`RateLimiter`]'s sliding window (a deque of timestamps), this uses a
123/// fixed-window counter: `key`'s count lives under one Redis key with a TTL equal
124/// to the window length, incremented atomically via `INCR`. This is the standard
125/// distributed rate-limiting pattern — Redis guarantees `INCR` is atomic across
126/// concurrent callers, so counters stay correct even under heavy concurrent load
127/// from multiple `rws` processes. The tradeoff versus a sliding window: a client
128/// can burst up to `2x max_requests` across a window boundary (once near the end
129/// of one window, once at the start of the next).
130///
131/// Cloning is cheap — all clones share the same underlying TCP connection (one
132/// persistent connection per `RedisRateLimiter` instance).
133///
134/// # Example
135///
136/// ```rust,no_run
137/// use rust_web_server::rate_limit::RedisRateLimiter;
138///
139/// let limiter = RedisRateLimiter::new("127.0.0.1:6379", None, 100, 60); // 100 req / 60s
140///
141/// match limiter.check("192.168.1.1") {
142/// Ok(true) => { /* process request */ }
143/// Ok(false) => { /* return 429 Too Many Requests */ }
144/// Err(e) => { /* Redis unreachable — decide fail-open vs fail-closed */ }
145/// }
146/// ```
147#[cfg(not(target_arch = "wasm32"))]
148pub struct RedisRateLimiter {
149 conn: Arc<RespConn>,
150 max_requests: AtomicU32,
151 window_secs: AtomicU64,
152}
153
154#[cfg(not(target_arch = "wasm32"))]
155impl Clone for RedisRateLimiter {
156 fn clone(&self) -> Self {
157 RedisRateLimiter {
158 conn: Arc::clone(&self.conn),
159 max_requests: AtomicU32::new(self.max()),
160 window_secs: AtomicU64::new(self.window().as_secs()),
161 }
162 }
163}
164
165#[cfg(not(target_arch = "wasm32"))]
166impl RedisRateLimiter {
167 /// Create a limiter that connects to `addr` (e.g. `"127.0.0.1:6379"`),
168 /// allowing `max_requests` per `window_secs`-second window.
169 /// `password` is passed to Redis `AUTH` if `Some`.
170 pub fn new(addr: impl Into<String>, password: Option<String>, max_requests: u32, window_secs: u64) -> Self {
171 RedisRateLimiter {
172 conn: Arc::new(RespConn::new(addr, password)),
173 max_requests: AtomicU32::new(max_requests),
174 window_secs: AtomicU64::new(window_secs),
175 }
176 }
177
178 /// Build a limiter from environment variables:
179 /// - `RWS_REDIS_HOST` (default `127.0.0.1`)
180 /// - `RWS_REDIS_PORT` (default `6379`)
181 /// - `RWS_REDIS_PASSWORD` (optional)
182 /// - `RWS_CONFIG_RATE_LIMIT_MAX_REQUESTS` (default `1000`)
183 /// - `RWS_CONFIG_RATE_LIMIT_WINDOW_SECS` (default `60`)
184 pub fn from_env() -> Self {
185 let host = std::env::var("RWS_REDIS_HOST").unwrap_or_else(|_| "127.0.0.1".into());
186 let port = std::env::var("RWS_REDIS_PORT").unwrap_or_else(|_| "6379".into());
187 let addr = format!("{}:{}", host, port);
188 let password = std::env::var("RWS_REDIS_PASSWORD").ok();
189 let max: u32 = std::env::var("RWS_CONFIG_RATE_LIMIT_MAX_REQUESTS")
190 .ok()
191 .and_then(|v| v.parse().ok())
192 .unwrap_or(1000);
193 let window: u64 = std::env::var("RWS_CONFIG_RATE_LIMIT_WINDOW_SECS")
194 .ok()
195 .and_then(|v| v.parse().ok())
196 .unwrap_or(60);
197 Self::new(addr, password, max, window)
198 }
199
200 /// Update the limits on a live limiter without restarting.
201 pub fn set_limits(&self, max_requests: u32, window_secs: u64) {
202 self.max_requests.store(max_requests, Ordering::Relaxed);
203 self.window_secs.store(window_secs, Ordering::Relaxed);
204 }
205
206 fn window(&self) -> Duration {
207 Duration::from_secs(self.window_secs.load(Ordering::Relaxed))
208 }
209
210 fn max(&self) -> u32 {
211 self.max_requests.load(Ordering::Relaxed)
212 }
213
214 fn redis_key(key: &str) -> Vec<u8> {
215 format!("rws:ratelimit:{}", key).into_bytes()
216 }
217
218 /// Returns `Ok(true)` if `key` (typically a client IP) is within the rate
219 /// limit, `Ok(false)` if the limit has been exceeded, or `Err` if the
220 /// Redis server could not be reached.
221 ///
222 /// A call always increments the shared counter, whether permitted or not,
223 /// so callers must decide for themselves whether to fail open (allow the
224 /// request) or fail closed (deny it) on `Err` — this limiter does not
225 /// silently pick one.
226 pub fn check(&self, key: &str) -> std::io::Result<bool> {
227 let redis_key = Self::redis_key(key);
228 let window = self.window().as_secs().to_string();
229 // Atomically create the key with a TTL the first time it's seen. If
230 // it already exists, `SET ... NX` is a no-op — this only races once,
231 // at creation, and `SET NX` is itself atomic on the Redis server.
232 self.conn.cmd(&[b"SET", &redis_key, b"0", b"EX", window.as_bytes(), b"NX"])?;
233 let count = match self.conn.cmd(&[b"INCR", &redis_key])? {
234 RespReply::Int(n) => n,
235 _ => return Err(std::io::Error::new(std::io::ErrorKind::InvalidData, "unexpected INCR reply")),
236 };
237 Ok(count as u32 <= self.max())
238 }
239
240 /// Number of remaining requests `key` may make within the current window,
241 /// or `Err` if the Redis server could not be reached.
242 pub fn remaining(&self, key: &str) -> std::io::Result<u32> {
243 match self.conn.cmd(&[b"GET", &Self::redis_key(key)])? {
244 RespReply::Bulk(Some(bytes)) => {
245 let count: u32 = String::from_utf8_lossy(&bytes).parse().unwrap_or(0);
246 Ok(self.max().saturating_sub(count))
247 }
248 _ => Ok(self.max()),
249 }
250 }
251
252 /// Remove all tracked state for `key`. Useful in tests.
253 pub fn reset(&self, key: &str) -> std::io::Result<()> {
254 self.conn.cmd(&[b"DEL", &Self::redis_key(key)])?;
255 Ok(())
256 }
257}
258
259static GLOBAL_LIMITER: OnceLock<RateLimiter> = OnceLock::new();
260
261/// Return the process-wide rate limiter, initialized from environment variables.
262///
263/// | Variable | Default | Meaning |
264/// |---|---|---|
265/// | `RWS_CONFIG_RATE_LIMIT_MAX_REQUESTS` | `1000` | Requests allowed per window |
266/// | `RWS_CONFIG_RATE_LIMIT_WINDOW_SECS` | `60` | Window length in seconds |
267///
268/// Returns `None` when rate limiting is disabled (`RWS_CONFIG_RATE_LIMIT_MAX_REQUESTS=0`).
269pub fn global() -> &'static RateLimiter {
270 GLOBAL_LIMITER.get_or_init(|| {
271 let max: u32 = std::env::var("RWS_CONFIG_RATE_LIMIT_MAX_REQUESTS")
272 .ok()
273 .and_then(|v| v.parse().ok())
274 .unwrap_or(1000);
275 let window: u64 = std::env::var("RWS_CONFIG_RATE_LIMIT_WINDOW_SECS")
276 .ok()
277 .and_then(|v| v.parse().ok())
278 .unwrap_or(60);
279 RateLimiter::new(max, window)
280 })
281}