Skip to main content

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}