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