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}