Skip to main content

dig_rpc/middleware/
rate_limit.rs

1//! Per-(peer, tier) token-bucket rate limiting.
2//!
3//! The server keeps a `HashMap<(PeerKey, Tier), Bucket>` where
4//! `Bucket = { tokens: f64, last_refill: Instant }`. Every request debits one
5//! token; refills are lazy (computed at check time from `fill_per_sec *
6//! elapsed`). A request that cannot debit yields [`RateLimitOutcome::Deny`],
7//! which the transport turns into an `ErrorCode::ServerError` response with a
8//! `Retry-After` hint.
9//!
10//! Buckets are keyed by [`Tier`] so the three surfaces get independent budgets:
11//! generous public reads, moderate peer traffic, tight control.
12
13use std::collections::HashMap;
14use std::time::Instant;
15
16use dig_rpc_protocol::Tier;
17use parking_lot::Mutex;
18
19/// Opaque per-peer identifier used as a hash key. Callers use
20/// `SHA-256(cert SPKI)` for mTLS peers, or a hashed `IP:port` for public
21/// callers — the limiter only hashes the bytes.
22pub type PeerKey = Vec<u8>;
23
24/// Per-bucket configuration.
25#[derive(Debug, Clone, Copy)]
26pub struct BucketSpec {
27    /// Tokens added per second.
28    pub fill_per_sec: f64,
29    /// Maximum tokens the bucket can hold (burst allowance).
30    pub capacity: f64,
31}
32
33/// Full rate-limit configuration: one [`BucketSpec`] per [`Tier`].
34#[derive(Debug, Clone)]
35pub struct RateLimitConfig {
36    /// Per-tier bucket specs.
37    pub buckets: HashMap<Tier, BucketSpec>,
38}
39
40impl RateLimitConfig {
41    /// Sane defaults: generous public reads, moderate peer, tight control.
42    pub fn defaults() -> Self {
43        let mut buckets = HashMap::new();
44        buckets.insert(
45            Tier::PublicRead,
46            BucketSpec {
47                fill_per_sec: 50.0,
48                capacity: 100.0,
49            },
50        );
51        buckets.insert(
52            Tier::Peer,
53            BucketSpec {
54                fill_per_sec: 20.0,
55                capacity: 40.0,
56            },
57        );
58        buckets.insert(
59            Tier::Control,
60            BucketSpec {
61                fill_per_sec: 5.0,
62                capacity: 10.0,
63            },
64        );
65        Self { buckets }
66    }
67}
68
69impl Default for RateLimitConfig {
70    fn default() -> Self {
71        Self::defaults()
72    }
73}
74
75/// Outcome of a rate-limit check.
76#[derive(Debug, Clone, Copy, PartialEq, Eq)]
77pub enum RateLimitOutcome {
78    /// Within budget; one token was debited.
79    Allow,
80    /// Denied; the bucket refills in approximately `retry_after_secs` seconds.
81    Deny {
82        /// Suggested retry delay in whole seconds (minimum 1).
83        retry_after_secs: u64,
84    },
85}
86
87/// Mutable per-peer rate-limit state (cheap clone — `Arc` internally).
88#[derive(Debug, Clone)]
89pub struct RateLimitState {
90    inner: std::sync::Arc<Mutex<HashMap<(PeerKey, Tier), Bucket>>>,
91    config: std::sync::Arc<RateLimitConfig>,
92}
93
94#[derive(Debug)]
95struct Bucket {
96    tokens: f64,
97    last_refill: Instant,
98}
99
100impl RateLimitState {
101    /// Construct fresh state with the given config.
102    pub fn new(config: RateLimitConfig) -> Self {
103        Self {
104            inner: std::sync::Arc::new(Mutex::new(HashMap::new())),
105            config: std::sync::Arc::new(config),
106        }
107    }
108
109    /// Attempt to debit one token from the `(peer, tier)` bucket.
110    pub fn check(&self, peer: &PeerKey, tier: Tier) -> RateLimitOutcome {
111        let Some(spec) = self.config.buckets.get(&tier).copied() else {
112            // Unconfigured tier → fail open (log so the gap is visible).
113            tracing::warn!(?tier, "rate tier not configured; allowing");
114            return RateLimitOutcome::Allow;
115        };
116
117        let mut g = self.inner.lock();
118        let now = Instant::now();
119        let b = g.entry((peer.clone(), tier)).or_insert(Bucket {
120            tokens: spec.capacity,
121            last_refill: now,
122        });
123        let elapsed = now.duration_since(b.last_refill).as_secs_f64();
124        b.tokens = (b.tokens + spec.fill_per_sec * elapsed).min(spec.capacity);
125        b.last_refill = now;
126
127        if b.tokens >= 1.0 {
128            b.tokens -= 1.0;
129            RateLimitOutcome::Allow
130        } else {
131            let deficit = 1.0 - b.tokens;
132            let wait_s = (deficit / spec.fill_per_sec).ceil() as u64;
133            RateLimitOutcome::Deny {
134                retry_after_secs: wait_s.max(1),
135            }
136        }
137    }
138}
139
140#[cfg(test)]
141mod tests {
142    use super::*;
143
144    /// **Proves:** a fresh bucket allows the first request (starts full).
145    /// **Catches:** a regression initialising `tokens: 0.0`.
146    #[test]
147    fn first_request_allowed() {
148        let s = RateLimitState::new(RateLimitConfig::defaults());
149        assert_eq!(
150            s.check(&vec![0; 32], Tier::PublicRead),
151            RateLimitOutcome::Allow
152        );
153    }
154
155    /// **Proves:** calling faster than the fill rate exhausts the bucket and
156    /// denies with a non-zero retry.
157    /// **Catches:** a no-op limiter (tokens never decrement).
158    #[test]
159    fn exhaust_bucket_denies() {
160        let mut buckets = HashMap::new();
161        buckets.insert(
162            Tier::Control,
163            BucketSpec {
164                fill_per_sec: 1.0,
165                capacity: 3.0,
166            },
167        );
168        let s = RateLimitState::new(RateLimitConfig { buckets });
169        for _ in 0..3 {
170            assert_eq!(
171                s.check(&vec![0; 32], Tier::Control),
172                RateLimitOutcome::Allow
173            );
174        }
175        match s.check(&vec![0; 32], Tier::Control) {
176            RateLimitOutcome::Deny { retry_after_secs } => assert!(retry_after_secs >= 1),
177            _ => panic!("expected Deny"),
178        }
179    }
180
181    /// **Proves:** budgets are per-peer — one peer exhausting its bucket does
182    /// not starve another.
183    /// **Catches:** a key that drops the peer (a global counter).
184    #[test]
185    fn buckets_are_per_peer() {
186        let mut buckets = HashMap::new();
187        buckets.insert(
188            Tier::Peer,
189            BucketSpec {
190                fill_per_sec: 1.0,
191                capacity: 2.0,
192            },
193        );
194        let s = RateLimitState::new(RateLimitConfig { buckets });
195        let a = vec![0xAA; 32];
196        let b = vec![0xBB; 32];
197        for _ in 0..2 {
198            assert_eq!(s.check(&a, Tier::Peer), RateLimitOutcome::Allow);
199        }
200        assert!(matches!(
201            s.check(&a, Tier::Peer),
202            RateLimitOutcome::Deny { .. }
203        ));
204        assert_eq!(s.check(&b, Tier::Peer), RateLimitOutcome::Allow);
205    }
206
207    /// **Proves:** budgets are per-tier — exhausting Control does not affect
208    /// PublicRead for the same peer.
209    #[test]
210    fn buckets_are_per_tier() {
211        let mut buckets = HashMap::new();
212        buckets.insert(
213            Tier::Control,
214            BucketSpec {
215                fill_per_sec: 1.0,
216                capacity: 1.0,
217            },
218        );
219        buckets.insert(
220            Tier::PublicRead,
221            BucketSpec {
222                fill_per_sec: 1.0,
223                capacity: 1.0,
224            },
225        );
226        let s = RateLimitState::new(RateLimitConfig { buckets });
227        let p = vec![1; 32];
228        assert_eq!(s.check(&p, Tier::Control), RateLimitOutcome::Allow);
229        assert!(matches!(
230            s.check(&p, Tier::Control),
231            RateLimitOutcome::Deny { .. }
232        ));
233        // PublicRead for the same peer is untouched.
234        assert_eq!(s.check(&p, Tier::PublicRead), RateLimitOutcome::Allow);
235    }
236
237    /// **Proves:** an unconfigured tier fails open (allows) rather than bricking
238    /// the surface.
239    #[test]
240    fn unconfigured_tier_allows() {
241        let s = RateLimitState::new(RateLimitConfig {
242            buckets: HashMap::new(),
243        });
244        assert_eq!(
245            s.check(&vec![0; 32], Tier::PublicRead),
246            RateLimitOutcome::Allow
247        );
248    }
249}