rustlavel_cache/
rate_limit.rs1use crate::store::Cache;
31use rustlavel_core::Result;
32use std::sync::Arc;
33use std::time::{Duration, SystemTime, UNIX_EPOCH};
34
35const NAMESPACE: &str = "rustlavel:throttle:";
38
39#[derive(Debug, Clone, PartialEq, Eq)]
41pub struct RateLimit {
42 pub limit: u64,
44 pub used: u64,
46 pub remaining: u64,
48 pub reset_after: Duration,
50 pub exceeded: bool,
52}
53
54impl RateLimit {
55 pub fn retry_after_seconds(&self) -> u64 {
58 self.reset_after.as_secs().max(1)
59 }
60
61 pub fn reset_at(&self) -> u64 {
63 now_millis().div_euclid(1000) + self.reset_after.as_secs()
64 }
65}
66
67#[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 pub fn with_driver(store: impl Cache) -> Self {
80 RateLimiter { store: Arc::new(store) }
81 }
82
83 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 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 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 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 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 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 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 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 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}