rainier_cache/
rate_limit.rs1use std::sync::Arc;
33use std::time::Duration;
34
35use rainier_middleware::{Hit, RateLimitStore};
36use rainier_support::{BoxFuture, Result};
37
38use crate::cache::Cache;
39
40pub struct CacheRateLimiter {
42 cache: Arc<dyn Cache>,
43 prefix: String,
44}
45
46impl CacheRateLimiter {
47 pub fn new(cache: Arc<dyn Cache>) -> Self {
49 Self { cache, prefix: "rate:".to_string() }
50 }
51
52 #[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 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 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 let opened = self.cache.add(&counter, b"0", Some(window)).await?;
89
90 let ends_at = if opened {
91 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
139fn 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
151fn read_millis(stored: Option<Vec<u8>>) -> Option<u64> {
153 String::from_utf8(stored?).ok()?.trim().parse().ok()
154}
155
156fn 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 let limiter = limiter();
228 let window = Duration::from_millis(120);
229
230 limiter.hit("ada", window).await.unwrap();
232
233 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 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 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 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 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}