Skip to main content

car_engine/
rate_limit.rs

1//! Token bucket rate limiter for tool calls with backpressure support.
2
3use std::collections::HashMap;
4use tokio::sync::Mutex;
5use tokio::time::Instant;
6
7/// Per-tool rate limit configuration.
8#[derive(Debug, Clone)]
9pub struct RateLimit {
10    pub max_calls: u32,
11    pub interval_secs: f64,
12}
13
14/// Token bucket rate limiter for tool calls.
15///
16/// Each tool can have an independent rate limit. When a tool's bucket is empty,
17/// `acquire()` blocks until a token becomes available (backpressure).
18pub struct RateLimiter {
19    limits: Mutex<HashMap<String, RateLimit>>,
20    buckets: Mutex<HashMap<String, TokenBucket>>,
21}
22
23struct TokenBucket {
24    tokens: f64,
25    max_tokens: f64,
26    refill_rate: f64, // tokens per second
27    last_refill: Instant,
28}
29
30impl TokenBucket {
31    fn new(max_tokens: f64, refill_rate: f64) -> Self {
32        Self {
33            tokens: max_tokens,
34            max_tokens,
35            refill_rate,
36            last_refill: Instant::now(),
37        }
38    }
39
40    /// Refill tokens based on elapsed time since last refill.
41    fn refill(&mut self) {
42        let now = Instant::now();
43        let elapsed = now.duration_since(self.last_refill).as_secs_f64();
44        self.tokens = (self.tokens + elapsed * self.refill_rate).min(self.max_tokens);
45        self.last_refill = now;
46    }
47
48    /// Try to consume one token. Returns true if successful.
49    fn try_consume(&mut self) -> bool {
50        self.refill();
51        if self.tokens >= 1.0 {
52            self.tokens -= 1.0;
53            true
54        } else {
55            false
56        }
57    }
58
59    /// Seconds until one token is available (0.0 if already available).
60    fn time_until_available(&mut self) -> f64 {
61        self.refill();
62        if self.tokens >= 1.0 {
63            return 0.0;
64        }
65        let deficit = 1.0 - self.tokens;
66        deficit / self.refill_rate
67    }
68}
69
70impl RateLimiter {
71    /// Create an empty rate limiter with no limits configured.
72    pub fn new() -> Self {
73        Self {
74            limits: Mutex::new(HashMap::new()),
75            buckets: Mutex::new(HashMap::new()),
76        }
77    }
78
79    /// Configure a rate limit for a specific tool.
80    ///
81    /// `max_calls` tokens over `interval_secs` seconds. The refill rate is
82    /// `max_calls / interval_secs` tokens per second.
83    pub async fn set_limit(&self, tool: &str, limit: RateLimit) {
84        let max_tokens = limit.max_calls as f64;
85        let refill_rate = max_tokens / limit.interval_secs;
86
87        self.limits.lock().await.insert(tool.to_string(), limit);
88
89        self.buckets
90            .lock()
91            .await
92            .insert(tool.to_string(), TokenBucket::new(max_tokens, refill_rate));
93    }
94
95    /// Configure a rate limit from a NON-async context. Returns false if
96    /// either lock was held and no limit was installed.
97    ///
98    /// Exists for the synchronous `Runtime::with_*` builders, which cannot
99    /// `.await` [`Self::set_limit`] but must still install a tool's declared
100    /// rate limit — a limit the builder skipped would leave the tool
101    /// unbounded, which is the opposite of what declaring one meant. The locks
102    /// are uncontended during construction, so `try_lock` succeeds.
103    pub fn try_set_limit(&self, tool: &str, limit: RateLimit) -> bool {
104        let max_tokens = limit.max_calls as f64;
105        let refill_rate = max_tokens / limit.interval_secs;
106
107        let (Ok(mut limits), Ok(mut buckets)) = (self.limits.try_lock(), self.buckets.try_lock())
108        else {
109            return false;
110        };
111        limits.insert(tool.to_string(), limit);
112        buckets.insert(tool.to_string(), TokenBucket::new(max_tokens, refill_rate));
113        true
114    }
115
116    /// Wait until a token is available for the given tool, then consume it.
117    ///
118    /// If no rate limit is configured for the tool, returns immediately.
119    /// This provides backpressure: callers block until capacity is available.
120    pub async fn acquire(&self, tool: &str) {
121        loop {
122            let wait_time = {
123                let mut buckets = self.buckets.lock().await;
124                let bucket = match buckets.get_mut(tool) {
125                    Some(b) => b,
126                    None => return, // no limit configured
127                };
128
129                if bucket.try_consume() {
130                    return;
131                }
132
133                bucket.time_until_available()
134            };
135
136            // Sleep outside the lock to allow other tasks to proceed.
137            tokio::time::sleep(std::time::Duration::from_secs_f64(wait_time)).await;
138        }
139    }
140
141    /// Non-blocking attempt to acquire a token for the given tool.
142    ///
143    /// Returns `true` if a token was consumed, `false` if the bucket is empty.
144    /// Returns `true` if no rate limit is configured for the tool.
145    pub async fn try_acquire(&self, tool: &str) -> bool {
146        let mut buckets = self.buckets.lock().await;
147        match buckets.get_mut(tool) {
148            Some(bucket) => bucket.try_consume(),
149            None => true, // no limit configured
150        }
151    }
152}
153
154impl Default for RateLimiter {
155    fn default() -> Self {
156        Self::new()
157    }
158}
159
160#[cfg(test)]
161mod tests {
162    use super::*;
163
164    #[tokio::test(start_paused = true)]
165    async fn test_token_bucket_refills_correctly() {
166        let limiter = RateLimiter::new();
167        limiter
168            .set_limit(
169                "tool_a",
170                RateLimit {
171                    max_calls: 2,
172                    interval_secs: 1.0,
173                },
174            )
175            .await;
176
177        // Consume both tokens.
178        assert!(limiter.try_acquire("tool_a").await);
179        assert!(limiter.try_acquire("tool_a").await);
180        // Bucket is empty.
181        assert!(!limiter.try_acquire("tool_a").await);
182
183        // Advancing 0.5s refills exactly one token at 2 tokens/sec.
184        tokio::time::advance(std::time::Duration::from_millis(500)).await;
185        assert!(limiter.try_acquire("tool_a").await);
186        assert!(!limiter.try_acquire("tool_a").await);
187    }
188
189    #[tokio::test(start_paused = true)]
190    async fn test_acquire_blocks_when_empty() {
191        let limiter = Arc::new(RateLimiter::new());
192        limiter
193            .set_limit(
194                "tool_b",
195                RateLimit {
196                    max_calls: 1,
197                    interval_secs: 0.2,
198                },
199            )
200            .await;
201
202        // Drain the single token.
203        assert!(limiter.try_acquire("tool_b").await);
204        assert!(!limiter.try_acquire("tool_b").await);
205
206        // acquire() remains pending until virtual time reaches the refill.
207        let waiting = {
208            let limiter = limiter.clone();
209            tokio::spawn(async move { limiter.acquire("tool_b").await })
210        };
211        tokio::task::yield_now().await;
212        assert!(
213            !waiting.is_finished(),
214            "empty bucket must block acquisition"
215        );
216        tokio::time::advance(std::time::Duration::from_millis(200)).await;
217        waiting
218            .await
219            .expect("acquire task must complete after refill");
220    }
221
222    #[tokio::test]
223    async fn test_independent_tool_limits() {
224        let limiter = RateLimiter::new();
225        limiter
226            .set_limit(
227                "fast",
228                RateLimit {
229                    max_calls: 10,
230                    interval_secs: 1.0,
231                },
232            )
233            .await;
234        limiter
235            .set_limit(
236                "slow",
237                RateLimit {
238                    max_calls: 1,
239                    interval_secs: 1.0,
240                },
241            )
242            .await;
243
244        // Drain the slow bucket.
245        assert!(limiter.try_acquire("slow").await);
246        assert!(!limiter.try_acquire("slow").await);
247
248        // fast bucket should still have tokens.
249        for _ in 0..10 {
250            assert!(limiter.try_acquire("fast").await);
251        }
252        assert!(!limiter.try_acquire("fast").await);
253    }
254
255    #[tokio::test]
256    async fn test_no_limit_always_passes() {
257        let limiter = RateLimiter::new();
258        // No limit set for "unconfigured".
259        assert!(limiter.try_acquire("unconfigured").await);
260        limiter.acquire("unconfigured").await; // should return immediately
261    }
262
263    use std::sync::Arc;
264
265    #[tokio::test(start_paused = true)]
266    async fn test_max_tokens_cap() {
267        let limiter = RateLimiter::new();
268        limiter
269            .set_limit(
270                "capped",
271                RateLimit {
272                    max_calls: 2,
273                    interval_secs: 1.0,
274                },
275            )
276            .await;
277
278        // Drain the initial bucket, then advance through many refill intervals.
279        assert!(limiter.try_acquire("capped").await);
280        assert!(limiter.try_acquire("capped").await);
281        assert!(!limiter.try_acquire("capped").await);
282        tokio::time::advance(std::time::Duration::from_secs(10)).await;
283
284        // Refill remains capped at exactly two tokens.
285        assert!(limiter.try_acquire("capped").await);
286        assert!(limiter.try_acquire("capped").await);
287        assert!(!limiter.try_acquire("capped").await);
288    }
289}