dwara_core/extensions/rate_limiter.rs
1//! Rate limiting extension point.
2//!
3//! # Contract: [`RateLimiter`]
4//!
5//! **Purpose:** decide, per caller-supplied key (consumer id, IP, route, or
6//! a composite), whether a request costing `cost` units may proceed.
7//!
8//! **Semantics:** `check` is the hot path — it MUST be non-blocking from the
9//! caller's perspective (in-flight for at most the backend round-trip) and
10//! MUST be atomic: concurrent `check` calls for the same key are linearized
11//! by the implementation. Ordering across distinct keys is unspecified.
12//! `check` both decides AND reserves: if it returns `allowed: true` the cost
13//! has already been deducted from the key's budget; callers must not call
14//! again to "commit". There is no separate refund in M1.
15//!
16//! **Failure model:** returns [`ExtensionsError`]; a limiter that cannot
17//! reach its backend should report [`ExtensionsError::Backend`]. Callers are
18//! expected to apply their own fail-open/fail-closed policy — the trait does
19//! not prescribe one. No retries are built in.
20//!
21//! **Editions:** OSS ships [`InMemoryRateLimiter`] (fixed window, kept for
22//! reference) and [`GcraRateLimiter`] (DW-017: the real sharded GCRA
23//! limiter behind this same trait). Additional distributed limiter
24//! backends may be provided separately in future editions.
25//!
26//! # DW-017: GCRA limiter and policy engine
27//!
28//! [`GcraRateLimiter`] implements the [`RateLimiter`] trait with the
29//! `governor` crate's GCRA cells over a sharded keyed state store
30//! (`GcraShardStore`: fixed-count shard-locked maps per window, no
31//! global mutex; contrast [`InMemoryRateLimiter`]'s single
32//! `Mutex<HashMap>`, which is why it stayed a skeleton). A limiter may
33//! STACK several windows (e.g. 10 r/s AND 100 r/hour): each window is
34//! an independent GCRA cell per key and the decision is the AND of all
35//! windows — denied if ANY window denies, `retry_after` from the
36//! denying (binding) window.
37//!
38//! **Stacking consumption semantics (documented trade-off):** windows are
39//! evaluated shortest-first and evaluation STOPS at the first denial, so
40//! windows before the binding one have already consumed their cell. A
41//! request denied by the hourly window still spends one second-window
42//! token. This is fail-fast and slightly STRICTER than a fully-atomic
43//! all-windows decision (governor's public API has no non-consuming
44//! peek), never more permissive, and the waste is bounded to the
45//! short-window bucket (which also replenishes fastest).
46//!
47//! **Multi-rule denial semantics:** when several RULES apply (route- and
48//! service-attached policies), a denial in one rule does not stop
49//! evaluation of the others — every applicable rule's state advances and
50//! the reported `Retry-After` is the MAXIMUM wait across all denying
51//! rules, while the Limit/Remaining headers come from the first (binding)
52//! denying rule. Headers thus show the tightest constraint in resolution
53//! order; Retry-After is the longest wait, so a compliant client never
54//! retries into a second 429 early. This max-wait rule is across RULES;
55//! the stricter-not-looser stop-at-first-denial semantics above stay,
56//! per limiter, for WINDOWS stacked within one rule.
57//!
58//! **Dry-run bundles (DW-041):** a policy bundle may set `dry_run` —
59//! its rules still evaluate (buckets advance exactly as if enforcing,
60//! so denial rates preview enforcement) but its denials are reported to
61//! the caller ([`RateLimitEngine::evaluate`]'s `dry_denied`]) instead of
62//! enforced, and its allowed outcomes contribute no `X-RateLimit-*`
63//! headers. LIVE bundles attached to the same request enforce
64//! unaffected: monitor mode observes without ever making enforcement
65//! more permissive. Extensions may not import observability, so the
66//! log/metric side of the report lives on the dataplane caller.
67//!
68//! **Per-key eviction (#122):** every window's keyed GCRA state lives in
69//! a `GcraShardStore` — [`GCRA_STORE_SHARDS`] independent lock-guarded
70//! maps replacing governor's unbounded `DashMapStateStore` (whose key
71//! set a reload can only reset wholesale). Each shard holds at most
72//! [`crate::config::limits::MAX_RATE_LIMITER_KEYS_PER_SHARD`] keys, so a
73//! window's worst case is `GCRA_STORE_SHARDS` times that cap — an
74//! `[ip]`-selector limiter under key spray is memory-bounded for the
75//! process lifetime. The sweep runs INLINE, on the shard lock a
76//! reservation already holds (cascade-on-insert; no background task, no
77//! O(cap) work per request): when a shard reaches its cap it first
78//! drops IDLE cells — keys untouched for at least the window's
79//! full-refill time, whose GCRA state is indistinguishable from a fresh
80//! cell, so dropping them cannot change any decision — and if the shard
81//! is still crowded (every key fresh — sustained spray) it evicts the
82//! idlest half, ordered by `(last_touch_ms, key)`, down to half the cap
83//! so the O(cap) sweep amortizes to O(1) per insertion. Idle-first
84//! ordering means a key actively under enforcement (denied, hence
85//! freshly touched) is evicted only when the shard holds more than a
86//! cap-full of keys ALL touched within one full-refill window; losing a
87//! cell then merely resets that key's bucket (a fresh bucket can only
88//! be MORE permissive for that key, never stricter for anyone) — the
89//! documented fail-open trade under spray. `GcraRateLimiter::evictions`
90//! exposes the dropped-cell count as a monotonic counter in the
91//! balancer's fail-open-picks style; extensions may not import
92//! observability, so no metric family is wired here (a future gauge can
93//! scrape it from the dataplane).
94//!
95//! **Legacy field mapping:** a policy's old `rate_limit
96//! {requests, window_seconds}` compiles to one rule with selector
97//! `[route]` and a single window of `requests` per `window_seconds`
98//! (burst = requests). Both fields may be set; both apply.
99//!
100//! **Burst vs sustained:** a window of `requests_per.s = 10` with
101//! `burst: 20` is a GCRA quota replenishing 1 token per 1/10 s with a
102//! 20-token bucket: 20 rapid requests pass (burst), sustained traffic
103//! above 10 r/s starts drawing 429s once the bucket empties. `burst`
104//! defaults to the window's request count; under GCRA the first window
105//! can admit up to `burst + replenished` cells (documented, standard
106//! GCRA shape).
107
108use std::collections::hash_map::RandomState;
109use std::collections::HashMap;
110use std::hash::BuildHasher;
111use std::num::NonZeroU32;
112use std::time::{Duration, Instant};
113// DW-025 (#122): under the `loom` feature the shard mutexes and the
114// eviction counter swap for loom's model-checked equivalents so the
115// shard reservation path (lock -> read TAT -> decide -> write TAT) can
116// be exhaustively explored; builds are bit-identical otherwise.
117#[cfg(feature = "loom")]
118use loom::sync::atomic::{AtomicU64, Ordering};
119#[cfg(feature = "loom")]
120use loom::sync::Mutex;
121#[cfg(not(feature = "loom"))]
122use std::sync::atomic::{AtomicU64, Ordering};
123#[cfg(not(feature = "loom"))]
124use std::sync::{Arc, Mutex};
125// Arc stays std even under loom (the health.rs precedent): it is a
126// container, not a synchronization primitive.
127#[cfg(feature = "loom")]
128use std::sync::Arc;
129
130use async_trait::async_trait;
131use governor::clock::Clock as _;
132use governor::clock::{DefaultClock, QuantaInstant};
133use governor::middleware::{NoOpMiddleware, StateInformationMiddleware};
134use governor::nanos::Nanos;
135use governor::state::keyed::ShrinkableKeyedStateStore;
136use governor::state::{RateLimiter as GovernorLimiter, StateStore};
137use governor::Quota;
138
139use super::ExtensionsError;
140
141/// Outcome of a [`RateLimiter::check`] call.
142#[derive(Debug, Clone, Copy, PartialEq, Eq)]
143pub struct RateDecision {
144 /// Whether the request is allowed (cost already reserved if true).
145 pub allowed: bool,
146 /// Units remaining for the key after this decision.
147 pub remaining: u64,
148 /// When allowed is false: milliseconds until the window resets and the
149 /// caller may retry. `None` when allowed. This is the window remainder,
150 /// not a success promise: a retry may still be denied when the request's
151 /// cost exceeds the key's limit.
152 pub retry_after_ms: Option<u64>,
153}
154
155/// Swappable rate-limit backend.
156#[async_trait]
157pub trait RateLimiter: Send + Sync {
158 /// Atomically decide-and-reserve `cost` units for `key`.
159 async fn check(&self, key: &str, cost: u32) -> Result<RateDecision, ExtensionsError>;
160}
161
162#[derive(Debug)]
163struct WindowState {
164 window_start_ms: u128,
165 used: u64,
166}
167
168/// In-memory fixed-window limiter (OSS skeleton).
169///
170/// DW-017 superseded this skeleton with [`GcraRateLimiter`] (sharded,
171/// stacked-window GCRA behind the same trait); it is kept as the simple
172/// fixed-window reference implementation and trait-existence proof.
173#[derive(Debug)]
174pub struct InMemoryRateLimiter {
175 limit: u64,
176 window_ms: u128,
177 now_ms: fn() -> u128,
178 windows: Mutex<HashMap<String, WindowState>>,
179}
180
181impl InMemoryRateLimiter {
182 /// New limiter allowing `limit` units per `window_ms` per key.
183 pub fn new(limit: u64, window_ms: u64) -> Self {
184 Self {
185 limit,
186 window_ms: window_ms as u128,
187 now_ms: || {
188 std::time::SystemTime::now()
189 .duration_since(std::time::UNIX_EPOCH)
190 .map(|d| d.as_millis())
191 .unwrap_or(0)
192 },
193 windows: Mutex::new(HashMap::new()),
194 }
195 }
196
197 /// New limiter using a caller-supplied millisecond clock.
198 ///
199 /// Intended for tests and other time-controlled setups; production code
200 /// should prefer [`InMemoryRateLimiter::new`], which uses the system
201 /// clock. `now_ms` must return Unix-epoch milliseconds and must be cheap
202 /// to call (it runs on every `check`).
203 pub fn with_clock(limit: u64, window_ms: u64, now_ms: fn() -> u128) -> Self {
204 Self {
205 limit,
206 window_ms: window_ms as u128,
207 now_ms,
208 windows: Mutex::new(HashMap::new()),
209 }
210 }
211
212 fn now(&self) -> u128 {
213 (self.now_ms)()
214 }
215}
216
217#[async_trait]
218impl RateLimiter for InMemoryRateLimiter {
219 async fn check(&self, key: &str, cost: u32) -> Result<RateDecision, ExtensionsError> {
220 let cost = u64::from(cost);
221 let mut windows = self.windows.lock().expect("rate limiter state poisoned");
222 let now = self.now();
223 let state = match windows.get_mut(key) {
224 Some(state) => state,
225 None => windows.entry(key.to_owned()).or_insert(WindowState {
226 window_start_ms: now,
227 used: 0,
228 }),
229 };
230 if now.saturating_sub(state.window_start_ms) >= self.window_ms {
231 state.window_start_ms = now;
232 state.used = 0;
233 }
234 if state.used + cost <= self.limit {
235 state.used += cost;
236 Ok(RateDecision {
237 allowed: true,
238 remaining: self.limit - state.used,
239 retry_after_ms: None,
240 })
241 } else {
242 Ok(RateDecision {
243 allowed: false,
244 remaining: self.limit - state.used,
245 retry_after_ms: Some(
246 u64::try_from(self.window_ms - now.saturating_sub(state.window_start_ms))
247 .unwrap_or(0),
248 ),
249 })
250 }
251 }
252}
253
254/// Number of independent shard locks in a `GcraShardStore`. Fixed (not
255/// CPU-count-derived) so the worst-case key bound per window
256/// (`GCRA_STORE_SHARDS * MAX_RATE_LIMITER_KEYS_PER_SHARD`) is the same
257/// number on every machine. Public for the eviction tests' bound
258/// assertions.
259pub const GCRA_STORE_SHARDS: usize = 16;
260
261/// Per-key GCRA cell: governor's theoretical arrival time in nanoseconds
262/// since the limiter was created (`0` = fresh/absent, mirroring
263/// governor's `NonZeroU64` encoding) plus the last-touch stamp the
264/// eviction sweep orders by. The stamp comes from the store's
265/// bookkeeping clock, NOT governor's GCRA clock.
266#[derive(Debug, Default, Clone, Copy)]
267struct GcraCell {
268 tat: u64,
269 last_touch_ms: u64,
270}
271
272/// One shard's keyed cells (a plain map; the shard mutex provides the
273/// atomicity governor's `measure_and_replace` contract requires).
274#[derive(Debug, Default)]
275struct ShardCells {
276 cells: HashMap<String, GcraCell>,
277}
278
279/// Clock for eviction bookkeeping only — governor keeps its own
280/// monotonic clock for the GCRA math (see `GcraRateLimiter::with_clock`).
281#[derive(Debug, Clone, Copy)]
282enum StoreClock {
283 /// Milliseconds elapsed since store creation (production default).
284 Monotonic(Instant),
285 /// Caller-supplied millisecond source (deterministic tests).
286 Injected(fn() -> u64),
287}
288
289impl StoreClock {
290 fn now_ms(&self) -> u64 {
291 match *self {
292 StoreClock::Monotonic(start) => start.elapsed().as_millis() as u64,
293 StoreClock::Injected(now_ms) => now_ms(),
294 }
295 }
296}
297
298/// Sharded keyed state store for one GCRA window (#122).
299///
300/// Implements governor's [`StateStore`] so the limiter keeps governor's
301/// GCRA decision math (`measure_and_replace` receives the closure and
302/// must atomically apply its new state — or leave the old state in place
303/// when it returns `Err`, i.e. on a denial). Unlike governor's
304/// `DashMapStateStore`, whose key set grows for the process lifetime,
305/// each shard is size-capped at
306/// [`crate::config::limits::MAX_RATE_LIMITER_KEYS_PER_SHARD`] keys with
307/// an inline idle-first eviction sweep (see the module docs for the
308/// policy and its fail-open trade).
309struct GcraShardStore {
310 shards: Vec<Mutex<ShardCells>>,
311 hasher: RandomState,
312 clock: StoreClock,
313 /// A key untouched for at least this long is indistinguishable from
314 /// a fresh cell and may be dropped by the sweep's idle pass.
315 max_idle_ms: u64,
316 /// Dropped-cell count, shared with the owning window (monotonic;
317 /// scrape-ready in the fail-open-picks style).
318 evictions: Arc<AtomicU64>,
319}
320
321impl GcraShardStore {
322 fn new(max_idle_ms: u64, clock: StoreClock, evictions: Arc<AtomicU64>) -> Self {
323 Self {
324 shards: (0..GCRA_STORE_SHARDS)
325 .map(|_| Mutex::new(ShardCells::default()))
326 .collect(),
327 hasher: RandomState::new(),
328 clock,
329 max_idle_ms,
330 evictions,
331 }
332 }
333
334 fn shard_for(&self, key: &str) -> &Mutex<ShardCells> {
335 &self.shards[(self.hasher.hash_one(key) as usize) & (GCRA_STORE_SHARDS - 1)]
336 }
337
338 /// Inline cascade-on-insert sweep (see module docs): runs when the
339 /// shard REACHES its cap — before the requesting key's own insert,
340 /// so a shard never exceeds the cap, even transiently — first
341 /// dropping idle cells, then, if every key is fresh, the idlest half
342 /// by `(last_touch_ms, key)` order down to half the cap, which
343 /// amortizes the O(cap) scan to O(1) per insertion. Deterministic:
344 /// the victim order is a total order. Must be called with the shard
345 /// already locked, BEFORE the current key's cell is touched, so the
346 /// sweep orders by pre-request stamps: a cell dropped by the idle
347 /// pass is decision-identical to a fresh cell (its TAT lies in the
348 /// past), and the size pass's cap-full-of-fresh-keys trade is the
349 /// documented one.
350 fn sweep_if_crowded(&self, shard: &mut ShardCells, now_ms: u64) {
351 let cap = crate::config::limits::MAX_RATE_LIMITER_KEYS_PER_SHARD;
352 if shard.cells.len() < cap {
353 return;
354 }
355 // Idle pass: cells untouched for at least one full refill are
356 // fresh-equivalent; dropping them is unobservable through
357 // decisions (the millisecond rounding keeps a cell at most ~1ms
358 // past its refill before it becomes droppable).
359 let before = shard.cells.len();
360 shard
361 .cells
362 .retain(|_, cell| now_ms.saturating_sub(cell.last_touch_ms) < self.max_idle_ms);
363 let mut dropped = (before - shard.cells.len()) as u64;
364 // Size pass: still at/over the cap — every remaining key is
365 // fresh. Evict the idlest half down to the low-water mark.
366 if shard.cells.len() >= cap {
367 let excess = shard.cells.len() - cap / 2;
368 let mut victims: Vec<(u64, String)> = shard
369 .cells
370 .iter()
371 .map(|(key, cell)| (cell.last_touch_ms, key.clone()))
372 .collect();
373 victims.sort_unstable();
374 for (_, key) in victims.into_iter().take(excess) {
375 shard.cells.remove(&key);
376 }
377 dropped += excess as u64;
378 }
379 if dropped > 0 {
380 self.evictions.fetch_add(dropped, Ordering::Relaxed);
381 }
382 }
383}
384
385/// The reservation critical section: one shard lock covers the
386/// read-decide-write of the TAT, linearizing concurrent checks for the
387/// same key (and any keys sharing its shard).
388impl StateStore for GcraShardStore {
389 type Key = String;
390
391 fn measure_and_replace<T, F, E>(&self, key: &String, f: F) -> Result<T, E>
392 where
393 F: Fn(Option<Nanos>) -> Result<(T, Nanos), E>,
394 {
395 let now_ms = self.clock.now_ms();
396 let shard = self.shard_for(key);
397 let mut guard = shard.lock().expect("rate limiter shard poisoned");
398 self.sweep_if_crowded(&mut guard, now_ms);
399 if let Some(cell) = guard.cells.get_mut(key) {
400 // Fast path: measure the existing cell. A denial (`Err`)
401 // leaves the TAT untouched per governor's contract, but
402 // still counts as activity — a throttled key is an ACTIVE
403 // key and must not age out of the store ahead of idle ones.
404 cell.last_touch_ms = now_ms;
405 let prev = if cell.tat == 0 {
406 None
407 } else {
408 Some(Nanos::from(cell.tat))
409 };
410 match f(prev) {
411 Ok((outcome, new_tat)) => {
412 cell.tat = new_tat.as_u64();
413 Ok(outcome)
414 }
415 Err(e) => Err(e),
416 }
417 } else {
418 // Fresh cell: governor seeds the TAT from `t0` itself, so a
419 // first touch is always admitted (when the cost fits the
420 // bucket at all). A denial here stores nothing.
421 match f(None) {
422 Ok((outcome, new_tat)) => {
423 guard.cells.insert(
424 key.clone(),
425 GcraCell {
426 tat: new_tat.as_u64(),
427 last_touch_ms: now_ms,
428 },
429 );
430 Ok(outcome)
431 }
432 Err(e) => Err(e),
433 }
434 }
435 }
436}
437
438/// Housekeeping seam (parity with governor's own stores): exposes the
439/// live key count the eviction bound is asserted against, plus
440/// TAT-based retention for callers that want a wider-than-idle drop.
441impl ShrinkableKeyedStateStore<String> for GcraShardStore {
442 fn retain_recent(&self, drop_below: Nanos) {
443 for shard in &self.shards {
444 let mut guard = shard.lock().expect("rate limiter shard poisoned");
445 guard.cells.retain(|_, cell| cell.tat > drop_below.as_u64());
446 }
447 }
448
449 fn shrink_to_fit(&self) {
450 for shard in &self.shards {
451 let mut guard = shard.lock().expect("rate limiter shard poisoned");
452 guard.cells.shrink_to_fit();
453 }
454 }
455
456 fn len(&self) -> usize {
457 self.shards
458 .iter()
459 .map(|shard| {
460 shard
461 .lock()
462 .expect("rate limiter shard poisoned")
463 .cells
464 .len()
465 })
466 .sum()
467 }
468
469 fn is_empty(&self) -> bool {
470 self.len() == 0
471 }
472}
473
474/// One stacked GCRA window (DW-017): `requests` per `window` with a
475/// `burst`-token bucket, backed by its own sharded keyed state.
476struct GcraWindow {
477 limiter: GovernorLimiter<String, GcraShardStore, DefaultClock, StateInformationMiddleware>,
478 /// Dropped-cell counter shared with the window's store (the store is
479 /// owned by the governor limiter; this handle keeps it readable).
480 evictions: Arc<AtomicU64>,
481 /// Bucket size (governor's max_burst) — the `X-RateLimit-Limit` this
482 /// window reports when it is the binding constraint.
483 burst: NonZeroU32,
484 /// Full-bucket refill time (`burst_size_replenished_in`) — used when
485 /// a cost can never fit the bucket, and as the store's idle
486 /// threshold.
487 full_refill_ms: u64,
488}
489
490/// Result of one [`GcraRateLimiter::check`] across its stacked windows.
491#[derive(Debug, Clone, Copy, PartialEq, Eq)]
492pub struct GcraOutcome {
493 pub decision: RateDecision,
494 /// Bucket size of the binding window (denying window on denial, the
495 /// least-remaining window on success) — for `X-RateLimit-Limit`.
496 pub limit: u32,
497 /// Estimated milliseconds until the binding window is FULLY
498 /// replenished (on denial: until the next conforming retry) — the
499 /// basis of `X-RateLimit-Reset`.
500 pub refill_ms: u64,
501}
502
503/// One window specification for [`GcraRateLimiter::new`].
504#[derive(Debug, Clone, Copy, PartialEq, Eq)]
505pub struct GcraWindowSpec {
506 /// Sustained rate: `requests` per `window`.
507 pub requests: NonZeroU32,
508 pub window: Duration,
509 /// Bucket size; defaults to `requests` when `None`.
510 pub burst: Option<NonZeroU32>,
511}
512
513/// Sharded GCRA limiter over one or more stacked windows (DW-017).
514///
515/// Implements the [`RateLimiter`] trait: `check(key, cost)` reserves
516/// `cost` units in every stacked window for `key` (see the module docs
517/// for the stop-at-first-denial consumption semantics). Keys are opaque
518/// strings; the caller composes them (see [`RateLimitEngine`]).
519///
520/// **Clocks:** the GCRA math runs on governor's own quanta monotonic
521/// clock; there is deliberately no clock injection for it. The EVICTION
522/// bookkeeping (per-key last-touch stamps) has a separate, injectable
523/// millisecond clock: `new` uses elapsed monotonic time, tests use
524/// [`GcraRateLimiter::with_clock`] to advance deterministically. The two
525/// clocks only meet in the idle-eviction threshold (one full refill of
526/// the window's bucket), where both advance with wall time.
527pub struct GcraRateLimiter {
528 /// Shortest window first (see module docs: consumption on a stacked
529 /// denial falls on the fastest-replenishing buckets).
530 windows: Vec<GcraWindow>,
531}
532
533impl std::fmt::Debug for GcraRateLimiter {
534 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
535 f.debug_struct("GcraRateLimiter")
536 .field("windows", &self.windows.len())
537 .finish()
538 }
539}
540
541impl GcraRateLimiter {
542 /// New limiter over the given window specs (any order; internally
543 /// sorted shortest window first). Returns `None` for an empty spec
544 /// list — a limiter with no windows cannot make decisions.
545 pub fn new(specs: Vec<GcraWindowSpec>) -> Option<Self> {
546 Self::build(specs, StoreClock::Monotonic(Instant::now()))
547 }
548
549 /// New limiter using a caller-supplied millisecond clock for the
550 /// per-key eviction bookkeeping (NOT the GCRA decisions — see the
551 /// clocks note in the type docs).
552 ///
553 /// Intended for tests and other time-controlled setups; production
554 /// code should prefer [`GcraRateLimiter::new`]. `now_ms` must return
555 /// non-decreasing milliseconds and is called once per window per
556 /// `check` (cheap).
557 pub fn with_clock(specs: Vec<GcraWindowSpec>, now_ms: fn() -> u64) -> Option<Self> {
558 Self::build(specs, StoreClock::Injected(now_ms))
559 }
560
561 fn build(specs: Vec<GcraWindowSpec>, clock: StoreClock) -> Option<Self> {
562 if specs.is_empty() {
563 return None;
564 }
565 let mut specs = specs;
566 specs.sort_by_key(|s| s.window);
567 let windows = specs
568 .into_iter()
569 .map(|spec| {
570 let burst = spec.burst.unwrap_or(spec.requests);
571 // Replenish interval = window / requests; `with_period`
572 // returns None only for a zero period, which a non-zero
573 // request count over a non-zero window cannot produce
574 // (sub-nanosecond periods clamp to 1ns inside governor).
575 let quota = Quota::with_period(spec.window / spec.requests.get())
576 .unwrap_or_else(|| Quota::per_second(spec.requests))
577 .allow_burst(burst);
578 let full_refill_ms = quota.burst_size_replenished_in().as_millis() as u64;
579 let evictions = Arc::new(AtomicU64::new(0));
580 let store = GcraShardStore::new(full_refill_ms, clock, Arc::clone(&evictions));
581 // `new` leaves the middleware parameter uninferred; the
582 // raw limiter is NoOp until the snapshot middleware is
583 // layered on (the field's final type).
584 let raw: GovernorLimiter<
585 String,
586 GcraShardStore,
587 DefaultClock,
588 NoOpMiddleware<QuantaInstant>,
589 > = GovernorLimiter::new(quota, store, DefaultClock::default());
590 GcraWindow {
591 limiter: raw.with_middleware::<StateInformationMiddleware>(),
592 evictions,
593 burst,
594 full_refill_ms,
595 }
596 })
597 .collect();
598 Some(Self { windows })
599 }
600
601 /// Live per-key cell count across all stacked windows (sum of the
602 /// shard maps; approximate under concurrent checks). Bounded by
603 /// `GCRA_STORE_SHARDS *
604 /// crate::config::limits::MAX_RATE_LIMITER_KEYS_PER_SHARD` per
605 /// window — the bound the spray tests assert.
606 pub fn live_keys(&self) -> usize {
607 self.windows.iter().map(|w| w.limiter.len()).sum::<usize>()
608 }
609
610 /// Cells dropped by eviction sweeps across all stacked windows
611 /// (monotonic for the limiter's lifetime; a reload builds a fresh
612 /// limiter, so the counter resets with the generation).
613 pub fn evictions(&self) -> u64 {
614 self.windows
615 .iter()
616 .map(|w| w.evictions.load(Ordering::Relaxed))
617 .sum()
618 }
619
620 /// Check-and-reserve `cost` units for `key` across all stacked
621 /// windows (see module docs). `cost` 0 is treated as 1 (a request
622 /// always costs at least one unit); a cost larger than any window's
623 /// bucket is always denied.
624 pub fn check(&self, key: &str, cost: u32) -> GcraOutcome {
625 let cost = NonZeroU32::new(cost.max(1)).expect("cost.max(1) is non-zero");
626 let mut binding: Option<GcraOutcome> = None;
627 let key = key.to_string();
628 for w in &self.windows {
629 match w.limiter.check_key_n(&key, cost) {
630 // Cost can never fit this bucket: deny for a full refill.
631 Err(_) => {
632 return denied_outcome(w.burst.get(), w.full_refill_ms);
633 }
634 Ok(Err(not_until)) => {
635 let wait = not_until.wait_time_from(w.limiter.clock().now());
636 return denied_outcome(w.burst.get(), wait.as_millis() as u64);
637 }
638 Ok(Ok(snapshot)) => {
639 let remaining = snapshot.remaining_burst_capacity();
640 let refill = snapshot.quota().burst_size_replenished_in().as_millis() as u64;
641 let candidate = GcraOutcome {
642 decision: RateDecision {
643 allowed: true,
644 remaining: u64::from(remaining),
645 retry_after_ms: None,
646 },
647 limit: w.burst.get(),
648 refill_ms: refill,
649 };
650 if binding
651 .as_ref()
652 .is_none_or(|b| candidate.decision.remaining < b.decision.remaining)
653 {
654 binding = Some(candidate);
655 }
656 }
657 }
658 }
659 binding.expect("non-empty window list always yields a decision")
660 }
661}
662
663pub fn denied_outcome(limit: u32, retry_after_ms: u64) -> GcraOutcome {
664 GcraOutcome {
665 decision: RateDecision {
666 allowed: false,
667 remaining: 0,
668 retry_after_ms: Some(retry_after_ms),
669 },
670 limit,
671 refill_ms: retry_after_ms,
672 }
673}
674
675#[async_trait]
676impl RateLimiter for GcraRateLimiter {
677 async fn check(&self, key: &str, cost: u32) -> Result<RateDecision, ExtensionsError> {
678 Ok(self.check(key, cost).decision)
679 }
680}
681
682// --- policy engine (DW-017 wiring) --------------------------------------
683
684/// One compiled rate-limit rule: selector set plus its stacked windows.
685/// `dry_run` (DW-041) is the owning policy bundle's monitor flag: the
686/// rule still evaluates (its GCRA buckets advance exactly as if
687/// enforcing), but its denials are REPORTED to the caller instead of
688/// returned as the enforcement outcome.
689struct EngineRule {
690 selectors: Vec<crate::config::RateLimitSelector>,
691 limiter: EngineLimiter,
692 dry_run: bool,
693}
694
695/// The limiter backend for one compiled rule (DW-031): the local
696/// in-memory GCRA limiter (OSS default) or the Redis-backed GCRA
697/// limiter (ent feature, when configured and licensed). Both expose
698/// the same `check` shape returning a [`GcraOutcome`], so the engine
699/// can use either uniformly — the Redis variant is async (one
700/// round-trip per window), the local variant is sync (in-memory).
701enum EngineLimiter {
702 Local(GcraRateLimiter),
703 #[cfg(feature = "ent")]
704 Redis(Box<crate::extensions::redis_rate_limiter::RedisRateLimiter>),
705}
706
707impl EngineLimiter {
708 /// Check-and-reserve `cost` units for `key`. Delegates to the
709 /// local (sync) or Redis (async) backend. The Redis path is one
710 /// round-trip per stacked window; the local path is in-memory.
711 async fn check(&self, key: &str, cost: u32) -> GcraOutcome {
712 match self {
713 EngineLimiter::Local(l) => l.check(key, cost),
714 #[cfg(feature = "ent")]
715 EngineLimiter::Redis(l) => l.check(key, cost).await,
716 }
717 }
718
719 /// Live per-key cell count (local only; Redis manages its own keys
720 /// and reports 0 here — the gauge is a local-limiter metric).
721 fn live_keys(&self) -> usize {
722 match self {
723 EngineLimiter::Local(l) => l.live_keys(),
724 #[cfg(feature = "ent")]
725 EngineLimiter::Redis(_) => 0,
726 }
727 }
728
729 /// Cells dropped by eviction sweeps (local only; Redis manages its
730 /// own key expiry and reports 0 here).
731 fn evictions(&self) -> u64 {
732 match self {
733 EngineLimiter::Local(l) => l.evictions(),
734 #[cfg(feature = "ent")]
735 EngineLimiter::Redis(_) => 0,
736 }
737 }
738}
739
740/// The per-request attributes a rule key can be built from (DW-017).
741#[derive(Debug, Clone, Copy)]
742pub struct RateLimitKeyContext<'a> {
743 /// Direct connection peer (the `ip` selector; same IP as X-Real-IP).
744 pub peer: std::net::IpAddr,
745 /// Authenticated consumer name; `None` for anonymous traffic — the
746 /// `credential` selector then falls back to the peer IP.
747 pub consumer: Option<&'a str>,
748 /// Name of the matched route (the `route` selector); the empty
749 /// string on unrouted traffic (no route resolved — the selector then
750 /// keys one bucket shared by all unrouted requests of that policy).
751 pub route: &'a str,
752}
753
754/// What the engine decided for one request (DW-017).
755#[derive(Debug, Clone, Copy, PartialEq, Eq)]
756pub enum RateLimitOutcome {
757 /// No rule applied to the request: no limiting, and responses carry
758 /// no rate headers.
759 NotLimited,
760 /// Admitted; `limit`/`remaining`/`reset_epoch_s` describe the binding
761 /// constraint (the window with the least remaining budget).
762 Allowed {
763 limit: u32,
764 remaining: u32,
765 reset_epoch_s: u64,
766 },
767 /// Denied: emit 429 with `Retry-After` = `retry_after_s` (ceil, min 1)
768 /// and the binding (first-denying) rule's rate headers. When several
769 /// rules deny, `retry_after_s` is the MAX wait across them (see
770 /// [`RateLimitEngine::check`]); headers still show the first binding
771 /// rule — Limit/Remaining report the tightest constraint in
772 /// resolution order, Retry-After the longest wait.
773 Denied {
774 limit: u32,
775 remaining: u32,
776 reset_epoch_s: u64,
777 retry_after_s: u32,
778 },
779}
780
781/// Policy-resolution and key-building engine for request rate limiting
782/// (DW-017). Compiled once per config generation.
783///
784/// **Precedence chain** (frozen vocabulary: consumer, then route, then
785/// service, then listener, then global): rules from ALL applicable
786/// policies apply and are AND-ed; the resolution order is consumer
787/// (authenticated requests carry their consumer's policies), then route,
788/// then service, then listener (`listeners[].policies`, the listener
789/// that accepted the request), then global (`gateway.global_policies`).
790/// The order matters for the 429 HEADERS only (the first denying rule
791/// binds Limit/Remaining/Reset; see [`RateLimitEngine::check`]); every
792/// applicable rule still gates the request. On UNROUTED traffic
793/// (no route resolved) only the listener and global links apply —
794/// consumer, route, and service links are unknowable before routing,
795/// and the documented request-path order places authentication after
796/// route resolution.
797///
798/// Key building per rule: each selector contributes one component
799/// (`ip` = peer, `credential` = consumer or peer fallback, `route` =
800/// route name; the empty string for unrouted requests); components are
801/// joined with `|` into one key. Rules attached to the same policy
802/// share a limiter instance, so two rules with identical selectors and
803/// windows would double-count — validation does not reject it
804/// (harmless), operators just should not write it.
805pub struct RateLimitEngine {
806 /// (policy name, rule) in config order; resolution scans by name.
807 rules: Vec<(String, EngineRule)>,
808}
809
810impl std::fmt::Debug for RateLimitEngine {
811 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
812 f.debug_struct("RateLimitEngine")
813 .field("rules", &self.rules.len())
814 .finish()
815 }
816}
817
818fn window_specs(rp: &crate::config::RateRequestsPer, burst: Option<u32>) -> Vec<GcraWindowSpec> {
819 /// `window` seconds per `requests` (burst defaults to the request
820 /// count; see the module docs for the GCRA first-window shape).
821 fn spec(requests: u32, window: u64, burst: Option<u32>) -> GcraWindowSpec {
822 let requests = NonZeroU32::new(requests).expect("validation rejects 0");
823 let burst = burst.and_then(NonZeroU32::new).unwrap_or(requests);
824 GcraWindowSpec {
825 requests,
826 window: Duration::from_secs(window),
827 burst: Some(burst),
828 }
829 }
830 let mut specs = Vec::new();
831 if let Some(s) = rp.per_second.filter(|v| *v > 0) {
832 specs.push(spec(s, 1, burst));
833 }
834 if let Some(m) = rp.minute.filter(|v| *v > 0) {
835 specs.push(spec(m, 60, burst));
836 }
837 if let Some(h) = rp.hour.filter(|v| *v > 0) {
838 specs.push(spec(h, 3600, burst));
839 }
840 specs
841}
842
843impl RateLimitEngine {
844 /// Compile every policy's rate rules of a config generation. Policies
845 /// without rate rules contribute nothing (their timeouts and future
846 /// plugin bundles are other layers' concern).
847 pub fn compile(gateway: &crate::config::Gateway) -> Self {
848 let mut rules = Vec::new();
849 for policy in &gateway.policies {
850 // Legacy single-window field: selector [route], one window of
851 // `requests` per `window_seconds` (documented mapping; the
852 // field stays for schema stability within M1).
853 if let Some(rl) = &policy.rate_limit {
854 if rl.requests > 0 && rl.window_seconds > 0 {
855 let requests = NonZeroU32::new(u32::try_from(rl.requests).unwrap_or(u32::MAX))
856 .expect("validated > 0");
857 rules.push((
858 policy.name.clone(),
859 EngineRule {
860 selectors: vec![crate::config::RateLimitSelector::Route],
861 limiter: EngineLimiter::Local(
862 GcraRateLimiter::new(vec![GcraWindowSpec {
863 requests,
864 window: Duration::from_secs(rl.window_seconds),
865 burst: Some(requests),
866 }])
867 .expect("one window spec"),
868 ),
869 dry_run: policy.dry_run,
870 },
871 ));
872 }
873 }
874 for rule in &policy.rate_limits {
875 let specs = window_specs(&rule.requests_per, rule.burst);
876 let Some(limiter) = GcraRateLimiter::new(specs) else {
877 continue; // empty rule shapes are rejected by validation
878 };
879 rules.push((
880 policy.name.clone(),
881 EngineRule {
882 selectors: rule.selector.clone(),
883 limiter: EngineLimiter::Local(limiter),
884 dry_run: policy.dry_run,
885 },
886 ));
887 }
888 }
889 Self { rules }
890 }
891
892 /// Compile every policy's rate rules with Redis-backed limiters
893 /// (DW-031, ent feature only). Same rule compilation as
894 /// [`Self::compile`], but each rule's limiter is a
895 /// [`RedisRateLimiter`](crate::extensions::redis_rate_limiter::RedisRateLimiter)
896 /// sharing the provided connection (cloned cheaply per rule). The
897 /// config supplies the fail-open flag, key prefix, and key TTL.
898 /// Used when the `ent` feature is compiled in, the
899 /// `redis_rate_limiter` config block is present, and the license
900 /// grants the `redis_rate_limiter` feature claim.
901 #[cfg(feature = "ent")]
902 pub fn compile_with_redis(
903 gateway: &crate::config::Gateway,
904 conn: redis::aio::ConnectionManager,
905 config: &crate::config::RedisRateLimiterConfig,
906 ) -> Self {
907 use crate::extensions::redis_rate_limiter::RedisRateLimiter;
908
909 let mut rules = Vec::new();
910 for policy in &gateway.policies {
911 if let Some(rl) = &policy.rate_limit {
912 if rl.requests > 0 && rl.window_seconds > 0 {
913 let requests = NonZeroU32::new(u32::try_from(rl.requests).unwrap_or(u32::MAX))
914 .expect("validated > 0");
915 let specs = vec![GcraWindowSpec {
916 requests,
917 window: Duration::from_secs(rl.window_seconds),
918 burst: Some(requests),
919 }];
920 if let Some(limiter) =
921 RedisRateLimiter::from_config(conn.clone(), specs, config)
922 {
923 rules.push((
924 policy.name.clone(),
925 EngineRule {
926 selectors: vec![crate::config::RateLimitSelector::Route],
927 limiter: EngineLimiter::Redis(Box::new(limiter)),
928 dry_run: policy.dry_run,
929 },
930 ));
931 }
932 }
933 }
934 for rule in &policy.rate_limits {
935 let specs = window_specs(&rule.requests_per, rule.burst);
936 let Some(limiter) = RedisRateLimiter::from_config(conn.clone(), specs, config)
937 else {
938 continue;
939 };
940 rules.push((
941 policy.name.clone(),
942 EngineRule {
943 selectors: rule.selector.clone(),
944 limiter: EngineLimiter::Redis(Box::new(limiter)),
945 dry_run: policy.dry_run,
946 },
947 ));
948 }
949 }
950 Self { rules }
951 }
952
953 /// Whether any rule is compiled in at all (fast path: configs with no
954 /// rate-limit policies skip per-request key building entirely).
955 pub fn is_empty(&self) -> bool {
956 self.rules.is_empty()
957 }
958
959 /// Live per-key cell count across every compiled rule's windows
960 /// (approximate under concurrent checks) — the spray-bounded figure
961 /// for tests and ops visibility. See [`GcraRateLimiter::live_keys`].
962 pub fn live_keys(&self) -> usize {
963 self.rules
964 .iter()
965 .map(|(_, rule)| rule.limiter.live_keys())
966 .sum()
967 }
968
969 /// Cells dropped by eviction sweeps across every compiled rule
970 /// (monotonic per config generation). See [`GcraRateLimiter::evictions`].
971 pub fn evictions(&self) -> u64 {
972 self.rules
973 .iter()
974 .map(|(_, rule)| rule.limiter.evictions())
975 .sum()
976 }
977
978 /// Resolve the applicable rules for one request and check them.
979 /// The policy name lists are those attached to the authenticated
980 /// consumer (DW-019; empty for anonymous traffic), the matched
981 /// route, its service, the accepting listener, and the gateway
982 /// (global) — in that resolution order (the frozen precedence
983 /// chain). On unrouted traffic the caller passes empty
984 /// consumer/route/service lists (`ctx.route` is then the empty
985 /// string) and only the listener/global links resolve.
986 /// A policy attached at multiple levels (or repeated within one
987 /// list) is evaluated ONCE per request; its first — most
988 /// specific — chain position is the occurrence that binds the
989 /// 429 headers.
990 /// All applicable rules apply (AND); on success the reported
991 /// constraint is the tightest one (least remaining budget). On denial
992 /// the FIRST denying rule binds the Limit/Remaining/Reset headers,
993 /// but evaluation continues through the remaining applicable rules so
994 /// `retry_after_s` (and the matching Reset) reflect the MAXIMUM wait
995 /// any denying rule demands — a client honoring the hint never
996 /// retries into a second 429 with an understated Retry-After.
997 /// Dry-run bundles (DW-041, `policies[].dry_run`) never contribute
998 /// to this outcome — see [`RateLimitEngine::evaluate`], which this
999 /// delegates to.
1000 pub async fn check(
1001 &self,
1002 ctx: &RateLimitKeyContext<'_>,
1003 consumer_policies: &[String],
1004 route_policies: &[String],
1005 service_policies: &[String],
1006 listener_policies: &[String],
1007 global_policies: &[String],
1008 ) -> RateLimitOutcome {
1009 self.evaluate(
1010 ctx,
1011 consumer_policies,
1012 route_policies,
1013 service_policies,
1014 listener_policies,
1015 global_policies,
1016 |_| 1.0,
1017 )
1018 .await
1019 .outcome
1020 }
1021
1022 /// [`Self::check`] with the dry-run observation attached (DW-041):
1023 /// every applicable rule still EVALUATES (dry bundles' GCRA buckets
1024 /// advance exactly as if enforcing, so their denial rates reflect
1025 /// what enforcement would do), but the enforcement `outcome` is
1026 /// computed from LIVE rules only, while `dry_denied` carries the
1027 /// first (most specific) dry bundle's would-be denial for the caller
1028 /// to log and count. Dry bundles also contribute no
1029 /// `X-RateLimit-*` header values: a monitor never touches the
1030 /// response. A request can therefore be BOTH 429'd by a live rule
1031 /// and reported as a dry would-deny in the same evaluation.
1032 ///
1033 /// `adaptive_factor` (DW-089) maps a policy name to its current
1034 /// adaptive factor (1.0 = no tuning). The factor scales the
1035 /// per-request COST against the GCRA bucket: a factor < 1.0
1036 /// (tightened) increases the cost so the effective rate drops; a
1037 /// factor > 1.0 (relaxed) decreases the cost so the effective rate
1038 /// rises. The limiter is NOT rebuilt — the factor is applied at
1039 /// check time. Extensions may not import resilience, so the caller
1040 /// (the dataplane) supplies the factor lookup.
1041 #[allow(clippy::too_many_arguments)]
1042 pub async fn evaluate<F>(
1043 &self,
1044 ctx: &RateLimitKeyContext<'_>,
1045 consumer_policies: &[String],
1046 route_policies: &[String],
1047 service_policies: &[String],
1048 listener_policies: &[String],
1049 global_policies: &[String],
1050 adaptive_factor: F,
1051 ) -> RateLimitEvaluation
1052 where
1053 F: Fn(&str) -> f64,
1054 {
1055 // Resolution order (precedence): consumer > route > service >
1056 // listener > global. One policy is ONE evaluation: a name listed
1057 // at several levels (or twice in one list) resolves its rules
1058 // once, at its FIRST chain position, so a shared policy spends
1059 // its budget once per request and the most specific level's
1060 // occurrence is the one that binds when it denies. The attached
1061 // lists are tiny and config-bounded, so the dedup is an
1062 // allocation-free rescan of the earlier positions — cheaper
1063 // than any heap set on this per-request path.
1064 //
1065 // Header binding: the FIRST denying LIVE rule supplies Limit /
1066 // Remaining / Reset (the tightest constraint in resolution
1067 // order); Retry-After is the MAX wait across every denying live
1068 // rule, so a client honoring it never retries into a second 429
1069 // with an understated hint. Allowed outcomes are irrelevant
1070 // once a live rule has denied (the response is a 429
1071 // regardless). Dry denials (DW-041) run through the same loop
1072 // into their own accumulator: the first one is the report, the
1073 // rest only stretch its Retry-After for the log line.
1074 let attached = [
1075 consumer_policies,
1076 route_policies,
1077 service_policies,
1078 listener_policies,
1079 global_policies,
1080 ];
1081 let attached_names = || attached.iter().flat_map(|l| l.iter());
1082 let mut acc: Option<RateLimitOutcome> = None;
1083 let mut denied: Option<RateLimitOutcome> = None;
1084 let mut dry_denied: Option<RateLimitOutcome> = None;
1085 for (pos, name) in attached_names().enumerate() {
1086 if attached_names().take(pos).any(|prev| prev == name) {
1087 // Already resolved at a more specific position.
1088 continue;
1089 }
1090 for (policy_name, rule) in &self.rules {
1091 if policy_name != name {
1092 continue;
1093 }
1094 let key = build_key(ctx, &rule.selectors);
1095 // DW-089: scale the per-request cost by the adaptive
1096 // factor. A factor < 1.0 (tightened) increases the cost
1097 // so the effective rate drops; a factor > 1.0 (relaxed)
1098 // decreases the cost so the effective rate rises. The
1099 // cost is never 0 (a request always costs at least one
1100 // unit). The limiter is NOT rebuilt — the factor is
1101 // applied at check time.
1102 let factor = adaptive_factor(policy_name);
1103 let cost = if factor > 0.0 {
1104 (1.0 / factor).round().max(1.0) as u32
1105 } else {
1106 1
1107 };
1108 match rate_outcome(rule.limiter.check(&key, cost).await) {
1109 RateLimitOutcome::Denied {
1110 limit,
1111 remaining,
1112 reset_epoch_s,
1113 retry_after_s,
1114 } => {
1115 // Dry bundles report instead of enforce (DW-041);
1116 // the accumulator is the same first-binds /
1117 // later-stretches shape as the live one.
1118 let sink = if rule.dry_run {
1119 &mut dry_denied
1120 } else {
1121 &mut denied
1122 };
1123 match sink.as_mut() {
1124 // First denial binds the headers (live) or
1125 // the report (dry).
1126 None => {
1127 *sink = Some(RateLimitOutcome::Denied {
1128 limit,
1129 remaining,
1130 reset_epoch_s,
1131 retry_after_s,
1132 });
1133 }
1134 // Later denials only stretch Retry-After (and
1135 // the matching Reset) when they wait longer.
1136 Some(RateLimitOutcome::Denied {
1137 retry_after_s: max_ra,
1138 reset_epoch_s: max_rs,
1139 ..
1140 }) => {
1141 if retry_after_s > *max_ra {
1142 *max_ra = retry_after_s;
1143 *max_rs = reset_epoch_s;
1144 }
1145 }
1146 // The sinks only ever hold Denied variants.
1147 Some(_) => unreachable!("denial sinks only hold Denied"),
1148 }
1149 }
1150 next @ RateLimitOutcome::Allowed { remaining, .. } => {
1151 // Dry bundles never contribute headers: their
1152 // allowed outcomes are dropped entirely.
1153 if denied.is_some() || rule.dry_run {
1154 continue;
1155 }
1156 // The tightest constraint (least remaining
1157 // budget) is the one the headers report.
1158 let keep_prev = matches!(
1159 acc,
1160 Some(RateLimitOutcome::Allowed { remaining: prev, .. })
1161 if remaining >= prev
1162 );
1163 if !keep_prev {
1164 acc = Some(next);
1165 }
1166 }
1167 RateLimitOutcome::NotLimited => {}
1168 }
1169 }
1170 }
1171 RateLimitEvaluation {
1172 outcome: denied.unwrap_or(acc.unwrap_or(RateLimitOutcome::NotLimited)),
1173 dry_denied,
1174 }
1175 }
1176}
1177
1178/// The full rate-limit decision for one request (DW-041): the LIVE
1179/// enforcement outcome (exactly the pre-dry-run `check` semantics over
1180/// live bundles) plus the first dry bundle's would-be denial, if any
1181/// evaluated to one.
1182#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1183pub struct RateLimitEvaluation {
1184 pub outcome: RateLimitOutcome,
1185 /// The first (most specific) `dry_run` bundle that would have denied
1186 /// this request: a [`RateLimitOutcome::Denied`] payload for the log
1187 /// line and metric — never enforced, never on the response.
1188 pub dry_denied: Option<RateLimitOutcome>,
1189}
1190
1191fn rate_outcome(result: GcraOutcome) -> RateLimitOutcome {
1192 let now_epoch = std::time::SystemTime::now()
1193 .duration_since(std::time::UNIX_EPOCH)
1194 .map(|d| d.as_secs())
1195 .unwrap_or(0);
1196 if !result.decision.allowed {
1197 let retry_ms = result.decision.retry_after_ms.unwrap_or(0);
1198 RateLimitOutcome::Denied {
1199 limit: result.limit,
1200 remaining: result.decision.remaining as u32,
1201 reset_epoch_s: now_epoch + retry_ms.div_ceil(1000),
1202 retry_after_s: u32::try_from(retry_ms.div_ceil(1000)).unwrap_or(1).max(1),
1203 }
1204 } else {
1205 RateLimitOutcome::Allowed {
1206 limit: result.limit,
1207 remaining: result.decision.remaining as u32,
1208 reset_epoch_s: now_epoch + result.refill_ms.div_ceil(1000),
1209 }
1210 }
1211}
1212
1213fn build_key(
1214 ctx: &RateLimitKeyContext<'_>,
1215 selectors: &[crate::config::RateLimitSelector],
1216) -> String {
1217 let mut key = String::new();
1218 for s in selectors {
1219 if !key.is_empty() {
1220 key.push('|');
1221 }
1222 match s {
1223 crate::config::RateLimitSelector::Ip => key.push_str(&ctx.peer.to_string()),
1224 // Falls back to the peer IP until DW-019 identifies consumers:
1225 // anonymous traffic then limits per client rather than sharing
1226 // one global "anonymous" bucket (documented choice).
1227 crate::config::RateLimitSelector::Credential => {
1228 key.push_str(ctx.consumer.unwrap_or(&ctx.peer.to_string()))
1229 }
1230 crate::config::RateLimitSelector::Route => key.push_str(ctx.route),
1231 }
1232 }
1233 key
1234}