Skip to main content

decdn_node/
rate_limit.rs

1//! Shared three-layer token-bucket rate limiter for per-request inbound
2//! traffic on authenticated QUIC handlers.
3//!
4//! This is the engine behind both the `cdn/dht/v1` limiter (ADR 022 §DHT
5//! Rate Limiting, via [`crate::dht::rate_limit::DhtRateLimiter`]) and the
6//! `cdn/probe/v1` limiter (ADR 005 §Probe rate limiting, via
7//! [`crate::handlers::probe_rate_limit::ProbeRateLimiter`]). The two ADRs
8//! specify the identical three-layer shape — the differences are the default
9//! parameters, the operator-visible metric names, and that the DHT path
10//! additionally drives the engine's batch-token accounting
11//! ([`ThreeLayerRateLimiter::admit_batch_extra`]) while the probe path issues
12//! exactly one [`ThreeLayerRateLimiter::check`] per connection. So the
13//! mechanism lives here once and each path supplies its own
14//! [`RateLimitMetricsSink`].
15//!
16//! Distinct from the connection-level [`crate::dispatch::ConnectionLimiter`]:
17//! that limiter caps how many connections we accept; this one caps the
18//! per-request work done on each accepted connection (for DHT, many requests
19//! per connection; the probe path is one probe per connection). Both run — the
20//! connection limiter rejects floods at handshake, this layer rejects
21//! per-request floods on a successfully accepted connection.
22//!
23//! # Layered checks
24//!
25//! Layers fire **global → per-IP → per-peer** (cheapest-first ordering). A
26//! request rejected by the global cap never pays the per-IP map lookup; a
27//! per-IP rejection never pays the per-peer map lookup. The first rejection
28//! short-circuits and is the only one reported to the sink's
29//! [`RateLimitMetricsSink::rejected`] — one unlabeled Counter per layer, per
30//! the sibling-counter convention settled in #1475 (see [`RejectLayer`] below
31//! and `adr/appendix-observability.md` § Reason splits). **Not** a backend
32//! limitation: `iroh_metrics` supports labels via `Family<L, M>`, which
33//! `DecdnMetrics::probe_hold_unavailable` and `DecdnMetrics::streams_active`
34//! both use. Each layer has an unrelated remedy, so no alert spans the family.
35//! Operators recover the rolled-up rate with
36//! `sum(rate({__name__=~"decdn_(probe|dht)_rate_limit_rejected_(per_peer|per_ip|global)_total"}[1m]))`.
37
38use std::net::IpAddr;
39use std::num::NonZeroU32;
40use std::sync::Arc;
41use std::sync::atomic::{AtomicBool, Ordering};
42use std::time::Duration;
43
44use governor::{DefaultDirectRateLimiter, DefaultKeyedRateLimiter, Quota};
45use iroh::TransportAddr;
46use iroh::endpoint::Connection;
47
48use crate::dht::routing::NodeId;
49use crate::dispatch::source_key;
50use crate::prune_guard::PruneGuard;
51
52/// Resolved three-layer rate-limit configuration.
53///
54/// A layer is disabled (its `Option<_>` limiter is `None`) when its quota is
55/// not buildable — see `make_quota`: that is `*_rate_per_sec <= 0.0`, a
56/// non-finite rate, **or** `*_burst == 0`. The intended operator opt-out sets
57/// **both** the rate to `0.0` and the burst to `0`; the config resolver
58/// (`resolve_{dht,probe}_into`) rejects the asymmetric `rate > 0, burst == 0`
59/// combination up front, so in production a layer is only ever disabled via the
60/// both-zero opt-out. A `RateLimitConfig` built directly (bypassing the
61/// resolver) with `rate > 0, burst == 0` will silently disable that layer
62/// rather than error — construct via the `From<&Resolved{Dht,Probe}>` impls or
63/// the resolver to keep that invariant.
64///
65/// [`Default`] returns the ADR 022 DHT-layer values (the historical default of
66/// this struct). The probe path supplies its own ADR 005 defaults through the
67/// config resolver and never relies on `Default` here.
68#[derive(Debug, Clone)]
69pub struct RateLimitConfig {
70    /// Per-peer (source `NodeId`) sustained rate (requests/second).
71    pub per_peer_rate_per_sec: f64,
72    /// Per-peer burst capacity.
73    pub per_peer_burst: u32,
74    /// Per-IP sustained rate (requests/second).
75    pub per_ip_rate_per_sec: f64,
76    /// Per-IP burst capacity.
77    pub per_ip_burst: u32,
78    /// Global inbound sustained rate (requests/second).
79    pub global_rate_per_sec: f64,
80    /// Global inbound burst capacity.
81    pub global_burst: u32,
82    /// Hard cap on the per-IP keyed-limiter map (#645). `0` => unbounded
83    /// (operator opt-in, the resolver warns).
84    pub max_tracked_per_ip: usize,
85    /// Hard cap on the per-peer keyed-limiter map (#645). `0` => unbounded.
86    pub max_tracked_per_peer: usize,
87}
88
89impl Default for RateLimitConfig {
90    fn default() -> Self {
91        Self {
92            per_peer_rate_per_sec: 20.0,
93            per_peer_burst: 40,
94            per_ip_rate_per_sec: 100.0,
95            per_ip_burst: 200,
96            global_rate_per_sec: 1000.0,
97            global_burst: 2000,
98            max_tracked_per_ip: 4096,
99            max_tracked_per_peer: 4096,
100        }
101    }
102}
103
104// The resolved DHT/probe config (in `decdn-common`) and this engine config
105// hold the identical eight fields, but `common` can't depend on `node`, so the
106// engine config lives here. These `From` impls are the single mapping point
107// the runtime uses to build a limiter from resolved config, so a field added
108// on one side has exactly one place to be threaded through on the other.
109//
110// Three failure modes, three different guards (#1457):
111//
112// - **Arity** — the compiler, but from two places, not one. The literals below
113//   are exhaustive (no `..Default::default()`), which covers three of the four
114//   quadrants: a field added to or dropped from `RateLimitConfig` fails here,
115//   and so does a field dropped from a `Resolved*`. A field *added* to a
116//   `Resolved*` does not — reading `r.field` carries no exhaustiveness
117//   requirement, so these impls simply never mention it. That quadrant is
118//   caught by the exhaustive `Resolved*` literals in the two tests below
119//   (and by each type's own `Default` impl over in `common`), which is why
120//   those fixtures must never be relaxed to `..Default::default()`.
121// - **Transposition** — the `*_mapping_is_not_transposed` tests below. The
122//   compiler cannot help: all eight fields are `f64`/`u32`/`usize`, so
123//   swapping `per_peer_burst` with `per_ip_burst` still compiles. The tests
124//   feed eight pairwise-distinct sentinels through each impl and pin every
125//   landing slot. Review-by-diff cannot substitute — the two `Self { .. }`
126//   literals are byte-identical, so a swap in one reads as plausible next to
127//   the other.
128// - **Wiring** — `{Probe,Dht}RateLimiter::from_resolved`, which take the
129//   `Resolved*` type rather than the mapped config. Both limiter configs are
130//   aliases of `RateLimitConfig`, so a limiter built from the *other*
131//   protocol's section type-checks fine; going through `from_resolved` at the
132//   wiring sites makes that pairing a type error instead.
133//
134// One more duplication sits outside the mapping itself:
135// `RateLimitConfig::default()` above restates `ResolvedDht::default()`
136// value-for-value across a crate boundary, with no shared constant.
137// `dht_default_matches_engine_default` below is the only thing holding those
138// two in step.
139impl From<&decdn_common::config::ResolvedDht> for RateLimitConfig {
140    fn from(r: &decdn_common::config::ResolvedDht) -> Self {
141        Self {
142            per_peer_rate_per_sec: r.per_peer_rate_per_sec,
143            per_peer_burst: r.per_peer_burst,
144            per_ip_rate_per_sec: r.per_ip_rate_per_sec,
145            per_ip_burst: r.per_ip_burst,
146            global_rate_per_sec: r.global_rate_per_sec,
147            global_burst: r.global_burst,
148            max_tracked_per_ip: r.max_tracked_per_ip,
149            max_tracked_per_peer: r.max_tracked_per_peer,
150        }
151    }
152}
153
154impl From<&decdn_common::config::ResolvedProbe> for RateLimitConfig {
155    fn from(r: &decdn_common::config::ResolvedProbe) -> Self {
156        Self {
157            per_peer_rate_per_sec: r.per_peer_rate_per_sec,
158            per_peer_burst: r.per_peer_burst,
159            per_ip_rate_per_sec: r.per_ip_rate_per_sec,
160            per_ip_burst: r.per_ip_burst,
161            global_rate_per_sec: r.global_rate_per_sec,
162            global_burst: r.global_burst,
163            max_tracked_per_ip: r.max_tracked_per_ip,
164            max_tracked_per_peer: r.max_tracked_per_peer,
165        }
166    }
167}
168
169/// Layer that triggered a rejection. Used as a log/trace field and to pick
170/// the right per-layer Counter via [`RateLimitMetricsSink::rejected`].
171///
172/// A **sibling-counter** split, per the convention settled in #1475 — one
173/// unlabeled counter per layer (`decdn_probe_rate_limit_rejected_{per_peer,
174/// per_ip,global}_total` on the probe path, and the matching
175/// `decdn_dht_rate_limit_rejected_*` trio on the DHT path — this enum is shared
176/// by both), not a `layer` label. The labeled
177/// `decdn_probe_hold_unavailable_total` is the one deliberate reason-split
178/// exception.
179#[derive(Debug, Clone, Copy, PartialEq, Eq)]
180pub enum RejectLayer {
181    /// Per-peer (`NodeId`) bucket exhausted.
182    PerPeer,
183    /// Per-IP bucket exhausted.
184    PerIp,
185    /// Global cap exhausted.
186    Global,
187}
188
189impl RejectLayer {
190    /// Label string for log/metric fields. Stable across releases.
191    #[must_use]
192    pub const fn as_str(self) -> &'static str {
193        match self {
194            Self::PerPeer => "per_peer",
195            Self::PerIp => "per_ip",
196            Self::Global => "global",
197        }
198    }
199}
200
201/// Metrics fan-out for a [`ThreeLayerRateLimiter`]. Each protocol path
202/// (`cdn/dht/v1`, `cdn/probe/v1`) supplies an implementation that routes to
203/// its own operator-visible counters/gauges, so the limiter core stays
204/// backend-agnostic. Implementations must be cheap and lock-free on the hot
205/// `rejected` path.
206pub trait RateLimitMetricsSink: Send + Sync {
207    /// Record a request rejected at `layer` (the cheapest-first layer that
208    /// drained the budget). Called at most once per rejected request.
209    fn rejected(&self, layer: RejectLayer);
210    /// Record a `retain_recent` sweep of the per-IP keyed-limiter map (#645).
211    fn prune_sweep_per_ip(&self);
212    /// Record a `retain_recent` sweep of the per-peer keyed-limiter map (#645).
213    fn prune_sweep_per_peer(&self);
214    /// Publish the per-IP keyed-limiter map size after a prune (#645).
215    fn set_tracked_per_ip(&self, n: usize);
216    /// Publish the per-peer keyed-limiter map size after a prune (#645).
217    fn set_tracked_per_peer(&self, n: usize);
218}
219
220/// Three-layer rate limiter for inbound authenticated per-request traffic.
221///
222/// Methods are `&self` — concurrent admission decisions don't take a write
223/// lock. Each layer uses its own `governor` limiter and the
224/// `Result<(), RejectLayer>` short-circuits at the first rejection.
225///
226/// **Keyspace bound (#645).** The two keyed layers carry per-layer
227/// `cap_per_{ip,peer}` and `pruning_per_{ip,peer}` fields. The flags are
228/// deliberately split (not one shared) so a slow per-IP `retain_recent` sweep
229/// does not block a concurrent per-peer sweep — and vice versa.
230#[allow(missing_debug_implementations)]
231pub struct ThreeLayerRateLimiter {
232    /// `None` when the layer is disabled. Built once at construction; not
233    /// hot-reloadable (operators restart to change it). To make it
234    /// reloadable, follow the `ConnectionLimiter` pattern of `ArcSwap` on
235    /// the inner limiter.
236    global: Option<Arc<DefaultDirectRateLimiter>>,
237    /// Keyed by source IP. `None` when disabled.
238    per_ip: Option<Arc<DefaultKeyedRateLimiter<IpAddr>>>,
239    /// Keyed by source `NodeId`. `None` when disabled.
240    per_peer: Option<Arc<DefaultKeyedRateLimiter<NodeId>>>,
241    /// Hard cap on the per-IP keyed-limiter map (#645). `0` => unbounded
242    /// (no lazy prune from `check`; periodic `gc_per_ip` still runs but
243    /// only releases buckets that have refilled to their baseline).
244    cap_per_ip: usize,
245    /// Hard cap on the per-peer keyed-limiter map (#645).
246    cap_per_peer: usize,
247    /// Single-flight guard for `retain_recent` on the per-IP keyed map.
248    pruning_per_ip: AtomicBool,
249    /// Single-flight guard for `retain_recent` on the per-peer keyed map.
250    pruning_per_peer: AtomicBool,
251    metrics: Arc<dyn RateLimitMetricsSink>,
252}
253
254impl ThreeLayerRateLimiter {
255    /// Build a limiter from the resolved config snapshot and a metrics sink.
256    #[must_use]
257    pub fn new(cfg: &RateLimitConfig, metrics: Arc<dyn RateLimitMetricsSink>) -> Self {
258        Self {
259            global: build_direct_limiter(cfg.global_rate_per_sec, cfg.global_burst),
260            per_ip: build_keyed_limiter(cfg.per_ip_rate_per_sec, cfg.per_ip_burst),
261            per_peer: build_keyed_limiter(cfg.per_peer_rate_per_sec, cfg.per_peer_burst),
262            cap_per_ip: cfg.max_tracked_per_ip,
263            cap_per_peer: cfg.max_tracked_per_peer,
264            pruning_per_ip: AtomicBool::new(false),
265            pruning_per_peer: AtomicBool::new(false),
266            metrics,
267        }
268    }
269
270    /// Try to admit one inbound request. `peer_ip == None` for relay-only
271    /// connections — the per-IP layer is skipped (no key to charge), matching
272    /// the `ConnectionLimiter` precedent at
273    /// [`crate::dispatch::ConnectionLimiter::acquire`].
274    ///
275    /// On rejection, calls [`RateLimitMetricsSink::rejected`] with the layer
276    /// that fired and returns it. No sink call on the success path.
277    ///
278    /// Cheapest-first ordering (global → per-IP → per-peer): the first layer
279    /// to reject short-circuits, so a per-peer-flooded request that would also
280    /// have failed the global cap counts as `global` (the layer that actually
281    /// drained the budget).
282    pub fn check(&self, peer_node_id: &NodeId, peer_ip: Option<IpAddr>) -> Result<(), RejectLayer> {
283        // Charge one unit cheapest-first; report the layer that drained the
284        // budget to the sink. The accounting itself lives in `try_admit_one`
285        // so the batch stage-2 path (`admit_batch_extra`) can reuse it without
286        // the metric bump — a partial-admit boundary is not a request
287        // rejection. `prune = true`: a fresh inbound frame may have inserted a
288        // new keyed-map entry, so run the opportunistic keyspace prune.
289        match self.try_admit_one(peer_node_id, peer_ip, true) {
290            Ok(()) => Ok(()),
291            Err(layer) => {
292                self.metrics.rejected(layer);
293                Err(layer)
294            }
295        }
296    }
297
298    /// Charge exactly one unit from each enabled layer, cheapest-first
299    /// (global → per-IP → per-peer) with short-circuit on the first exhausted
300    /// layer. Does **not** touch any metric — callers decide whether a failure
301    /// is a rejection ([`Self::check`]) or a partial-admit boundary
302    /// ([`Self::admit_batch_extra`]).
303    ///
304    /// `prune` gates the opportunistic keyspace prune (#645). Stage-1
305    /// [`Self::check`] passes `true`; the stage-2 batch loop passes `false`
306    /// because every extra unit it charges keys on the *same* `(peer, ip)`
307    /// stage-1 already inserted — so it can never grow the keyed maps, making a
308    /// per-unit prune check pure overhead (the keyspace bound is upheld by
309    /// stage-1 + the periodic GC sweep).
310    fn try_admit_one(
311        &self,
312        peer_node_id: &NodeId,
313        peer_ip: Option<IpAddr>,
314        prune: bool,
315    ) -> Result<(), RejectLayer> {
316        // Layer 1 — global.
317        if let Some(g) = self.global.as_ref()
318            && g.check().is_err()
319        {
320            return Err(RejectLayer::Global);
321        }
322
323        // Layer 2 — per-IP. Skipped for relay-only connections. The key is
324        // masked to its /64 prefix for IPv6 (#841) — the same mask the dispatch
325        // limiter applies — so an attacker rotating within one IPv6 allocation
326        // can't mint a fresh bucket per request.
327        if let (Some(ip), Some(limiter)) = (peer_ip, self.per_ip.as_ref()) {
328            let ip = source_key(ip);
329            let result = limiter.check_key(&ip);
330            if prune {
331                self.maybe_prune_per_ip(limiter);
332            }
333            if result.is_err() {
334                return Err(RejectLayer::PerIp);
335            }
336        }
337
338        // Layer 3 — per-peer (NodeId).
339        if let Some(limiter) = self.per_peer.as_ref() {
340            let result = limiter.check_key(peer_node_id);
341            if prune {
342                self.maybe_prune_per_peer(limiter);
343            }
344            if result.is_err() {
345                return Err(RejectLayer::PerPeer);
346            }
347        }
348
349        Ok(())
350    }
351
352    /// Stage 2 of the two-stage batch admission (ADR 022 §Batch token
353    /// accounting). After [`Self::check`] has charged the stage-1 frame token,
354    /// this consumes up to `extra` (= `n - 1` for a batch of `n` hashes) more
355    /// units cheapest-first, each unit identical to what one separate
356    /// `StoreRequest` admission would charge — so the per-second work ceiling
357    /// is the same whether the publisher sends `n` separate `Store`s or one
358    /// `BatchStore` of size `n` (ADR 022 AC 17).
359    ///
360    /// Returns the number of extra units granted; the first `1 + returned`
361    /// hashes of the batch pass through per-hash processing, the remainder are
362    /// acked `false`. Returns `0` when `extra == 0` or the budget is already
363    /// exhausted.
364    ///
365    /// No rejection counter is bumped: the inbound frame was already admitted
366    /// at stage 1, and the over-budget tail is deferred work the publisher
367    /// retries, not a rejected request. Because the per-unit charge
368    /// short-circuits cheapest-first, the terminating unit may consume a global
369    /// (and per-IP) token without a per-peer token — byte-for-byte what the
370    /// `(k+1)`-th separate `Store` would have done.
371    #[must_use]
372    pub fn admit_batch_extra(
373        &self,
374        peer_node_id: &NodeId,
375        peer_ip: Option<IpAddr>,
376        extra: usize,
377    ) -> usize {
378        let mut granted = 0usize;
379        while granted < extra {
380            // `prune = false`: see `try_admit_one` — stage-2 units never add
381            // keyed-map entries, so the prune check is redundant here.
382            if self.try_admit_one(peer_node_id, peer_ip, false).is_err() {
383                break;
384            }
385            granted = granted.saturating_add(1);
386        }
387        granted
388    }
389
390    /// Opportunistic prune of the per-IP keyed map when it exceeds
391    /// `cap + cap/10`. Single-flighted via `pruning_per_ip`; a contended
392    /// observer skips and the next over-cap observer picks up the work once the
393    /// prior sweep releases the guard. Hot-path cost on the no-flood path: one
394    /// `usize` compare, one relaxed CAS.
395    fn maybe_prune_per_ip(&self, limiter: &Arc<DefaultKeyedRateLimiter<IpAddr>>) {
396        if self.cap_per_ip > 0
397            && limiter.len() > self.cap_per_ip.saturating_add(self.cap_per_ip / 10)
398            && self
399                .pruning_per_ip
400                .compare_exchange(false, true, Ordering::Acquire, Ordering::Relaxed)
401                .is_ok()
402        {
403            let _guard = PruneGuard(&self.pruning_per_ip);
404            limiter.retain_recent();
405            self.metrics.prune_sweep_per_ip();
406            self.metrics.set_tracked_per_ip(limiter.len());
407        }
408    }
409
410    fn maybe_prune_per_peer(&self, limiter: &Arc<DefaultKeyedRateLimiter<NodeId>>) {
411        if self.cap_per_peer > 0
412            && limiter.len() > self.cap_per_peer.saturating_add(self.cap_per_peer / 10)
413            && self
414                .pruning_per_peer
415                .compare_exchange(false, true, Ordering::Acquire, Ordering::Relaxed)
416                .is_ok()
417        {
418            let _guard = PruneGuard(&self.pruning_per_peer);
419            limiter.retain_recent();
420            self.metrics.prune_sweep_per_peer();
421            self.metrics.set_tracked_per_peer(limiter.len());
422        }
423    }
424
425    /// Periodic GC sweep for the per-IP keyed map (#645). Returns
426    /// `Some((before, after))` on a sweep that actually ran, or `None` when the
427    /// per-IP layer is disabled or the single-flight CAS was lost to a
428    /// concurrent prune (lazy or another GC tick). Mirrors
429    /// [`crate::dispatch::ConnectionLimiter::gc_per_source`].
430    #[must_use]
431    pub fn gc_per_ip(&self) -> Option<(usize, usize)> {
432        // Borrow, don't clone: `len`/`retain_recent` take `&self`, and the
433        // `pruning_per_ip`/`metrics` accesses below touch disjoint fields, so a
434        // shared borrow of `self.per_ip` is sound (matches `maybe_prune_per_ip`).
435        let limiter = self.per_ip.as_ref()?;
436        if self
437            .pruning_per_ip
438            .compare_exchange(false, true, Ordering::Acquire, Ordering::Relaxed)
439            .is_ok()
440        {
441            let _guard = PruneGuard(&self.pruning_per_ip);
442            let before = limiter.len();
443            limiter.retain_recent();
444            let after = limiter.len();
445            self.metrics.prune_sweep_per_ip();
446            self.metrics.set_tracked_per_ip(after);
447            Some((before, after))
448        } else {
449            None
450        }
451    }
452
453    /// Periodic GC sweep for the per-peer keyed map (#645). Sibling of
454    /// [`Self::gc_per_ip`].
455    #[must_use]
456    pub fn gc_per_peer(&self) -> Option<(usize, usize)> {
457        // Borrow, don't clone (see `gc_per_ip`).
458        let limiter = self.per_peer.as_ref()?;
459        if self
460            .pruning_per_peer
461            .compare_exchange(false, true, Ordering::Acquire, Ordering::Relaxed)
462            .is_ok()
463        {
464            let _guard = PruneGuard(&self.pruning_per_peer);
465            let before = limiter.len();
466            limiter.retain_recent();
467            let after = limiter.len();
468            self.metrics.prune_sweep_per_peer();
469            self.metrics.set_tracked_per_peer(after);
470            Some((before, after))
471        } else {
472            None
473        }
474    }
475
476    /// Current tracked-key count for the per-IP keyed map (#645). `0` when the
477    /// layer is disabled.
478    #[must_use]
479    pub fn per_ip_tracked(&self) -> usize {
480        self.per_ip.as_ref().map_or(0, |l| l.len())
481    }
482
483    /// Current tracked-key count for the per-peer keyed map (#645).
484    #[must_use]
485    pub fn per_peer_tracked(&self) -> usize {
486        self.per_peer.as_ref().map_or(0, |l| l.len())
487    }
488}
489
490/// Extract the remote IP from a connection's currently-selected network path.
491///
492/// Returns `None` for relay-only connections that have no direct IP path.
493/// Path selection may not have completed at the moment `accept` returns;
494/// without the fallback to *any* IP path, the per-IP/per-source limits would
495/// silently no-op on freshly-accepted connections and an attacker churning
496/// identities could bypass the layer in that race window.
497///
498/// Shared by [`crate::dispatch`], the DHT handler, and the probe handler so
499/// "the connection's source IP" means one thing across every rate-limit layer.
500pub(crate) fn peer_ip(conn: &Connection) -> Option<IpAddr> {
501    // `paths()` returns a snapshot of the *currently open* paths (closed paths
502    // are not retained), so no explicit open-path filter is needed.
503    let paths = conn.paths();
504    paths
505        .iter()
506        .find(iroh::endpoint::Path::is_selected)
507        .and_then(|p| match p.remote_addr() {
508            TransportAddr::Ip(addr) => Some(addr.ip()),
509            _ => None,
510        })
511        .or_else(|| {
512            paths.iter().find_map(|p| match p.remote_addr() {
513                TransportAddr::Ip(addr) => Some(addr.ip()),
514                _ => None,
515            })
516        })
517}
518
519/// Build a non-keyed (global) governor limiter from a (rate, burst) pair, or
520/// `None` when the layer is disabled. Mirrors the private `build_keyed_limiter`
521/// helper in [`crate::dispatch`] — same `0.0` / non-finite-rate / zero-burst
522/// guards.
523fn build_direct_limiter(rate_per_sec: f64, burst: u32) -> Option<Arc<DefaultDirectRateLimiter>> {
524    let quota = make_quota(rate_per_sec, burst)?;
525    Some(Arc::new(governor::RateLimiter::direct(quota)))
526}
527
528fn build_keyed_limiter<K>(rate_per_sec: f64, burst: u32) -> Option<Arc<DefaultKeyedRateLimiter<K>>>
529where
530    K: std::hash::Hash + Eq + Clone + Send + Sync + 'static,
531{
532    let quota = make_quota(rate_per_sec, burst)?;
533    Some(Arc::new(DefaultKeyedRateLimiter::keyed(quota)))
534}
535
536fn make_quota(rate_per_sec: f64, burst: u32) -> Option<Quota> {
537    if rate_per_sec <= 0.0 || burst == 0 || !rate_per_sec.is_finite() {
538        return None;
539    }
540    let period_secs = 1.0_f64 / rate_per_sec;
541    // Saturate to at least 1ns: a positive but extremely high rate would
542    // otherwise round to 0ns and `Quota::with_period(0)` returns None.
543    #[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
544    let period_ns = (period_secs * 1_000_000_000.0).max(1.0) as u64;
545    let period = Duration::from_nanos(period_ns);
546    let burst_nz = NonZeroU32::new(burst)?;
547    Some(Quota::with_period(period)?.allow_burst(burst_nz))
548}
549
550#[cfg(test)]
551#[allow(
552    clippy::unwrap_used,
553    clippy::expect_used,
554    clippy::panic,
555    // float_cmp: the mapped `f64` slots are copied literals, never computed, so
556    // exact equality is the property being pinned — an epsilon compare here
557    // would be strictly laxer than the mapping guarantee.
558    clippy::float_cmp
559)]
560mod tests;