Skip to main content

structured_proxy/shield/
gcra.rs

1//! GCRA (Generic Cell Rate Algorithm) rate limiter core.
2//!
3//! GCRA is a token-bucket equivalent that stores a single value per key: the
4//! *theoretical arrival time* (TAT). Compared with a fixed window it has no
5//! boundary burst (a client cannot spend two full windows across a boundary)
6//! and it lets legitimate bursts through up to a configured capacity while
7//! throttling sustained abuse to the steady rate.
8//!
9//! This module is pure arithmetic with an injected clock, so the burst
10//! boundary, refill, and clock-skew behaviour are all unit-testable without a
11//! store or a real clock.
12
13use std::time::Duration;
14
15/// A GCRA limiter parameterised by a steady emission interval and a burst
16/// tolerance. Construct one from a [`Profile`] with [`Gcra::from_profile`].
17#[derive(Debug, Clone, Copy, PartialEq, Eq)]
18pub struct Gcra {
19    /// Time between two requests at the sustained rate (`window / rate`).
20    emission_interval: Duration,
21    /// Delay-variation tolerance: how far ahead of the steady schedule a burst
22    /// may run. `(burst - 1) * emission_interval`, so a fresh key admits exactly
23    /// `burst` requests instantly before throttling to the steady rate.
24    tau: Duration,
25    /// Bucket capacity: the most requests admissible at any instant. Caps the
26    /// reported `remaining` so an idle key cannot report above a full bucket.
27    burst: u64,
28}
29
30/// A named limit tier: a sustained rate over a window plus an instantaneous
31/// burst capacity.
32#[derive(Debug, Clone, Copy, PartialEq, Eq)]
33#[non_exhaustive]
34pub struct Profile {
35    /// Sustained requests permitted per `window`.
36    pub rate: u64,
37    /// Length of the sustained-rate window.
38    pub window: Duration,
39    /// Maximum requests admitted back-to-back before throttling to the rate.
40    /// At least 1.
41    pub burst: u64,
42}
43
44impl Profile {
45    /// `rate` requests per `window`, `burst` of them back to back.
46    pub fn new(rate: u64, window: Duration, burst: u64) -> Self {
47        Self {
48            rate,
49            window,
50            burst,
51        }
52    }
53}
54
55/// The outcome of a single GCRA check.
56#[derive(Debug, Clone, Copy, PartialEq, Eq)]
57#[non_exhaustive]
58pub struct Verdict {
59    /// Whether the request is conforming (allowed).
60    pub allowed: bool,
61    /// The TAT to persist for this key (unchanged from the stored value when
62    /// the request is rejected).
63    pub new_tat: Duration,
64    /// Requests still admissible at this instant after this one (0 when rejected).
65    pub remaining: u64,
66    /// When rejected, how long until a retry would conform (`Retry-After`).
67    pub retry_after: Duration,
68    /// How long until the limiter drains back toward full capacity
69    /// (`RateLimit-Reset`).
70    pub reset_after: Duration,
71}
72
73impl Gcra {
74    /// Build a limiter from a [`Profile`]. `burst` is clamped to at least 1 and
75    /// `rate` to at least 1 so the emission interval is finite.
76    pub fn from_profile(profile: Profile) -> Self {
77        let rate = profile.rate.max(1);
78        let burst = profile.burst.max(1);
79        // T = window / rate, computed in nanoseconds to avoid truncation.
80        let window_nanos = profile.window.as_nanos().max(1);
81        let emission_nanos = window_nanos / u128::from(rate);
82        let emission_interval = duration_from_nanos(emission_nanos.max(1));
83        let tau = emission_interval * u32::try_from(burst - 1).unwrap_or(u32::MAX);
84        Self {
85            emission_interval,
86            tau,
87            burst,
88        }
89    }
90
91    /// Evaluate a request arriving at `now` (a monotonically-increasing instant
92    /// expressed as a [`Duration`] since a fixed epoch), given the key's stored
93    /// TAT (`None` for a first-seen key).
94    ///
95    /// A `now` that moves backwards (clock skew) is tolerated: the check never
96    /// panics and treats the effective arrival time as `max(now, tat_floor)`.
97    pub fn check(&self, stored_tat: Option<Duration>, now: Duration) -> Verdict {
98        let t = self.emission_interval;
99        let tau = self.tau;
100        // Canonical GCRA uses the stored TAT as-is (a first-seen key starts at
101        // `now`, an empty bucket).
102        let tat = stored_tat.unwrap_or(now);
103
104        // Number of requests admissible at `now` from the stored TAT: each
105        // admitted request pushes TAT forward by `t`, and a request conforms
106        // while `now >= tat + k*t - tau`. `now + tau >= tat` means at least one
107        // conforms.
108        // Cap at the bucket: an idle key (TAT far in the past) yields a large
109        // slack, but no more than `burst` requests can ever be admissible at once.
110        let admissible = if now + tau >= tat {
111            let slack = (now + tau) - tat; // >= 0
112            (1 + div_floor(slack, t)).min(self.burst)
113        } else {
114            0
115        };
116
117        if admissible == 0 {
118            // Non-conforming: earliest conforming instant is `tat - tau`.
119            let retry_after = tat.saturating_sub(tau).saturating_sub(now);
120            Verdict {
121                allowed: false,
122                new_tat: tat,
123                remaining: 0,
124                retry_after,
125                reset_after: tat.saturating_sub(now),
126            }
127        } else {
128            let new_tat = tat.max(now) + t;
129            Verdict {
130                allowed: true,
131                new_tat,
132                remaining: admissible - 1,
133                retry_after: Duration::ZERO,
134                reset_after: new_tat.saturating_sub(now),
135            }
136        }
137    }
138}
139
140/// Floor division of two `Duration`s (`a / b`), in nanoseconds.
141fn div_floor(a: Duration, b: Duration) -> u64 {
142    let b = b.as_nanos().max(1);
143    u64::try_from(a.as_nanos() / b).unwrap_or(u64::MAX)
144}
145
146/// A `Duration` from a `u128` nanosecond count, saturating at `Duration::MAX`.
147fn duration_from_nanos(nanos: u128) -> Duration {
148    let secs = nanos / 1_000_000_000;
149    let sub = (nanos % 1_000_000_000) as u32;
150    match u64::try_from(secs) {
151        Ok(secs) => Duration::new(secs, sub),
152        Err(_) => Duration::MAX,
153    }
154}
155
156#[cfg(test)]
157mod tests {
158    use super::*;
159
160    fn profile(rate: u64, window_secs: u64, burst: u64) -> Profile {
161        Profile {
162            rate,
163            window: Duration::from_secs(window_secs),
164            burst,
165        }
166    }
167
168    /// A fresh key admits exactly `burst` requests instantly, then rejects.
169    #[test]
170    fn burst_boundary_admits_exactly_burst() {
171        let g = Gcra::from_profile(profile(60, 60, 3)); // 1 req/s, burst 3
172        let now = Duration::from_secs(100);
173        let mut tat = None;
174        for i in 0..3 {
175            let v = g.check(tat, now);
176            assert!(v.allowed, "request {i} should be allowed");
177            assert_eq!(v.remaining, (3 - 1 - i) as u64, "remaining after {i}");
178            tat = Some(v.new_tat);
179        }
180        // 4th at the same instant is rejected.
181        let v = g.check(tat, now);
182        assert!(!v.allowed);
183        assert_eq!(v.remaining, 0);
184        // Retry after one emission interval (1s).
185        assert_eq!(v.retry_after, Duration::from_secs(1));
186    }
187
188    /// After exhausting the burst, one request is admitted every emission
189    /// interval (refill at the steady rate).
190    #[test]
191    fn refills_at_steady_rate() {
192        let g = Gcra::from_profile(profile(60, 60, 2)); // 1 req/s, burst 2
193        let start = Duration::from_secs(0);
194        let mut tat = None;
195        // Spend the burst (2) at t=0.
196        for _ in 0..2 {
197            let v = g.check(tat, start);
198            assert!(v.allowed);
199            tat = Some(v.new_tat);
200        }
201        // Immediately after: rejected.
202        assert!(!g.check(tat, start).allowed);
203        // 1 second later: exactly one slot has refilled.
204        let later = start + Duration::from_secs(1);
205        let v = g.check(tat, later);
206        assert!(v.allowed);
207        tat = Some(v.new_tat);
208        // A second request at the same instant is rejected (only one refilled).
209        assert!(!g.check(tat, later).allowed);
210    }
211
212    /// A `now` that jumps backwards (clock skew) must not panic and must not
213    /// wrongly admit an unbounded burst.
214    #[test]
215    fn tolerates_backward_clock() {
216        let g = Gcra::from_profile(profile(60, 60, 1)); // 1 req/s, burst 1
217        let now = Duration::from_secs(1000);
218        let v = g.check(None, now);
219        assert!(v.allowed);
220        let tat = Some(v.new_tat);
221        // Clock jumps back 500s. The next request is still governed by the
222        // stored TAT (which is ahead), so it is rejected, not admitted.
223        let back = Duration::from_secs(500);
224        let v = g.check(tat, back);
225        // The stored TAT (ahead of the rewound clock) still governs: the request
226        // is rejected, not wrongly admitted, and retry_after is a finite,
227        // conservative wait (never a panic or an overflow).
228        assert!(!v.allowed);
229        assert!(v.retry_after > Duration::ZERO);
230        assert!(v.retry_after <= Duration::from_secs(501));
231    }
232
233    /// `reset_after` shrinks toward zero as the bucket drains over time.
234    #[test]
235    fn reset_after_drains_over_time() {
236        let g = Gcra::from_profile(profile(60, 60, 5)); // 1 req/s, burst 5
237        let start = Duration::from_secs(0);
238        let mut tat = None;
239        let mut last_reset = Duration::MAX;
240        for _ in 0..5 {
241            let v = g.check(tat, start);
242            assert!(v.allowed);
243            tat = Some(v.new_tat);
244            last_reset = v.reset_after;
245        }
246        // After the full burst, reset_after == burst * emission (5s).
247        assert_eq!(last_reset, Duration::from_secs(5));
248    }
249
250    /// After a key has fully drained (idle far past its TAT), the next request's
251    /// reported `remaining` must not exceed the bucket: `burst - 1`, never an
252    /// inflated count derived from the long idle gap.
253    #[test]
254    fn idle_key_remaining_capped_to_burst() {
255        let g = Gcra::from_profile(profile(60, 60, 3)); // 1/s, burst 3
256        let first = g.check(None, Duration::from_secs(100));
257        assert!(first.allowed);
258        // Idle for a long time, then one request: remaining is burst-1, not the
259        // huge slack-derived value.
260        let v = g.check(Some(first.new_tat), Duration::from_secs(1000));
261        assert!(v.allowed);
262        assert_eq!(v.remaining, 2, "remaining must be capped to burst-1");
263    }
264
265    /// A bare-rate profile (burst 1) admits one request per emission interval
266    /// with no instantaneous burst.
267    #[test]
268    fn burst_one_is_pure_rate_limit() {
269        let g = Gcra::from_profile(profile(2, 1, 1)); // 2 req/s, burst 1
270        let now = Duration::from_secs(0);
271        let v = g.check(None, now);
272        assert!(v.allowed);
273        // Second request at the same instant rejected (burst 1).
274        assert!(!g.check(Some(v.new_tat), now).allowed);
275    }
276}