Skip to main content

ullun/
rate_limit.rs

1//! In-memory rate limiting middleware
2//!
3//! Fixed window per key (the peer IP by default), or a sliding window with
4//! [`RateLimiter::sliding_window`]. State lives in this process,
5//! so with several instances each one enforces the limit on its own; put a
6//! shared limiter (reverse proxy, Redis) in front for a global limit.
7//!
8//! ```no_run
9//! use std::time::Duration;
10//! use ullun::prelude::*;
11//! use ullun::rate_limit::RateLimiter;
12//!
13//! # async fn run() {
14//! App::new()
15//!     .middleware(RateLimiter::new(100, Duration::from_secs(60)))
16//!     .get("/", |_req: Request| async { Ok(Response::text("ok")) })
17//!     .run("127.0.0.1:3000")
18//!     .await
19//!     .unwrap();
20//! # }
21//! ```
22
23use crate::error::{Error, Result};
24use crate::middleware::{Middleware, Next};
25use crate::request::Request;
26use crate::response::Response;
27use async_trait::async_trait;
28use std::collections::HashMap;
29use std::sync::{Arc, Mutex};
30use std::time::{Duration, Instant};
31
32type KeyFn = dyn Fn(&Request) -> Option<String> + Send + Sync;
33
34struct Window {
35    started: Instant,
36    count: u32,
37    /// Requests of the previous window (sliding mode)
38    prev: u32,
39}
40
41/// Outcome of counting one request
42#[derive(Debug, Clone, Copy, PartialEq, Eq)]
43pub struct Decision {
44    pub allowed: bool,
45    pub limit: u32,
46    pub remaining: u32,
47    /// Seconds until the current window ends (at least 1)
48    pub reset_secs: u64,
49}
50
51/// Where request counts live. The default is process memory; implement it (or
52/// use `RedisRateLimitStore`, feature `redis`) to share limits between instances.
53///
54/// A store decides by itself what to do when its backend fails; the Redis one
55/// lets requests through.
56#[async_trait]
57pub trait RateLimitStore: Send + Sync + 'static {
58    /// Count one request for `key` against `limit` per `window`
59    async fn hit(&self, key: &str, limit: u32, window: Duration, sliding: bool) -> Decision;
60}
61
62/// Fixed-window rate limiter, usable as middleware
63pub struct RateLimiter {
64    store: Option<Arc<dyn RateLimitStore>>,
65    limit: u32,
66    window: Duration,
67    max_keys: usize,
68    sliding: bool,
69    key_fn: Arc<KeyFn>,
70    state: Mutex<HashMap<String, Window>>,
71}
72
73impl RateLimiter {
74    /// Allow `limit` requests per `window` for each client IP
75    pub fn new(limit: u32, window: Duration) -> Self {
76        Self {
77            store: None,
78            limit,
79            window,
80            max_keys: 100_000,
81            sliding: false,
82            key_fn: Arc::new(|req| req.client_ip().map(|ip| ip.to_string())),
83            state: Mutex::new(HashMap::new()),
84        }
85    }
86
87    /// Count in `store` instead of process memory, so that all instances of a
88    /// service share one budget per client
89    pub fn store(mut self, store: impl RateLimitStore) -> Self {
90        self.store = Some(Arc::new(store));
91        self
92    }
93
94    /// Count over a window that slides with time instead of resetting at fixed
95    /// boundaries. A fixed window lets a client send `limit` requests just before
96    /// the reset and `limit` more just after (twice the limit within a moment);
97    /// the sliding window weighs the previous window by how much of it still
98    /// overlaps, so the rate stays at `limit` per window at any moment.
99    pub fn sliding_window(mut self) -> Self {
100        self.sliding = true;
101        self
102    }
103
104    /// Choose the bucket per request, e.g. an API key header. Returning `None`
105    /// exempts the request from limiting.
106    pub fn key_by<F>(mut self, f: F) -> Self
107    where
108        F: Fn(&Request) -> Option<String> + Send + Sync + 'static,
109    {
110        self.key_fn = Arc::new(f);
111        self
112    }
113
114    /// Use the first `X-Forwarded-For` address, falling back to the peer IP.
115    ///
116    /// Only enable this behind a reverse proxy that sets the header: otherwise
117    /// clients choose their own bucket and can evade the limit. Prefer
118    /// [`App::trusted_proxies`](crate::app::App::trusted_proxies): the default
119    /// key then is the real client address, found by skipping your own proxies
120    /// from the right, which clients cannot influence.
121    pub fn trust_forwarded_for(self) -> Self {
122        self.key_by(|req| {
123            req.headers
124                .get("x-forwarded-for")
125                .and_then(|v| v.to_str().ok())
126                .and_then(|v| v.split(',').next())
127                .map(|ip| ip.trim().to_string())
128                .filter(|ip| !ip.is_empty())
129                .or_else(|| req.remote_addr.map(|a| a.ip().to_string()))
130        })
131    }
132
133    /// Cap on tracked keys (default 100 000). When full after dropping expired
134    /// windows, unseen keys are let through untracked rather than blocked.
135    pub fn max_keys(mut self, max_keys: usize) -> Self {
136        self.max_keys = max_keys;
137        self
138    }
139
140    /// Count one request for `key` at time `now`
141    pub fn check(&self, key: &str, now: Instant) -> Decision {
142        let mut map = self.state.lock().unwrap_or_else(|e| e.into_inner());
143
144        if map.len() >= self.max_keys && !map.contains_key(key) {
145            let window = self.window;
146            map.retain(|_, w| now.duration_since(w.started) < window);
147            if map.len() >= self.max_keys {
148                return Decision {
149                    allowed: true,
150                    limit: self.limit,
151                    remaining: self.limit,
152                    reset_secs: self.window.as_secs().max(1),
153                };
154            }
155        }
156
157        let w = map.entry(key.to_string()).or_insert(Window {
158            started: now,
159            count: 0,
160            prev: 0,
161        });
162        if self.sliding {
163            return self.check_sliding(w, now);
164        }
165        if now.duration_since(w.started) >= self.window {
166            *w = Window {
167                started: now,
168                count: 0,
169                prev: 0,
170            };
171        }
172        let elapsed = now.duration_since(w.started);
173        let reset_secs = self
174            .window
175            .saturating_sub(elapsed)
176            .as_secs_f64()
177            .ceil()
178            .max(1.0) as u64;
179
180        if w.count < self.limit {
181            w.count += 1;
182            Decision {
183                allowed: true,
184                limit: self.limit,
185                remaining: self.limit - w.count,
186                reset_secs,
187            }
188        } else {
189            Decision {
190                allowed: false,
191                limit: self.limit,
192                remaining: 0,
193                reset_secs,
194            }
195        }
196    }
197
198    /// Sliding-window counter: requests of the previous window count in
199    /// proportion to how much of it the last `window` still covers
200    fn check_sliding(&self, w: &mut Window, now: Instant) -> Decision {
201        let win = self.window.as_secs_f64().max(f64::EPSILON);
202        let mut elapsed = now.duration_since(w.started).as_secs_f64();
203        if elapsed >= win {
204            let periods = (elapsed / win).floor();
205            w.prev = if periods < 2.0 { w.count } else { 0 };
206            w.count = 0;
207            w.started += Duration::from_secs_f64(periods * win);
208            elapsed = now.duration_since(w.started).as_secs_f64();
209        }
210        let weight = 1.0 - elapsed / win;
211        let estimate = f64::from(w.prev) * weight + f64::from(w.count);
212        let limit = f64::from(self.limit);
213
214        if estimate + 1.0 <= limit {
215            w.count += 1;
216            let used = estimate + 1.0;
217            return Decision {
218                allowed: true,
219                limit: self.limit,
220                remaining: (limit - used).floor().max(0.0) as u32,
221                reset_secs: (win - elapsed).ceil().max(1.0) as u64,
222            };
223        }
224        // when does the estimate drop far enough for one more request?
225        let wait = if w.prev > 0 && f64::from(w.count) < limit {
226            // the previous window keeps decaying: solve prev * (1 - t / win) + count <= limit - 1
227            let need = estimate + 1.0 - limit;
228            (need * win / f64::from(w.prev)).min(win - elapsed)
229        } else {
230            win - elapsed
231        };
232        Decision {
233            allowed: false,
234            limit: self.limit,
235            remaining: 0,
236            reset_secs: wait.ceil().max(1.0) as u64,
237        }
238    }
239}
240
241#[async_trait]
242impl Middleware for RateLimiter {
243    async fn handle(&self, req: Request, next: Next) -> Result<Response> {
244        // CORS preflights carry no credentials and must not eat the budget
245        if req.method == http::Method::OPTIONS {
246            return next.run(req).await;
247        }
248        let Some(key) = (self.key_fn)(&req) else {
249            return next.run(req).await;
250        };
251
252        let decision = match &self.store {
253            Some(store) => store.hit(&key, self.limit, self.window, self.sliding).await,
254            None => self.check(&key, Instant::now()),
255        };
256        let res = if decision.allowed {
257            match next.run(req).await {
258                Ok(res) => res,
259                Err(err) => Response::from_error(err),
260            }
261        } else {
262            Response::from_error(Error::custom(429, "Too many requests"))
263                .with_header("retry-after", decision.reset_secs.to_string())
264        };
265
266        Ok(res
267            .with_header("x-ratelimit-limit", decision.limit.to_string())
268            .with_header("x-ratelimit-remaining", decision.remaining.to_string())
269            .with_header("x-ratelimit-reset", decision.reset_secs.to_string()))
270    }
271}
272
273#[cfg(test)]
274mod tests {
275    use super::*;
276
277    #[test]
278    fn counts_within_window_and_resets_after() {
279        let rl = RateLimiter::new(2, Duration::from_secs(10));
280        let t0 = Instant::now();
281        let d1 = rl.check("a", t0);
282        assert_eq!((d1.allowed, d1.remaining), (true, 1));
283        assert_eq!(rl.check("a", t0 + Duration::from_secs(1)).remaining, 0);
284
285        let blocked = rl.check("a", t0 + Duration::from_millis(2500));
286        assert!(!blocked.allowed);
287        assert_eq!((blocked.remaining, blocked.reset_secs), (0, 8));
288
289        let again = rl.check("a", t0 + Duration::from_secs(10));
290        assert!(again.allowed && again.remaining == 1);
291    }
292
293    #[test]
294    fn keys_are_independent() {
295        let rl = RateLimiter::new(1, Duration::from_secs(60));
296        let t = Instant::now();
297        assert!(rl.check("a", t).allowed);
298        assert!(!rl.check("a", t).allowed);
299        assert!(rl.check("b", t).allowed);
300    }
301
302    #[test]
303    fn full_table_drops_expired_then_fails_open() {
304        let rl = RateLimiter::new(1, Duration::from_secs(10)).max_keys(2);
305        let t = Instant::now();
306        rl.check("a", t);
307        rl.check("b", t);
308        // table full and nothing expired: untracked, allowed, not remembered
309        assert!(rl.check("c", t).allowed);
310        assert!(rl.check("c", t).allowed);
311        assert!(!rl.check("a", t).allowed);
312        // later the expired entries are swept and "c" gets tracked
313        let later = t + Duration::from_secs(11);
314        assert!(rl.check("c", later).allowed);
315        assert!(!rl.check("c", later).allowed);
316    }
317
318    #[test]
319    fn sliding_window_has_no_boundary_burst() {
320        let t0 = Instant::now();
321        let secs = Duration::from_secs;
322        let at = |s: f64| t0 + Duration::from_secs_f64(s);
323        // fixed window: 10 just before the reset and 10 just after all pass
324        let fixed = RateLimiter::new(10, secs(10));
325        assert!(fixed.check("a", t0).allowed);
326        for _ in 0..9 {
327            assert!(fixed.check("a", at(9.0)).allowed);
328        }
329        let burst = (0..10)
330            .filter(|_| fixed.check("a", at(10.5)).allowed)
331            .count();
332        assert_eq!(burst, 10);
333
334        // sliding: the same pattern is held to the limit
335        let sliding = RateLimiter::new(10, secs(10)).sliding_window();
336        assert!(sliding.check("a", t0).allowed);
337        for _ in 0..9 {
338            assert!(sliding.check("a", at(9.0)).allowed);
339        }
340        let allowed = (0..10)
341            .filter(|_| sliding.check("a", at(10.5)).allowed)
342            .count();
343        assert_eq!(allowed, 0);
344    }
345
346    #[test]
347    fn sliding_window_recovers_gradually_and_reports_when() {
348        let rl = RateLimiter::new(10, Duration::from_secs(10)).sliding_window();
349        let t0 = Instant::now();
350        for _ in 0..10 {
351            assert!(rl.check("k", t0).allowed);
352        }
353        let blocked = rl.check("k", t0 + Duration::from_secs(1));
354        assert!(!blocked.allowed);
355        assert!(blocked.reset_secs >= 1 && blocked.reset_secs <= 10);
356
357        // after a full window nothing is left over
358        let later = rl.check("k", t0 + Duration::from_secs(25));
359        assert!(later.allowed);
360        assert_eq!(later.remaining, 9);
361
362        // half a window later about half of the old requests no longer count
363        let rl = RateLimiter::new(10, Duration::from_secs(10)).sliding_window();
364        for _ in 0..10 {
365            rl.check("k", t0);
366        }
367        let allowed = (0..10)
368            .filter(|_| rl.check("k", t0 + Duration::from_secs(15)).allowed)
369            .count();
370        assert_eq!(allowed, 5);
371    }
372
373    #[test]
374    fn sliding_remaining_counts_down() {
375        let rl = RateLimiter::new(3, Duration::from_secs(60)).sliding_window();
376        let t = Instant::now();
377        assert_eq!(rl.check("a", t).remaining, 2);
378        assert_eq!(rl.check("a", t).remaining, 1);
379        assert_eq!(rl.check("a", t).remaining, 0);
380        assert!(!rl.check("a", t).allowed);
381    }
382
383    #[test]
384    fn zero_limit_blocks_everything() {
385        let rl = RateLimiter::new(0, Duration::from_secs(1));
386        assert!(!rl.check("a", Instant::now()).allowed);
387    }
388}