keyhog_verifier/rate_limit.rs
1//! Per-service rate limiting for verification requests.
2//!
3//! `RateLimiter::wait` enforces a one-token minimum inter-request interval.
4//! Bounded lifecycle callers may reserve a small token-bucket burst while the
5//! same virtual schedule preserves the configured sustained service rate.
6//! Per-service entries can override the default interval via
7//! [`RateLimiter::update_limit`]; the default interval is hot-swappable at
8//! runtime via [`RateLimiter::set_default_rps`] so the CLI's `--verify-rate`
9//! flag can take effect after the global limiter has already been lazily
10//! initialised by an earlier call site.
11use dashmap::DashMap;
12use parking_lot::Mutex;
13use std::sync::atomic::{AtomicU64, AtomicUsize, Ordering};
14use std::time::{Duration, Instant};
15
16/// Aggregate transient-error count above which global backpressure engages,
17/// injecting [`GLOBAL_BACKPRESSURE_PENALTY`] per request until successes drain
18/// the counter back down. Hoisted to a single named owner (was an inline `> 50`
19/// / `from_secs(1)` pair). Tier-A CLI/TOML tuning is tracked separately.
20const GLOBAL_BACKPRESSURE_ERROR_THRESHOLD: usize = 50;
21const GLOBAL_BACKPRESSURE_PENALTY: Duration = Duration::from_secs(1);
22
23/// Per-service AIMD backoff (single named owners; Tier-A CLI/TOML tuning tracked
24/// separately). A `429 Too Many Requests` for a service MULTIPLICATIVELY DECREASES
25/// its rate (interval `*= RATE_LIMIT_BACKOFF_MULTIPLIER`, capped at
26/// `RATE_LIMIT_MAX_INTERVAL`); each subsequent SUCCESSFUL round-trip
27/// ADDITIVELY INCREASES the rate back (interval `-= base/RATE_LIMIT_RECOVERY_STEP_DIVISOR`,
28/// floored at the configured `base_interval`). This is the classic AIMD shape:
29/// back off fast when throttled, recover gently so the service is not immediately
30/// re-throttled. It replaces the previous `update_limit(service, 0.5)` hard-set,
31/// which pinned a service to 0.5 rps forever after a single 429 and NEVER
32/// recovered even after thousands of successes (Law 7: a permanently throttled
33/// verifier is a throughput bug).
34const RATE_LIMIT_BACKOFF_MULTIPLIER: u32 = 2;
35/// Slowest per-service pace under sustained throttling (~0.125 rps). The ceiling
36/// is `max(this, base_interval)` so a service configured slower than this is
37/// never sped up by the cap.
38const RATE_LIMIT_MAX_INTERVAL: Duration = Duration::from_secs(8);
39/// Additive-increase granularity: each success recovers `base_interval / this` of
40/// the backoff, so one doubling heals in `this` successes (gentle, monotone).
41const RATE_LIMIT_RECOVERY_STEP_DIVISOR: u32 = 2;
42
43struct ServiceLimit {
44 last_request: Instant,
45 /// Current working inter-request interval (AIMD-adjusted: `>= base_interval`,
46 /// grows on 429, shrinks back on success).
47 interval: Duration,
48 /// Configured target interval, the FASTEST (smallest) pace for this service
49 /// and the floor AIMD recovery returns to. Set at creation / `update_limit`.
50 base_interval: Duration,
51}
52
53pub struct RateLimiter {
54 services: DashMap<String, Mutex<ServiceLimit>>,
55 /// Default inter-request interval, in nanoseconds. Atomic so the
56 /// CLI can adjust the global limiter's pace after construction
57 /// without having to thread a setter through every caller.
58 default_interval_nanos: AtomicU64,
59 global_error_count: AtomicUsize,
60}
61
62impl RateLimiter {
63 pub fn new(rps: f64) -> Self {
64 Self {
65 services: DashMap::new(),
66 default_interval_nanos: AtomicU64::new(rps_to_nanos(rps)),
67 global_error_count: AtomicUsize::new(0),
68 }
69 }
70
71 /// Replace the default per-service interval. Existing per-service
72 /// entries created via [`Self::update_limit`] are left at their
73 /// override; only the lazily-created defaults pick up the new pace.
74 /// Non-finite or non-positive `rps` falls back to 1.0 - the same
75 /// guard as `new()` so a caller can't drive the limiter into a
76 /// zero-interval (= infinite-rate) state by accident.
77 pub fn set_default_rps(&self, rps: f64) {
78 self.default_interval_nanos
79 .store(rps_to_nanos(rps), Ordering::Relaxed);
80 }
81
82 /// Default interval as a `Duration`. Lock-free.
83 pub fn default_interval(&self) -> Duration {
84 Duration::from_nanos(self.default_interval_nanos.load(Ordering::Relaxed))
85 }
86
87 pub async fn wait(&self, service: &str) {
88 self.wait_with_burst(service, 1).await;
89 }
90
91 /// Reserve one request under a bounded token-bucket burst.
92 ///
93 /// The average rate remains the configured service rate. `burst` only
94 /// controls how many already-bounded lifecycle requests may start together.
95 pub(crate) async fn wait_with_burst(&self, service: &str, burst: usize) {
96 let bp = if self.global_error_count.load(Ordering::Relaxed)
97 > GLOBAL_BACKPRESSURE_ERROR_THRESHOLD
98 {
99 GLOBAL_BACKPRESSURE_PENALTY
100 } else {
101 Duration::ZERO
102 };
103 let wait_time = {
104 let default = self.default_interval();
105 if let Some(entry) = self.services.get(service) {
106 let mut limit = entry.value().lock();
107 reserve_service_slot_with_burst(&mut limit, Instant::now(), burst)
108 } else {
109 let inserted = self.services.entry(service.to_string()).or_insert_with(|| {
110 Mutex::new(ServiceLimit {
111 last_request: initial_last_request(Instant::now(), default),
112 interval: default,
113 base_interval: default,
114 })
115 });
116 let mut limit = inserted.value().lock();
117 reserve_service_slot_with_burst(&mut limit, Instant::now(), burst)
118 }
119 };
120 let delay = match wait_time {
121 Some(wait) => wait.max(bp),
122 None => bp,
123 };
124 if !delay.is_zero() {
125 tokio::time::sleep(delay).await;
126 }
127 }
128
129 pub fn record_error(&self) {
130 self.global_error_count.fetch_add(1, Ordering::Relaxed);
131 }
132
133 pub fn record_success(&self) {
134 let _ = self // LAW10: floor-at-zero decrement; failure means the counter is already zero
135 .global_error_count
136 .fetch_update(Ordering::Relaxed, Ordering::Relaxed, |n| n.checked_sub(1));
137 }
138
139 pub(crate) fn error_count_for_test(&self) -> usize {
140 self.global_error_count.load(Ordering::Relaxed)
141 }
142
143 /// Configure a service's TARGET rate (the `base_interval` AIMD recovers to).
144 /// A config change resets any in-flight backoff: `interval == base_interval`.
145 pub async fn update_limit(&self, service: &str, rps: f64) {
146 let interval = Duration::from_nanos(rps_to_nanos(rps));
147 self.services.insert(
148 service.to_string(),
149 Mutex::new(ServiceLimit {
150 last_request: Instant::now(),
151 interval,
152 base_interval: interval,
153 }),
154 );
155 }
156
157 /// Multiplicative-decrease: a `429` for `service` slows it by
158 /// [`RATE_LIMIT_BACKOFF_MULTIPLIER`], capped at
159 /// `max(RATE_LIMIT_MAX_INTERVAL, base_interval)` (never faster than the
160 /// configured base). If the service has no slot yet, one is created at the
161 /// default pace already backed off once. Takes effect on the next
162 /// [`Self::wait`]. Recovery is driven by [`Self::reward_service`] on success.
163 pub fn penalize_service(&self, service: &str) {
164 if let Some(entry) = self.services.get(service) {
165 let mut limit = entry.value().lock();
166 let ceiling = RATE_LIMIT_MAX_INTERVAL.max(limit.base_interval);
167 limit.interval = limit
168 .interval
169 .checked_mul(RATE_LIMIT_BACKOFF_MULTIPLIER)
170 .map_or(ceiling, |interval| interval)
171 .min(ceiling);
172 } else {
173 let default = self.default_interval();
174 let ceiling = RATE_LIMIT_MAX_INTERVAL.max(default);
175 let interval = default
176 .checked_mul(RATE_LIMIT_BACKOFF_MULTIPLIER)
177 .map_or(ceiling, |interval| interval)
178 .min(ceiling);
179 self.services.entry(service.to_string()).or_insert_with(|| {
180 Mutex::new(ServiceLimit {
181 last_request: Instant::now(),
182 interval,
183 base_interval: default,
184 })
185 });
186 }
187 }
188
189 /// Additive-increase: a successful round-trip for `service` recovers
190 /// `base_interval / RATE_LIMIT_RECOVERY_STEP_DIVISOR` of its backoff, floored
191 /// at `base_interval` (never faster than configured). No-op when the service
192 /// has no slot or is already at base (cheap to call on every success).
193 pub fn reward_service(&self, service: &str) {
194 if let Some(entry) = self.services.get(service) {
195 let mut limit = entry.value().lock();
196 if limit.interval > limit.base_interval {
197 let step = limit.base_interval / RATE_LIMIT_RECOVERY_STEP_DIVISOR;
198 limit.interval = limit.interval.saturating_sub(step).max(limit.base_interval);
199 }
200 }
201 }
202
203 /// Current working inter-request interval for `service`, or `None` if the
204 /// service has no slot yet. Introspection of the live AIMD state (used by the
205 /// recovery regression test and available for operator diagnostics).
206 pub fn service_interval(&self, service: &str) -> Option<Duration> {
207 self.services
208 .get(service)
209 .map(|entry| entry.value().lock().interval)
210 }
211}
212
213/// Initial `last_request` for a freshly-created service slot: one interval in
214/// the past so the very first request is admitted immediately (`next_slot =
215/// last_request + interval = now`). On a host with very low uptime (a fresh
216/// container, where `Instant::now()` can be *less than* `interval` from the
217/// monotonic clock's origin) a plain `now - interval` underflows and PANICS.
218/// `checked_sub` clamps to `now` instead: the first request then waits one
219/// interval, a one-off politeness delay, never a correctness or security
220/// regression, and never a panic.
221pub(crate) fn initial_last_request(now: Instant, interval: Duration) -> Instant {
222 now.checked_sub(interval).map_or(now, |instant| instant)
223}
224
225fn reserve_service_slot_with_burst(
226 limit: &mut ServiceLimit,
227 now: Instant,
228 burst: usize,
229) -> Option<Duration> {
230 // `last_request` is the start of the most-recent slot, including slots
231 // reserved by queued callers. A burst permits `burst` consecutive slots to
232 // begin together, then preserves one configured interval per later slot.
233 let next_slot = limit.last_request + limit.interval;
234 let tolerance = limit
235 .interval
236 .saturating_mul(u32::try_from(burst.saturating_sub(1)).unwrap_or(u32::MAX)); // LAW10: burst tolerance intentionally saturates at Duration's multiplier bound; it cannot wrap into a shorter allowance.
237 let earliest = next_slot.checked_sub(tolerance).unwrap_or(now); // LAW10: tolerance before Instant's representable origin conservatively permits the current request rather than wrapping time.
238 if now >= earliest {
239 limit.last_request = next_slot.max(now);
240 None
241 } else {
242 let wait = earliest.saturating_duration_since(now);
243 limit.last_request = next_slot;
244 Some(wait)
245 }
246}
247
248pub(crate) fn burst_reservation_waits_for_test(
249 interval: Duration,
250 burst: usize,
251 reservations: usize,
252) -> Vec<Option<Duration>> {
253 let now = Instant::now();
254 let mut limit = ServiceLimit {
255 last_request: initial_last_request(now, interval),
256 interval,
257 base_interval: interval,
258 };
259 (0..reservations)
260 .map(|_| reserve_service_slot_with_burst(&mut limit, now, burst))
261 .collect()
262}
263
264fn rps_to_nanos(rps: f64) -> u64 {
265 let rate = if rps.is_finite() && rps > 0.0 {
266 rps
267 } else {
268 1.0
269 };
270 let nanos = (1.0e9 / rate).round();
271 if nanos.is_finite() && nanos < 1.0 {
272 1
273 } else if nanos.is_finite() && nanos <= u64::MAX as f64 {
274 nanos as u64
275 } else {
276 1_000_000_000
277 }
278}
279
280use std::sync::OnceLock;
281pub static GLOBAL_RATE_LIMITER: OnceLock<RateLimiter> = OnceLock::new();
282
283/// Lazily create the process-wide rate limiter at the default 5 rps.
284/// Use [`set_global_default_rps`] to retune after init.
285pub fn get_rate_limiter() -> &'static RateLimiter {
286 GLOBAL_RATE_LIMITER.get_or_init(|| RateLimiter::new(5.0))
287}
288
289/// Convenience setter the CLI calls once at startup to apply the
290/// `--verify-rate` flag. Idempotent; safe to call before or after the
291/// limiter has been lazily initialised.
292pub fn set_global_default_rps(rps: f64) {
293 get_rate_limiter().set_default_rps(rps);
294}