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