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;