Skip to main content

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", &quota).await;
348            assert!(r.allowed);
349            assert_eq!(r.remaining, i);
350        }
351        assert!(!backend.check("clock", &quota).await.allowed);
352
353        tokio::time::sleep(Duration::from_millis(120)).await;
354        assert!(
355            backend.check("clock", &quota).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", &quota).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", &quota).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", &quota).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}