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}