Skip to main content

rustlavel_cache/
rate_limit.rs

1//! Rate limiting on top of the [`Cache`] trait.
2//!
3//! # Fixed window, and what that costs
4//!
5//! A counter is kept per *window*: the key embeds `now / window`, so at every
6//! boundary the counter is a new key that starts at zero and expires on its own.
7//! One increment per request, one key per window, nothing to clean up.
8//!
9//! The price is burstiness at the seam. With a limit of 60 per minute, a client
10//! can send 60 requests in the last instant of one window and 60 in the first
11//! instant of the next — 120 in a moment, twice the nominal rate. A sliding
12//! window (a sorted set of timestamps, or a weighted blend of the current and
13//! previous window) removes that, at the cost of storing a timestamp per
14//! request or of a second read on every request.
15//!
16//! Fixed window is the right default here because the job of a `throttle`
17//! middleware is to stop abuse and runaway clients, and a 2× burst for one
18//! instant does not defeat that — while the sorted-set approach makes every
19//! request more expensive for every honest user. Anything that genuinely needs
20//! a smooth rate (billing, an upstream API quota) should be built on
21//! [`RateLimiter::attempt`]'s reported window rather than pretending the
22//! boundary does not exist.
23//!
24//! # Which driver
25//!
26//! The limiter is only as shared as its cache. The memory driver counts per
27//! process, so four workers behind a load balancer allow four times the limit;
28//! use the Redis driver whenever more than one process serves traffic.
29
30use crate::store::Cache;
31use rustlavel_core::Result;
32use std::sync::Arc;
33use std::time::{Duration, SystemTime, UNIX_EPOCH};
34
35/// Keys are namespaced so a limiter can share a cache with ordinary entries
36/// without a `user:1` counter ever colliding with a `user:1` cache value.
37const NAMESPACE: &str = "rustlavel:throttle:";
38
39/// The outcome of one attempt, and everything the `X-RateLimit-*` headers need.
40#[derive(Debug, Clone, PartialEq, Eq)]
41pub struct RateLimit {
42    /// The configured ceiling for the window.
43    pub limit: u64,
44    /// How many attempts this key has made in the current window.
45    pub used: u64,
46    /// How many remain. Zero once the limit is reached.
47    pub remaining: u64,
48    /// How long until the window resets — what `Retry-After` reports.
49    pub reset_after: Duration,
50    /// Whether this attempt was refused.
51    pub exceeded: bool,
52}
53
54impl RateLimit {
55    /// `Retry-After` is expressed in whole seconds and must never be `0`, or a
56    /// client reads it as "retry immediately" and hammers straight back.
57    pub fn retry_after_seconds(&self) -> u64 {
58        self.reset_after.as_secs().max(1)
59    }
60
61    /// The Unix timestamp at which the window resets, for `X-RateLimit-Reset`.
62    pub fn reset_at(&self) -> u64 {
63        now_millis().div_euclid(1000) + self.reset_after.as_secs()
64    }
65}
66
67/// Counts attempts per key and window against any [`Cache`].
68#[derive(Clone)]
69pub struct RateLimiter {
70    store: Arc<dyn Cache>,
71}
72
73impl RateLimiter {
74    pub fn new(store: Arc<dyn Cache>) -> Self {
75        RateLimiter { store }
76    }
77
78    /// Build a limiter over a driver held by value.
79    pub fn with_driver(store: impl Cache) -> Self {
80        RateLimiter { store: Arc::new(store) }
81    }
82
83    /// Record one attempt against `key` and report where it stands.
84    ///
85    /// The counter is incremented even when the limit is already exceeded. That
86    /// is deliberate: a client that keeps hammering after a 429 keeps its
87    /// window alive, which is exactly the behaviour you want from a throttle.
88    pub async fn attempt(&self, key: &str, limit: u64, window: Duration) -> Result<RateLimit> {
89        let window_millis = window.as_millis().max(1) as u64;
90        let now = now_millis();
91        let slot = now / window_millis;
92
93        let counter = format!("{NAMESPACE}{key}:{slot}");
94
95        // A little slack past the boundary so a request that arrives just as
96        // the window turns cannot find its counter already swept away.
97        let ttl = Duration::from_millis(window_millis + 1_000);
98        let used = self.store.increment_within(&counter, 1, ttl).await?.max(0) as u64;
99
100        // Computed from the slot rather than read back from the store, so every
101        // driver reports the same reset instant whether or not it can answer
102        // a TTL query cheaply.
103        let window_ends = (slot + 1) * window_millis;
104        let reset_after = Duration::from_millis(window_ends.saturating_sub(now));
105
106        Ok(RateLimit {
107            limit,
108            used,
109            remaining: limit.saturating_sub(used),
110            reset_after,
111            exceeded: used > limit,
112        })
113    }
114
115    /// Whether the key has already exhausted its window, without spending an
116    /// attempt on the question.
117    pub async fn too_many(&self, key: &str, limit: u64, window: Duration) -> Result<bool> {
118        Ok(self.used(key, window).await? >= limit)
119    }
120
121    /// How many attempts the key has made in the current window.
122    pub async fn used(&self, key: &str, window: Duration) -> Result<u64> {
123        let window_millis = window.as_millis().max(1) as u64;
124        let slot = now_millis() / window_millis;
125        let counter = format!("{NAMESPACE}{key}:{slot}");
126
127        Ok(self
128            .store
129            .get(&counter)
130            .await?
131            .and_then(|value| value.as_i64())
132            .unwrap_or(0)
133            .max(0) as u64)
134    }
135
136    /// Forget a key's current window — after a successful login, say, so a user
137    /// who finally got their password right is not still locked out.
138    pub async fn clear(&self, key: &str, window: Duration) -> Result<()> {
139        let window_millis = window.as_millis().max(1) as u64;
140        let slot = now_millis() / window_millis;
141        self.store.forget(&format!("{NAMESPACE}{key}:{slot}")).await?;
142        Ok(())
143    }
144}
145
146fn now_millis() -> u64 {
147    SystemTime::now().duration_since(UNIX_EPOCH).unwrap_or_default().as_millis() as u64
148}
149
150#[cfg(test)]
151mod tests {
152    use super::*;
153    use crate::memory::MemoryStore;
154
155    fn limiter() -> RateLimiter {
156        RateLimiter::with_driver(MemoryStore::new())
157    }
158
159    #[tokio::test]
160    async fn the_first_attempts_are_allowed_and_the_next_one_is_not() {
161        let limiter = limiter();
162        let window = Duration::from_secs(60);
163
164        for expected_remaining in (0..3).rev() {
165            let outcome = limiter.attempt("ada", 3, window).await.unwrap();
166            assert!(!outcome.exceeded);
167            assert_eq!(outcome.remaining, expected_remaining);
168        }
169
170        let refused = limiter.attempt("ada", 3, window).await.unwrap();
171        assert!(refused.exceeded);
172        assert_eq!(refused.remaining, 0);
173        assert_eq!(refused.used, 4);
174    }
175
176    #[tokio::test]
177    async fn two_keys_are_counted_separately() {
178        let limiter = limiter();
179        let window = Duration::from_secs(60);
180
181        limiter.attempt("ada", 1, window).await.unwrap();
182        limiter.attempt("ada", 1, window).await.unwrap();
183
184        assert!(limiter.attempt("ada", 1, window).await.unwrap().exceeded);
185        assert!(!limiter.attempt("grace", 1, window).await.unwrap().exceeded);
186    }
187
188    #[tokio::test]
189    async fn a_window_that_passes_lets_the_client_back_in() {
190        let limiter = limiter();
191        let window = Duration::from_millis(80);
192
193        limiter.attempt("ada", 1, window).await.unwrap();
194        assert!(limiter.attempt("ada", 1, window).await.unwrap().exceeded);
195
196        // Two windows, to be sure the boundary was crossed however the first
197        // attempt happened to land inside its slot.
198        tokio::time::sleep(Duration::from_millis(180)).await;
199        assert!(!limiter.attempt("ada", 1, window).await.unwrap().exceeded);
200    }
201
202    #[tokio::test]
203    async fn retry_after_is_never_zero_seconds() {
204        let limiter = limiter();
205        // A sub-second window would otherwise round down to "retry now".
206        let outcome = limiter.attempt("ada", 1, Duration::from_millis(200)).await.unwrap();
207
208        assert!(outcome.reset_after < Duration::from_millis(201));
209        assert_eq!(outcome.retry_after_seconds(), 1);
210        assert!(outcome.reset_at() >= now_millis() / 1000);
211    }
212
213    #[tokio::test]
214    async fn too_many_reports_the_state_without_spending_an_attempt() {
215        let limiter = limiter();
216        let window = Duration::from_secs(60);
217
218        limiter.attempt("ada", 2, window).await.unwrap();
219        assert!(!limiter.too_many("ada", 2, window).await.unwrap());
220        assert_eq!(limiter.used("ada", window).await.unwrap(), 1);
221
222        limiter.attempt("ada", 2, window).await.unwrap();
223        assert!(limiter.too_many("ada", 2, window).await.unwrap());
224        // Asking twice must not have counted as two more attempts.
225        assert_eq!(limiter.used("ada", window).await.unwrap(), 2);
226    }
227
228    #[tokio::test]
229    async fn clearing_a_key_gives_the_whole_window_back() {
230        let limiter = limiter();
231        let window = Duration::from_secs(60);
232
233        limiter.attempt("ada", 1, window).await.unwrap();
234        assert!(limiter.attempt("ada", 1, window).await.unwrap().exceeded);
235
236        limiter.clear("ada", window).await.unwrap();
237        assert!(!limiter.attempt("ada", 1, window).await.unwrap().exceeded);
238    }
239
240    #[tokio::test]
241    async fn a_limiter_key_cannot_collide_with_an_ordinary_cache_entry() {
242        let store = MemoryStore::new();
243        store.forever("ada", rustlavel_core::Json::from("a cached value")).await.unwrap();
244
245        let limiter = RateLimiter::new(Arc::new(store.clone()));
246        limiter.attempt("ada", 5, Duration::from_secs(60)).await.unwrap();
247
248        assert_eq!(
249            store.get("ada").await.unwrap(),
250            Some(rustlavel_core::Json::from("a cached value")),
251            "the limiter must not have trampled the cached value"
252        );
253    }
254
255    #[tokio::test]
256    async fn concurrent_attempts_never_let_more_than_the_limit_through() {
257        let limiter = limiter();
258        let window = Duration::from_secs(60);
259
260        let mut tasks = Vec::new();
261        for _ in 0..40 {
262            let limiter = limiter.clone();
263            tasks.push(tokio::spawn(async move {
264                limiter.attempt("shared", 10, window).await.unwrap().exceeded
265            }));
266        }
267
268        let mut allowed = 0;
269        for task in tasks {
270            if !task.await.unwrap() {
271                allowed += 1;
272            }
273        }
274
275        assert_eq!(allowed, 10, "exactly the limit may pass, whatever the interleaving");
276    }
277}