Skip to main content

vtcode_core/tools/resilience/
rate_limiter.rs

1//! Simple in‑process rate limiter for tool execution.
2//!
3//! This is a lightweight fallback used when the full `governor` crate is not
4//! available in the build. It limits the number of tool calls per second
5//! globally for the process. The limiter is deliberately cheap – a `Mutex`
6//! protecting a counter and a timestamp – because the surrounding code already
7//! performs async work, so contention is minimal.
8//!
9//! The implementation is intentionally minimalistic: it provides a `RateLimiter`
10//! struct with a `try_acquire()` method that returns `Ok(())` when the call is
11//! allowed or an `Err` when the limit would be exceeded. Callers can decide how
12//! to handle the error (e.g., retry after a delay or surface a user‑friendly
13//! message).
14//!
15//! Usage example:
16//! ```rust,ignore
17//! use vtcode_core::tools::rate_limiter::GLOBAL_RATE_LIMITER;
18//!
19//! fn execute_tool() -> anyhow::Result<()> {
20//!     GLOBAL_RATE_LIMITER.try_acquire()?;
21//!     // … actual tool logic …
22//!     Ok(())
23//! }
24//! ```
25//!
26//! The limiter is configured via environment variables to keep the core
27//! library free of additional runtime configuration files:
28//!
29//! * `VTTOOL_RATE_LIMIT` – maximum calls per second (default = 20).
30//! * `VTTOOL_BURST` – maximum burst size (default = rate limit, min 5).
31//!
32//! The values are read once at startup.
33//!
34//! This file is added as part of the optimization plan to provide a central
35//! rate‑limiting mechanism for all external tool invocations (PTY, web fetch,
36//! filesystem, etc.).
37
38use anyhow::{Result, anyhow};
39use std::sync::{Mutex, MutexGuard};
40use std::time::Instant;
41
42/// Configuration for the limiter.
43#[derive(Debug, Clone, Copy)]
44pub struct RateLimiterConfig {
45    /// Allowed calls per second.
46    pub per_sec: u32,
47    /// Maximum burst capacity.
48    pub burst: u32,
49}
50
51impl Default for RateLimiterConfig {
52    fn default() -> Self {
53        // Environment variables are optional; fall back to sensible defaults.
54        let per_sec = std::env::var("VTTOOL_RATE_LIMIT")
55            .ok()
56            .and_then(|v| v.parse().ok())
57            .unwrap_or(20);
58        let burst = std::env::var("VTTOOL_BURST")
59            .ok()
60            .and_then(|v| v.parse().ok())
61            .unwrap_or(per_sec.max(5));
62        RateLimiterConfig { per_sec, burst }
63    }
64}
65
66/// Simple token‑bucket implementation.
67pub struct RateLimiterInner {
68    config: RateLimiterConfig,
69    /// Current number of available tokens.
70    tokens: u32,
71    /// When the bucket was last refilled.
72    last_refill: Instant,
73}
74
75impl RateLimiterInner {
76    fn new() -> Self {
77        Self::new_with_config(RateLimiterConfig::default())
78    }
79
80    pub fn new_with_config(config: RateLimiterConfig) -> Self {
81        Self {
82            config,
83            tokens: config.burst,
84            last_refill: Instant::now(),
85        }
86    }
87
88    /// Refill tokens based on elapsed time.
89    /// Uses fractional refill based on milliseconds for smoother rate limiting.
90    /// `speed_multiplier` allows adaptive rate limiting (faster refill for high priority).
91    fn refill(&mut self, speed_multiplier: f64) {
92        let now = Instant::now();
93        let elapsed = now.duration_since(self.last_refill);
94
95        // Calculate refill based on milliseconds for finer granularity
96        let millis = elapsed.as_millis() as u64;
97
98        // Minimum refill interval of 50ms to avoid excessive overhead
99        if millis < 50 {
100            return;
101        }
102
103        // Fractional refill: (per_sec * multiplier) tokens per 1000ms
104        #[allow(
105            clippy::cast_sign_loss,
106            reason = "Intentional compatibility, platform, or test-only suppression."
107        )]
108        let effective_rate = ((self.config.per_sec as f64 * speed_multiplier).max(0.0)) as u64;
109
110        // Using integer math: tokens = (effective_rate * millis) / 1000
111        let added = u32::try_from(effective_rate.saturating_mul(millis) / 1000).unwrap_or(u32::MAX);
112
113        if added > 0 {
114            let effective_burst = self.config.burst as f64 * speed_multiplier.max(1.0);
115            #[allow(
116                clippy::cast_sign_loss,
117                reason = "Intentional compatibility, platform, or test-only suppression."
118            )]
119            let effective_burst: u32 =
120                if effective_burst.is_finite() && effective_burst >= 0.0 && effective_burst <= u32::MAX as f64 {
121                    effective_burst as u32
122                } else {
123                    u32::MAX
124                };
125            self.tokens = self.tokens.saturating_add(added).min(effective_burst);
126            self.last_refill = now;
127        }
128    }
129
130    /// Attempt to acquire a single token with default speed (1.0).
131    pub fn try_acquire(&mut self) -> Result<()> {
132        self.try_acquire_scaled(1.0)
133    }
134
135    /// Attempt to acquire a single token with a speed multiplier.
136    pub fn try_acquire_scaled(&mut self, speed_multiplier: f64) -> Result<()> {
137        self.refill(speed_multiplier);
138        if self.tokens == 0 {
139            Err(anyhow!("tool rate limit exceeded"))
140        } else {
141            self.tokens -= 1;
142            Ok(())
143        }
144    }
145}
146
147/// Public alias for benchmark compatibility
148pub type RateLimiter = PerToolRateLimiter;
149
150use crate::types::CompactStr;
151use hashbrown::HashMap;
152/// Global rate limiter instance used by all tools.
153///
154/// The `lazy_static` pattern is avoided to keep the dependency surface low;
155/// instead we rely on `once_cell::sync::Lazy` which is already a transitive
156/// dependency of the project.
157use once_cell::sync::Lazy;
158
159pub static GLOBAL_RATE_LIMITER: Lazy<Mutex<RateLimiterInner>> = Lazy::new(|| Mutex::new(RateLimiterInner::new()));
160
161/// Per-tool rate limiter for finer-grained control.
162/// Each tool gets its own token bucket, allowing different rate limits per tool.
163pub struct PerToolRateLimiter {
164    /// Per-tool token buckets. Key is tool name.
165    buckets: HashMap<CompactStr, RateLimiterInner>,
166    /// Default config for new tool buckets.
167    default_config: RateLimiterConfig,
168}
169
170impl Default for PerToolRateLimiter {
171    fn default() -> Self {
172        Self::new()
173    }
174}
175
176impl PerToolRateLimiter {
177    /// Create a new per-tool rate limiter with default configuration.
178    pub fn new() -> Self {
179        Self {
180            buckets: HashMap::new(),
181            default_config: RateLimiterConfig::default(),
182        }
183    }
184
185    pub fn new_with_config(config: RateLimiterConfig) -> Self {
186        Self { buckets: HashMap::new(), default_config: config }
187    }
188
189    /// Try to acquire a token for a specific tool.
190    /// Returns `Ok(())` if allowed, `Err` if rate limited.
191    pub fn try_acquire_for(&mut self, tool_name: &str) -> Result<()> {
192        self.try_acquire_for_scaled(tool_name, 1.0)
193    }
194
195    /// Try to acquire a token with a priority multiplier.
196    pub fn try_acquire_for_scaled(&mut self, tool_name: &str, multiplier: f64) -> Result<()> {
197        let bucket = self
198            .buckets
199            .entry(CompactStr::from(tool_name))
200            .or_insert_with(|| RateLimiterInner::new_with_config(self.default_config));
201        bucket.try_acquire_scaled(multiplier)
202    }
203
204    /// Alias for try_acquire_for (used by benchmarks)
205    pub fn acquire(&mut self, tool_name: &str) -> Result<()> {
206        self.try_acquire_for(tool_name)
207    }
208
209    /// Check if a tool is currently rate limited without consuming a token.
210    pub fn is_limited(&mut self, tool_name: &str) -> bool {
211        if let Some(bucket) = self.buckets.get_mut(tool_name) {
212            bucket.refill(1.0); // Assume default refresher for check
213            bucket.tokens == 0
214        } else {
215            false
216        }
217    }
218
219    /// Reset the rate limiter for a specific tool.
220    pub fn reset_tool(&mut self, tool_name: &str) {
221        if let Some(bucket) = self.buckets.get_mut(tool_name) {
222            bucket.tokens = bucket.config.burst;
223            bucket.last_refill = Instant::now();
224        }
225    }
226}
227
228/// Global per-tool rate limiter instance.
229pub static PER_TOOL_RATE_LIMITER: Lazy<Mutex<PerToolRateLimiter>> = Lazy::new(|| Mutex::new(PerToolRateLimiter::new()));
230
231/// Public API – try to acquire permission for a tool call.
232///
233/// Returns `Ok(())` when the call is allowed, otherwise an error.
234pub fn try_acquire() -> Result<()> {
235    let mut guard: MutexGuard<'_, RateLimiterInner> =
236        GLOBAL_RATE_LIMITER.lock().map_err(|e| anyhow!("rate limiter poisoned: {e}"))?;
237    guard.try_acquire()
238}
239
240/// Try to acquire permission for a specific tool.
241/// Uses per-tool rate limiting for finer-grained control.
242pub fn try_acquire_for(tool_name: &str) -> Result<()> {
243    let mut guard = PER_TOOL_RATE_LIMITER
244        .lock()
245        .map_err(|e| anyhow!("per-tool rate limiter poisoned: {e}"))?;
246    guard.try_acquire_for(tool_name)
247}
248
249#[cfg(test)]
250mod tests {
251    use super::*;
252
253    #[test]
254    fn test_global_limiter_allows_burst() {
255        let mut limiter = RateLimiterInner::new();
256        // Should allow up to burst capacity
257        for _ in 0..limiter.config.burst {
258            limiter.try_acquire().unwrap();
259        }
260        // Next should fail
261        assert!(limiter.try_acquire().is_err());
262    }
263
264    #[test]
265    fn test_per_tool_limiter_isolates_tools() {
266        let mut limiter = PerToolRateLimiter::new();
267        // Exhaust tool_a
268        for _ in 0..5 {
269            let _ = limiter.try_acquire_for("tool_a");
270        }
271        // tool_b should still have tokens
272        limiter.try_acquire_for("tool_b").unwrap();
273    }
274
275    #[test]
276    fn test_reset_tool_restores_tokens() {
277        let mut limiter = PerToolRateLimiter::new();
278        // Exhaust tokens
279        let burst = limiter.default_config.burst;
280        for _ in 0..burst {
281            let _ = limiter.try_acquire_for("tool_x");
282        }
283        assert!(limiter.is_limited("tool_x"));
284        // Reset
285        limiter.reset_tool("tool_x");
286        assert!(!limiter.is_limited("tool_x"));
287    }
288}