Skip to main content

ai_crew_sync/
ratelimit.rs

1//! Per-token request rate limiting for the MCP surface.
2//!
3//! A token bucket per presented credential, keyed on the token's hash so a
4//! flood of *invalid* tokens is rejected without a database round-trip too.
5//!
6//! **This limiter is per process.** The bus is deliberately stateless and
7//! horizontally scalable, so with N replicas the effective ceiling is N times
8//! the configured rate. That is the honest trade: a shared limiter would need
9//! shared state on every request, which contradicts "one binary, one database".
10//! When a hard global ceiling matters, put it in the reverse proxy — this
11//! limiter is the in-process backstop that keeps one runaway agent from
12//! saturating the instance it is talking to.
13
14use std::{
15    collections::HashMap,
16    sync::{Arc, Mutex},
17    time::{Duration, Instant},
18};
19
20/// Entries idle for longer than this are dropped, so the map tracks active
21/// callers rather than every token ever presented.
22const IDLE_EVICTION: Duration = Duration::from_secs(600);
23
24/// How often a request triggers a sweep of idle entries. Cheap amortisation:
25/// no background task, no timer, no extra thread.
26const SWEEP_EVERY: usize = 512;
27
28/// Hard ceiling on tracked callers. Without it, a flood of distinct (likely
29/// invalid) tokens grows the map until the eviction window catches up —
30/// turning the component that exists to bound abuse into a way to exhaust
31/// memory.
32const MAX_TRACKED: usize = 10_000;
33
34struct Bucket {
35    tokens: f64,
36    last: Instant,
37}
38
39struct Inner {
40    buckets: HashMap<String, Bucket>,
41    since_sweep: usize,
42}
43
44/// Token bucket rate limiter. Cloning shares the same state.
45#[derive(Clone)]
46pub struct RateLimiter {
47    inner: Arc<Mutex<Inner>>,
48    /// Sustained rate, requests per second.
49    refill_per_sec: f64,
50    /// Bucket capacity: how large a burst is tolerated before throttling.
51    burst: f64,
52}
53
54/// How long a throttled caller should wait, for the `Retry-After` header.
55pub struct Throttled {
56    pub retry_after_secs: u64,
57}
58
59impl RateLimiter {
60    /// `per_minute` of 0 disables limiting entirely (`new` returns `None`).
61    pub fn new(per_minute: u32) -> Option<Self> {
62        if per_minute == 0 {
63            return None;
64        }
65        // A burst of a tenth of the minute's budget (at least 10) absorbs the
66        // bursty shape of agent traffic — a session start fires whoami,
67        // team_digest and read_messages back to back — without letting a loop
68        // sustain more than the configured rate.
69        let burst = ((per_minute as f64) / 10.0).max(10.0);
70        Some(Self {
71            inner: Arc::new(Mutex::new(Inner {
72                buckets: HashMap::new(),
73                since_sweep: 0,
74            })),
75            refill_per_sec: per_minute as f64 / 60.0,
76            burst,
77        })
78    }
79
80    /// Charge one request against `key`. `Err(Throttled)` means reject.
81    pub fn check(&self, key: &str) -> Result<(), Throttled> {
82        let now = Instant::now();
83        let mut inner = match self.inner.lock() {
84            Ok(guard) => guard,
85            // A poisoned lock means another thread panicked while holding it.
86            // Failing open is the right call: the limiter is a backstop, not
87            // an authorization decision.
88            Err(poisoned) => poisoned.into_inner(),
89        };
90
91        inner.since_sweep += 1;
92        if inner.since_sweep >= SWEEP_EVERY {
93            inner.since_sweep = 0;
94            inner
95                .buckets
96                .retain(|_, b| now.duration_since(b.last) < IDLE_EVICTION);
97        }
98
99        let burst = self.burst;
100        let refill = self.refill_per_sec;
101        // Borrowed lookup first: an existing bucket is the common case, and
102        // allocating a key for it every request is pure churn.
103        let bucket = if let Some(existing) = inner.buckets.get_mut(key) {
104            existing
105        } else {
106            if inner.buckets.len() >= MAX_TRACKED {
107                inner
108                    .buckets
109                    .retain(|_, b| now.duration_since(b.last) < IDLE_EVICTION);
110            }
111            if inner.buckets.len() >= MAX_TRACKED {
112                tracing::warn!(
113                    tracked = inner.buckets.len(),
114                    "rate limiter is at its tracking ceiling; rejecting unknown tokens"
115                );
116                return Err(Throttled {
117                    retry_after_secs: 60,
118                });
119            }
120            inner.buckets.entry(key.to_owned()).or_insert(Bucket {
121                tokens: burst,
122                last: now,
123            })
124        };
125
126        let elapsed = now.duration_since(bucket.last).as_secs_f64();
127        bucket.tokens = (bucket.tokens + elapsed * refill).min(burst);
128        bucket.last = now;
129
130        if bucket.tokens >= 1.0 {
131            bucket.tokens -= 1.0;
132            return Ok(());
133        }
134
135        // Time until one whole token is available again.
136        let deficit = 1.0 - bucket.tokens;
137        Err(Throttled {
138            retry_after_secs: (deficit / refill).ceil().max(1.0) as u64,
139        })
140    }
141}
142
143#[cfg(test)]
144mod tests {
145    use super::*;
146
147    #[test]
148    fn disabled_when_zero() {
149        assert!(RateLimiter::new(0).is_none());
150    }
151
152    #[test]
153    fn allows_a_burst_then_throttles() {
154        // 60/min = 1/s sustained, burst 10.
155        let rl = RateLimiter::new(60).expect("enabled");
156        for i in 0..10 {
157            assert!(rl.check("k").is_ok(), "burst request {i} should pass");
158        }
159        let throttled = rl.check("k");
160        assert!(
161            throttled.is_err(),
162            "the 11th immediate request is throttled"
163        );
164        assert!(throttled.expect_err("throttled").retry_after_secs >= 1);
165    }
166
167    #[test]
168    fn keys_are_independent() {
169        let rl = RateLimiter::new(60).expect("enabled");
170        for _ in 0..10 {
171            let _ = rl.check("a");
172        }
173        assert!(
174            rl.check("b").is_ok(),
175            "one caller's budget must not affect another's"
176        );
177    }
178
179    #[test]
180    fn tracking_is_bounded() {
181        let rl = RateLimiter::new(6000).expect("enabled");
182        for i in 0..(MAX_TRACKED + 500) {
183            let _ = rl.check(&format!("token-{i}"));
184        }
185        let tracked = rl.inner.lock().expect("lock").buckets.len();
186        assert!(
187            tracked <= MAX_TRACKED,
188            "tracked {tracked}, cap {MAX_TRACKED}"
189        );
190    }
191
192    #[test]
193    fn refills_over_time() {
194        let rl = RateLimiter::new(600).expect("enabled"); // 10/s, burst 60
195        for _ in 0..60 {
196            let _ = rl.check("k");
197        }
198        assert!(rl.check("k").is_err(), "bucket is drained");
199        std::thread::sleep(Duration::from_millis(250));
200        assert!(
201            rl.check("k").is_ok(),
202            "a quarter second refills 2.5 tokens at 10/s"
203        );
204    }
205}