throttle_kit/backend.rs
1#[cfg(feature = "in-memory")]
2use std::sync::OnceLock;
3#[cfg(feature = "in-memory")]
4use std::time::{Duration, Instant};
5
6use std::future::Future;
7
8use crate::metrics::RateLimitResult;
9use crate::quota::Quota;
10
11/// Backend trait for rate limit state storage.
12///
13/// Uses native `async fn` in trait (MSRV 1.85) instead of
14/// `#[async_trait]`, so a check dispatches no future boxing and no
15/// vtable call. The returned future is required to be `Send` so
16/// backends work on multi-threaded runtimes (the Tower layer's boxed
17/// future, spawned tasks).
18///
19/// The trait is intentionally **not dyn-compatible**: every consumer in
20/// this crate is generic over `B: RateLimitBackend`, keeping the hot
21/// path statically dispatched. If you need heterogeneous backend sets,
22/// wrap them in your own enum rather than a `dyn` trait object.
23pub trait RateLimitBackend: Send + Sync + 'static {
24 /// Check if a request for `key` is allowed under the given `quota`.
25 fn check(&self, key: &str, quota: &Quota) -> impl Future<Output = RateLimitResult> + Send;
26}
27
28/// Pure GCRA (Generic Cell Rate Algorithm) conformance decision.
29///
30/// This is the same math as the Redis GCRA Lua script in `src/redis.rs`
31/// (`new_tat = max(tac, now) + emission`, `allow_at = new_tat - emission *
32/// burst`), lifted out of the DashMap/Redis contexts so the decision core is
33/// a total, side-effect-free function that can be exhaustively model-checked
34/// with Kani (see `tests/kani.rs`).
35///
36/// Returns `(allowed, retry_after_ms, remaining)`:
37///
38/// - `allowed`: whether the request conforms.
39/// - `retry_after_ms`: milliseconds until the next request can conform;
40/// always `0` iff the request was allowed.
41/// - `remaining`: reported remaining capacity, in `[0, burst]`; always `0`
42/// when the request was denied.
43///
44/// # Argument contract
45///
46/// - `now_ms` / `tac_ms`: monotonic clock reading and the key's stored
47/// *theoretical arrival time* (TAC). **Every** `u64` input is handled
48/// without overflow or panic: all intermediate math is `u128`.
49/// - `emission_interval_ms`: minimum spacing between conforming requests.
50/// `0` (sub-millisecond quotas) is clamped to `1` ms so the divisions
51/// below are well-defined.
52/// - `burst_ms`: burst capacity in requests. `0` is strict GCRA: nothing
53/// conforms before a full interval has elapsed.
54pub fn gcra_decide(
55 now_ms: u64,
56 tac_ms: u64,
57 emission_interval_ms: u64,
58 burst_ms: u64,
59) -> (bool, u64, u64) {
60 // Sub-millisecond emission intervals are floored to 1 ms so the
61 // divisions below are well-defined (no divide-by-zero panic).
62 let emission = u128::from(emission_interval_ms.max(1));
63 let burst = u128::from(burst_ms);
64 let now = u128::from(now_ms);
65
66 let new_tat = u128::from(tac_ms).max(now) + emission;
67 // No overflow is possible: `emission * burst` is at most
68 // (2^64-1)^2 < 2^128, and adding `now` (< 2^64) stays well below
69 // u128::MAX. (The naive signed form `new_tat - emission * burst` could
70 // underflow, so the comparison is rearranged into the all-unsigned
71 // `new_tat <= now + emission * burst`.)
72 let conforms = new_tat <= now + emission * burst;
73
74 if conforms {
75 // Tokens available right now (floor division), clamped to the burst
76 // capacity so the reported value can never exceed `burst`. The
77 // result is <= burst <= u64::MAX, so the cast cannot truncate.
78 let remaining = ((new_tat - now) / emission).min(burst) as u64;
79 (true, 0, remaining)
80 } else {
81 // `allow_at - now` in ms: strictly positive on this branch and
82 // bounded by `new_tat - now`, which only exceeds u64::MAX for
83 // absurd inputs (TAC near u64::MAX with now near 0) — clamp instead
84 // of wrapping.
85 let retry_after = u64::try_from(new_tat - now - emission * burst).unwrap_or(u64::MAX);
86 (false, retry_after, 0)
87 }
88}
89
90/// In-memory backend backed by a `DashMap`.
91///
92/// Suitable for single-node deployments. Each key tracks its GCRA
93/// theoretical arrival time (TAC) in monotonic milliseconds since process
94/// start; the allow/deny/remaining decision is delegated to the verified
95/// pure core [`gcra_decide`], matching the Redis GCRA backend's semantics.
96#[cfg(feature = "in-memory")]
97#[derive(Clone)]
98pub struct InMemoryBackend {
99 entries: dashmap::DashMap<String, Entry>,
100}
101
102#[cfg(feature = "in-memory")]
103#[derive(Clone)]
104struct Entry {
105 /// GCRA theoretical arrival time (TAC) in monotonic ms. A fresh key
106 /// starts with `tac == now`, i.e. a full burst budget.
107 tac_ms: u64,
108}
109
110/// One GCRA step for a stored entry: decide via the verified pure core
111/// [`gcra_decide`], then — exactly as it computed — advance the TAC to
112/// `new_tat = max(tac, now) + emission` when the request conforms.
113///
114/// Shared by the warm-key (`get_mut`) and cold-key (`entry`) paths so
115/// the two cannot drift.
116#[cfg(feature = "in-memory")]
117fn gcra_advance(entry: &mut Entry, now_ms: u64, interval_ms: u64, burst: u64) -> (bool, u64, u64) {
118 // Verified GCRA decision core (see tests/kani.rs).
119 let (allowed, retry_after_ms, remaining) =
120 gcra_decide(now_ms, entry.tac_ms, interval_ms, burst);
121 if allowed {
122 entry.tac_ms = entry.tac_ms.max(now_ms).saturating_add(interval_ms);
123 }
124 (allowed, retry_after_ms, remaining)
125}
126
127/// Monotonic milliseconds since process start (the GCRA clock domain).
128///
129/// Anchored once so that clock adjustments never move the TAC timeline.
130#[cfg(feature = "in-memory")]
131fn monotonic_anchor() -> Instant {
132 static ANCHOR: OnceLock<Instant> = OnceLock::new();
133 *ANCHOR.get_or_init(Instant::now)
134}
135
136/// Monotonic milliseconds since process start (the GCRA clock domain).
137#[cfg(feature = "in-memory")]
138fn monotonic_ms() -> u64 {
139 monotonic_anchor().elapsed().as_millis() as u64
140}
141
142/// A deadline `from_now_ms` after the shared monotonic anchor.
143///
144/// Same clock domain as [`monotonic_ms`], so `reset_at` is derived from
145/// the single clock read of a check instead of a fresh `Instant::now()`.
146#[cfg(feature = "in-memory")]
147fn monotonic_deadline(from_now_ms: u64) -> Instant {
148 monotonic_anchor()
149 .checked_add(Duration::from_millis(from_now_ms))
150 .unwrap_or_else(Instant::now)
151}
152
153#[cfg(feature = "in-memory")]
154impl InMemoryBackend {
155 /// Create a new in-memory backend.
156 pub fn new() -> Self {
157 Self {
158 entries: dashmap::DashMap::new(),
159 }
160 }
161}
162
163#[cfg(feature = "in-memory")]
164impl Default for InMemoryBackend {
165 fn default() -> Self {
166 Self::new()
167 }
168}
169
170#[cfg(feature = "in-memory")]
171impl RateLimitBackend for InMemoryBackend {
172 async fn check(&self, key: &str, quota: &Quota) -> RateLimitResult {
173 let interval_ms = quota.interval().as_millis().max(1) as u64;
174 let burst = u64::from(quota.burst);
175 let now_ms = monotonic_ms();
176
177 // Warm-key fast path (steady-state traffic): update the existing
178 // entry in place — zero allocation. Only a key's first-ever call
179 // pays for the `to_owned()` insert on the cold path below.
180 let (allowed, retry_after_ms, remaining) =
181 if let Some(mut entry) = self.entries.get_mut(key) {
182 gcra_advance(&mut entry, now_ms, interval_ms, burst)
183 } else {
184 let mut entry = self
185 .entries
186 .entry(key.to_owned())
187 .or_insert_with(|| Entry { tac_ms: now_ms });
188 gcra_advance(&mut entry, now_ms, interval_ms, burst)
189 };
190
191 // Derived from the same clock read as the GCRA decision above —
192 // a check performs exactly one clock read.
193 let reset_at = monotonic_deadline(now_ms.saturating_add(interval_ms));
194
195 #[cfg(feature = "metrics")]
196 {
197 // `remaining <= burst` is a Kani-verified property of
198 // `gcra_decide`, so utilization is in [0, 1].
199 let utilization = 1.0 - remaining as f64 / burst as f64;
200 metrics::histogram!("ratelimit_utilization").record(utilization);
201 if allowed {
202 metrics::counter!("ratelimit_allowed_total", "key" => key.to_string()).increment(1);
203 } else {
204 metrics::counter!("ratelimit_rejected_total", "key" => key.to_string())
205 .increment(1);
206 }
207 }
208
209 RateLimitResult {
210 allowed,
211 remaining,
212 reset_at,
213 limit: burst,
214 retry_after: if allowed {
215 None
216 } else {
217 Some(Duration::from_millis(retry_after_ms))
218 },
219 }
220 }
221}
222
223/// No-op backend: allows every request with the quota's full budget.
224///
225/// Intended for tests, as a feature-flag "kill switch" that never
226/// throttles, or as a placeholder while wiring a real backend. Not for
227/// production limiting — it enforces nothing.
228///
229/// # Examples
230///
231/// ```
232/// use throttle_kit::{NoopBackend, Quota, RateLimiter};
233///
234/// # async fn run() {
235/// let limiter = RateLimiter::new(Quota::per_second(10), NoopBackend);
236///
237/// // Never throttles: every check is allowed with a full budget.
238/// assert!(limiter.check("anything").await.allowed);
239/// assert!(limiter.check("anything").await.allowed);
240/// # }
241/// # run();
242/// ```
243#[cfg(feature = "test-util")]
244#[cfg_attr(docsrs, doc(cfg(feature = "test-util")))]
245#[derive(Debug, Clone, Copy, Default)]
246pub struct NoopBackend;
247
248#[cfg(feature = "test-util")]
249impl RateLimitBackend for NoopBackend {
250 fn check(&self, _key: &str, quota: &Quota) -> impl Future<Output = RateLimitResult> + Send {
251 std::future::ready(RateLimitResult {
252 allowed: true,
253 remaining: u64::from(quota.burst).saturating_sub(1),
254 reset_at: std::time::Instant::now() + quota.interval(),
255 limit: u64::from(quota.burst),
256 retry_after: None,
257 })
258 }
259}
260
261#[cfg(test)]
262mod tests {
263 use super::*;
264
265 #[test]
266 fn gcra_decide_fresh_key_allows_with_one_interval_budget() {
267 // Fresh key (tac == now): the request itself is conforming, and
268 // exactly one further interval of budget is visible.
269 let (allowed, retry_after, remaining) = gcra_decide(1_000, 1_000, 100, 10);
270 assert!(allowed);
271 assert_eq!(retry_after, 0);
272 assert_eq!(remaining, 1);
273 }
274
275 #[test]
276 fn gcra_decide_burst_rapid_requests_then_deny() {
277 // burst = 3: two more rapid requests conform after the first, the
278 // fourth is denied with a positive retry_after and zero remaining.
279 let (a1, r1, rem1) = gcra_decide(0, 0, 100, 3);
280 assert!(a1 && r1 == 0 && rem1 == 1);
281 let (a2, r2, rem2) = gcra_decide(0, 100, 100, 3);
282 assert!(a2 && r2 == 0 && rem2 == 2);
283 let (a3, r3, rem3) = gcra_decide(0, 200, 100, 3);
284 assert!(a3 && r3 == 0 && rem3 == 3);
285 let (a4, r4, rem4) = gcra_decide(0, 300, 100, 3);
286 assert!(!a4 && r4 > 0 && rem4 == 0);
287 }
288
289 #[test]
290 fn gcra_decide_zero_burst_never_allows() {
291 let (allowed, retry_after, remaining) = gcra_decide(1_000, 1_000, 100, 0);
292 assert!(!allowed);
293 assert!(retry_after >= 1);
294 assert_eq!(remaining, 0);
295 }
296
297 #[test]
298 fn gcra_decide_zero_emission_is_clamped() {
299 // Sub-millisecond quota: emission is clamped to 1 ms, no panic.
300 let (allowed, retry_after, _remaining) = gcra_decide(5, 5, 0, 1);
301 assert!(allowed);
302 assert_eq!(retry_after, 0);
303 }
304
305 #[test]
306 fn gcra_decide_deny_with_now_greater_than_zero() {
307 // A denial with now > 0 pins the retry_after subtraction chain:
308 // new_tat = max(1300, 1000) + 100 = 1400, and the request conforms
309 // only while new_tat <= now + emission * burst (1400 <= 1300 fails),
310 // so retry_after must be exactly new_tat - now - emission * burst.
311 let (allowed, retry_after, remaining) = gcra_decide(1_000, 1_300, 100, 3);
312 assert!(!allowed);
313 assert_eq!(retry_after, 100);
314 assert_eq!(remaining, 0);
315 }
316
317 #[test]
318 fn gcra_decide_extreme_inputs_no_overflow() {
319 // All-u64 extremes: must not overflow or panic (u128 internals).
320 // new_tat = 2*u64::MAX (as u128); emission*burst = u64::MAX^2, so
321 // the request conforms and exactly 2 emission-intervals of budget
322 // are visible (both burst and emission are u64::MAX here).
323 let (allowed, retry_after, remaining) = gcra_decide(0, u64::MAX, u64::MAX, u64::MAX);
324 assert!(allowed);
325 assert_eq!(retry_after, 0);
326 assert_eq!(remaining, 2);
327
328 // now == tac == u64::MAX, emission 1 ms, burst 2: conforms with
329 // exactly one 1-ms interval of budget visible.
330 let (allowed2, retry_after2, remaining2) = gcra_decide(u64::MAX, u64::MAX, 1, 2);
331 assert!(allowed2);
332 assert_eq!(retry_after2, 0);
333 assert_eq!(remaining2, 1);
334 }
335
336 #[cfg(feature = "in-memory")]
337 #[tokio::test]
338 async fn in_memory_backend_reallows_after_real_elapsed_time() {
339 // The backend clock must advance with real time. Exhausting the
340 // burst and sleeping past one interval must re-enable the key; a
341 // constant-clock mutant never observes the sleep and stays denied.
342 let backend = InMemoryBackend::new();
343 let quota = Quota::per_second(10); // 100 ms interval, burst 10
344
345 // Ten rapid conforming requests exhaust the burst budget.
346 for i in 1..=10u64 {
347 let r = backend.check("clock", "a).await;
348 assert!(r.allowed);
349 assert_eq!(r.remaining, i);
350 }
351 assert!(!backend.check("clock", "a).await.allowed);
352
353 tokio::time::sleep(Duration::from_millis(120)).await;
354 assert!(
355 backend.check("clock", "a).await.allowed,
356 "key must re-conform after one full interval of real time"
357 );
358 }
359
360 #[cfg(feature = "in-memory")]
361 #[tokio::test]
362 async fn in_memory_reset_at_is_in_the_future_when_allowed() {
363 let backend = InMemoryBackend::new();
364 let r = backend.check("reset", &Quota::per_second(10)).await;
365 assert!(r.allowed);
366 assert!(
367 r.reset_at > std::time::Instant::now(),
368 "reset_at must be in the future for an allowed request"
369 );
370 }
371
372 #[cfg(feature = "in-memory")]
373 #[tokio::test]
374 async fn warm_key_fast_path_matches_cold_path_semantics() {
375 // The borrowed-key fast path (`get_mut`) must behave exactly like
376 // the cold path it replaced: burst exhaustion and TAC advance are
377 // identical across the boundary, and no duplicate map entries are
378 // created for repeated checks.
379 let backend = InMemoryBackend::new();
380 let quota = Quota::per_second(10); // 100 ms interval, burst 10
381
382 // Call 1 takes the cold path (entry insert); calls 2..=10 take
383 // the warm path. Remaining increments across the boundary exactly
384 // as it did when every call went through `entry()`.
385 for i in 1..=10u64 {
386 let r = backend.check("hot", "a).await;
387 assert!(r.allowed, "call {i} must conform");
388 assert_eq!(r.remaining, i);
389 }
390 // Burst is exhausted through the warm path.
391 let denied = backend.check("hot", "a).await;
392 assert!(!denied.allowed);
393 assert!(denied.retry_after.is_some());
394
395 // Exactly one entry for the key — the fast path must not insert.
396 assert_eq!(backend.entries.len(), 1);
397 assert!(backend.entries.contains_key("hot"));
398 }
399
400 #[cfg(feature = "in-memory")]
401 #[tokio::test]
402 async fn warm_key_reset_at_derived_from_single_clock_read() {
403 // reset_at is anchored to the check's own monotonic clock read:
404 // read_time + interval, with read_time inside the call's wall
405 // window. One clock read per check — no fresh Instant for the
406 // deadline.
407 let backend = InMemoryBackend::new();
408 let quota = Quota::per_second(10); // 100 ms interval
409 let before = std::time::Instant::now();
410 let r = backend.check("clock-domain", "a).await;
411 let after = std::time::Instant::now();
412 assert!(r.allowed);
413 // The monotonic read is truncated to whole milliseconds, so the
414 // deadline can sit up to 1 ms earlier than the untruncated read.
415 assert!(
416 r.reset_at + Duration::from_millis(1) >= before + quota.interval(),
417 "reset_at = clock_read + interval, read no earlier than `before`"
418 );
419 assert!(
420 r.reset_at <= after + quota.interval(),
421 "reset_at = clock_read + interval, read no later than `after`"
422 );
423 }
424}