Skip to main content

rainier_cache/
rate_limit.rs

1//! Rate-limit counters in the cache — [`CacheRateLimiter`].
2//!
3//! ```ignore
4//! let limits: Arc<dyn RateLimitStore> = Arc::new(CacheRateLimiter::new(cache.store()));
5//!
6//! router.post("/login", login).middleware(
7//!     ThrottleRequests::per_minute(5).named("login").stored_in(Arc::clone(&limits)),
8//! );
9//! ```
10//!
11//! The counters go wherever `CACHE_DRIVER` already points, the same way
12//! [`LockManager`](crate::LockManager) does — so a deployment that has already
13//! decided where its shared state lives does not decide again for this.
14//!
15//! # Two keys, and why
16//!
17//! A fixed window needs the counter to expire on its own, and the cache port
18//! has no "increment, and set a TTL if you just created it". So the first hit
19//! does `add(key, 0, ttl)` — atomic, and `false` if somebody beat it — and then
20//! `increment`. That is the `SET key 0 EX 60 NX` / `INCR` pair everybody writes
21//! against Redis by hand, and it has the same property: the window is anchored
22//! to its **first** hit and cannot be extended by later ones. A caller cannot
23//! hold a window open by continuing to knock.
24//!
25//! The second key holds **when the window ends**, as a wall-clock instant. The
26//! cache port cannot report a key's remaining TTL, and without it `retry-after`
27//! could only say "the whole window" — telling somebody who is one second from
28//! the reset to come back in a minute. On a credential limiter that is a real
29//! cost paid by the person who mistyped their password, so it is worth the
30//! extra read.
31
32use std::sync::Arc;
33use std::time::Duration;
34
35use rainier_middleware::{Hit, RateLimitStore};
36use rainier_support::{BoxFuture, Result};
37
38use crate::cache::Cache;
39
40/// Rate-limit counters kept in a [`Cache`].
41pub struct CacheRateLimiter {
42    cache: Arc<dyn Cache>,
43    prefix: String,
44}
45
46impl CacheRateLimiter {
47    /// Count in `cache`, under the `rate:` prefix.
48    pub fn new(cache: Arc<dyn Cache>) -> Self {
49        Self { cache, prefix: "rate:".to_string() }
50    }
51
52    /// Count under a different prefix.
53    ///
54    /// Worth changing when the cache is shared with something else that might
55    /// pick the same names.
56    #[must_use = "this returns a configured limiter rather than configuring in place"]
57    pub fn with_prefix(mut self, prefix: impl Into<String>) -> Self {
58        self.prefix = prefix.into();
59        self
60    }
61
62    /// The cache underneath.
63    pub fn cache(&self) -> &Arc<dyn Cache> {
64        &self.cache
65    }
66
67    fn counter(&self, key: &str) -> String {
68        format!("{}{}", self.prefix, key)
69    }
70
71    /// The companion key holding when this window ends.
72    ///
73    /// Stored rather than derived, because the cache port cannot report a
74    /// key's remaining TTL — and `retry-after` has to say something true.
75    fn deadline(&self, key: &str) -> String {
76        format!("{}{}:until", self.prefix, key)
77    }
78}
79
80impl RateLimitStore for CacheRateLimiter {
81    fn hit<'a>(&'a self, key: &'a str, window: Duration) -> BoxFuture<'a, Result<Hit>> {
82        Box::pin(async move {
83            let counter = self.counter(key);
84            let deadline = self.deadline(key);
85
86            // Atomic, and `false` when somebody else opened the window first —
87            // which is the whole reason this is `add` and not `put`.
88            let opened = self.cache.add(&counter, b"0", Some(window)).await?;
89
90            let ends_at = if opened {
91                // Only the caller that opened the window writes the deadline,
92                // so a later hit cannot push it back. An absolute instant
93                // rather than a duration: the point is when it ends, and every
94                // later reader is asking at a different time.
95                let ends_at = now_millis() + window.as_millis() as u64;
96                let _ =
97                    self.cache.add(&deadline, ends_at.to_string().as_bytes(), Some(window)).await;
98                Some(ends_at)
99            } else {
100                read_millis(self.cache.get(&deadline).await?)
101            };
102
103            let count = self.cache.increment(&counter, 1).await?;
104
105            Ok(Hit { count: count.max(0) as u32, resets_in: remaining(ends_at, window) })
106        })
107    }
108
109    fn peek<'a>(&'a self, key: &'a str) -> BoxFuture<'a, Result<Option<Hit>>> {
110        Box::pin(async move {
111            let Some(raw) = self.cache.get(&self.counter(key)).await? else {
112                return Ok(None);
113            };
114
115            let count = String::from_utf8_lossy(&raw).trim().parse::<u32>().unwrap_or(0);
116            let ends_at = read_millis(self.cache.get(&self.deadline(key)).await?);
117
118            Ok(Some(Hit { count, resets_in: remaining(ends_at, Duration::ZERO) }))
119        })
120    }
121
122    fn clear<'a>(&'a self, key: &'a str) -> BoxFuture<'a, Result<()>> {
123        Box::pin(async move {
124            self.cache.forget(&self.counter(key)).await?;
125            self.cache.forget(&self.deadline(key)).await?;
126            Ok(())
127        })
128    }
129
130    fn is_shared(&self) -> bool {
131        self.cache.is_shared()
132    }
133
134    fn name(&self) -> &str {
135        self.cache.name()
136    }
137}
138
139/// Now, in milliseconds since the epoch.
140///
141/// A wall clock rather than an `Instant`, because the deadline is written by
142/// one process and read by another — and an `Instant` means nothing outside
143/// the process that made it.
144fn now_millis() -> u64 {
145    std::time::SystemTime::now()
146        .duration_since(std::time::UNIX_EPOCH)
147        .map(|since| since.as_millis() as u64)
148        .unwrap_or(0)
149}
150
151/// A stored deadline, if it is there and reads as a number.
152fn read_millis(stored: Option<Vec<u8>>) -> Option<u64> {
153    String::from_utf8(stored?).ok()?.trim().parse().ok()
154}
155
156/// How long until `ends_at`, or `fallback` when there is no deadline to read.
157///
158/// The fallback matters: the deadline key can be missing where the counter is
159/// not — a cache that evicted one under memory pressure, or a window opened by
160/// an older version of this code. Reporting the whole window then is an
161/// over-estimate, which is the safe direction for a `retry-after`.
162fn remaining(ends_at: Option<u64>, fallback: Duration) -> Duration {
163    match ends_at {
164        Some(ends_at) => Duration::from_millis(ends_at.saturating_sub(now_millis())),
165        None => fallback,
166    }
167}
168
169impl std::fmt::Debug for CacheRateLimiter {
170    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
171        f.debug_struct("CacheRateLimiter")
172            .field("cache", &self.cache.name())
173            .field("shared", &self.cache.is_shared())
174            .finish()
175    }
176}
177
178#[cfg(test)]
179mod tests {
180    use super::*;
181    use crate::MemoryCache;
182
183    fn limiter() -> CacheRateLimiter {
184        CacheRateLimiter::new(Arc::new(MemoryCache::new()))
185    }
186
187    #[tokio::test]
188    async fn hits_count_up() {
189        let limiter = limiter();
190        let window = Duration::from_secs(60);
191
192        for expected in 1..=3 {
193            assert_eq!(limiter.hit("ada", window).await.unwrap().count, expected);
194        }
195    }
196
197    #[tokio::test]
198    async fn keys_are_counted_separately() {
199        let limiter = limiter();
200        let window = Duration::from_secs(60);
201
202        limiter.hit("ada", window).await.unwrap();
203        limiter.hit("ada", window).await.unwrap();
204
205        assert_eq!(limiter.hit("grace", window).await.unwrap().count, 1);
206        assert_eq!(limiter.peek("ada").await.unwrap().unwrap().count, 2);
207    }
208
209    #[tokio::test]
210    async fn the_window_expires_on_its_own() {
211        let limiter = limiter();
212        let window = Duration::from_millis(40);
213
214        limiter.hit("ada", window).await.unwrap();
215        limiter.hit("ada", window).await.unwrap();
216
217        tokio::time::sleep(Duration::from_millis(60)).await;
218
219        assert!(limiter.peek("ada").await.unwrap().is_none());
220        assert_eq!(limiter.hit("ada", window).await.unwrap().count, 1);
221    }
222
223    #[tokio::test]
224    async fn a_later_hit_cannot_extend_the_window() {
225        // The property that makes this a fixed window rather than a sliding
226        // one: knocking repeatedly must not hold the door open.
227        let limiter = limiter();
228        let window = Duration::from_millis(120);
229
230        // Opens the window at t=0, so it ends at t=120.
231        limiter.hit("ada", window).await.unwrap();
232
233        // Three more inside it, at t=20, t=40, t=60. Deliberately not up to
234        // the boundary: a hit at exactly t=120 finds the counter already gone
235        // and legitimately opens the *next* window, which is the behaviour and
236        // not the bug.
237        for _ in 0..3 {
238            tokio::time::sleep(Duration::from_millis(20)).await;
239            let hit = limiter.hit("ada", window).await.unwrap();
240            assert!(hit.count > 1, "a hit inside the window opened a new one");
241        }
242
243        // Now past t=120, and it is gone despite four hits' worth of traffic.
244        tokio::time::sleep(Duration::from_millis(100)).await;
245        assert!(limiter.peek("ada").await.unwrap().is_none());
246    }
247
248    #[tokio::test]
249    async fn clearing_restores_the_allowance() {
250        let limiter = limiter();
251        let window = Duration::from_secs(60);
252
253        limiter.hit("ada", window).await.unwrap();
254        limiter.hit("ada", window).await.unwrap();
255        limiter.clear("ada").await.unwrap();
256
257        assert!(limiter.peek("ada").await.unwrap().is_none());
258        assert_eq!(limiter.hit("ada", window).await.unwrap().count, 1);
259    }
260
261    #[tokio::test]
262    async fn it_reports_the_sharedness_of_its_cache() {
263        // The whole point of putting counters here, and the thing a boot check
264        // asks about.
265        assert!(!limiter().is_shared());
266        assert_eq!(limiter().name(), "memory");
267    }
268
269    #[tokio::test]
270    async fn peeking_at_nothing_is_none() {
271        assert!(limiter().peek("nobody").await.unwrap().is_none());
272    }
273
274    #[tokio::test]
275    async fn the_reset_time_counts_down_rather_than_reporting_the_whole_window() {
276        // The reason the second key exists. Telling somebody one second from
277        // the reset to come back in a minute is a real cost, paid by the
278        // person who mistyped their password.
279        let limiter = limiter();
280        let window = Duration::from_millis(400);
281
282        let first = limiter.hit("ada", window).await.unwrap();
283        assert!(first.resets_in <= window);
284
285        tokio::time::sleep(Duration::from_millis(150)).await;
286        let second = limiter.hit("ada", window).await.unwrap();
287
288        assert!(
289            second.resets_in < first.resets_in,
290            "{:?} should be less than {:?}",
291            second.resets_in,
292            first.resets_in
293        );
294        assert!(second.resets_in < Duration::from_millis(300), "{:?}", second.resets_in);
295    }
296
297    #[tokio::test]
298    async fn a_missing_deadline_falls_back_rather_than_reporting_zero() {
299        // The deadline key can be gone where the counter is not — an eviction
300        // under memory pressure, or a window opened by an older build. A
301        // `retry-after` of zero would invite an immediate retry that is
302        // refused again.
303        let cache: Arc<dyn Cache> = Arc::new(MemoryCache::new());
304        let limiter = CacheRateLimiter::new(Arc::clone(&cache));
305        let window = Duration::from_secs(60);
306
307        limiter.hit("ada", window).await.unwrap();
308        cache.forget("rate:ada:until").await.unwrap();
309
310        let hit = limiter.hit("ada", window).await.unwrap();
311
312        assert_eq!(hit.count, 2, "the counter itself is untouched");
313        assert_eq!(hit.resets_in, window, "it falls back to the whole window");
314    }
315}