cratestack_axum/ratelimit/budget.rs
1//! The deployment-tier knobs for cratestack#871's keyspace bound, and the
2//! throttled `WARN`s the bound emits.
3
4use std::time::Duration;
5
6pub(super) mod warn;
7
8/// Caps on how many distinct rate-limit buckets one scope may create,
9/// applied by [`super::RateLimitLayer`]'s default key derivation.
10///
11/// # Where the defaults come from
12///
13/// `max_distinct_per_peer = 128` is sized so that no realistic *legitimate*
14/// peer reaches it: a single NAT egress (or IPv6 /64) serving 128
15/// simultaneously-active distinct credentials within one scope lifetime is
16/// already an unusual deployment, and one that should be configuring this rather than
17/// inheriting it. An attacker, by contrast, needs one bucket per request
18/// to amplify — so 128 is three orders of magnitude below what the attack
19/// needs while still above what real traffic uses.
20///
21/// `max_distinct_global = 8192` applies only when there is no verified
22/// peer address at all (no `ConnectInfo`), where every caller shares one
23/// scope. It is deliberately far larger, because collateral there hits
24/// *unrelated* callers: the global scope degrades to a single loud
25/// overflow bucket only when the deployment is both misconfigured and
26/// under attack.
27///
28/// `window` (default 60s) is a **floor** on how long one admitted
29/// credential holds its slot, not a fixed period that resets the scope.
30/// The store raises it to at least the buckets' own TTL
31/// (`cratestack_core::scope_ttl_secs`), because a record that expired
32/// first bounded nothing — the next generation re-admitted `max_distinct`
33/// more while the previous one was still alive, for a real steady state of
34/// `max_distinct × ceil(bucket_ttl / window)` (cratestack#871 review,
35/// blocker 2). Slots expire individually and are refreshed on use, so an
36/// active credential keeps its slot while a rotated-away one releases it.
37#[derive(Debug, Clone, Copy, PartialEq, Eq)]
38pub struct RateLimitBucketBudget {
39 pub max_distinct_per_peer: u32,
40 pub max_distinct_global: u32,
41 pub window: Duration,
42}
43
44impl RateLimitBucketBudget {
45 pub const DEFAULT_MAX_DISTINCT_PER_PEER: u32 = 128;
46 pub const DEFAULT_MAX_DISTINCT_GLOBAL: u32 = 8192;
47 pub const DEFAULT_WINDOW: Duration = Duration::from_secs(60);
48
49 pub fn new(max_distinct_per_peer: u32, max_distinct_global: u32, window: Duration) -> Self {
50 Self {
51 max_distinct_per_peer,
52 max_distinct_global,
53 window,
54 }
55 }
56
57 pub fn max_distinct_per_peer(mut self, max: u32) -> Self {
58 self.max_distinct_per_peer = max;
59 self
60 }
61
62 pub fn max_distinct_global(mut self, max: u32) -> Self {
63 self.max_distinct_global = max;
64 self
65 }
66
67 pub fn window(mut self, window: Duration) -> Self {
68 self.window = window;
69 self
70 }
71}
72
73impl Default for RateLimitBucketBudget {
74 fn default() -> Self {
75 Self {
76 max_distinct_per_peer: Self::DEFAULT_MAX_DISTINCT_PER_PEER,
77 max_distinct_global: Self::DEFAULT_MAX_DISTINCT_GLOBAL,
78 window: Self::DEFAULT_WINDOW,
79 }
80 }
81}