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
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        let effective_rate = (self.config.per_sec as f64 * speed_multiplier) as u64;
105
106        // Using integer math: tokens = (effective_rate * millis) / 1000
107        let added = u32::try_from(effective_rate.saturating_mul(millis) / 1000).unwrap_or(u32::MAX);
108
109        if added > 0 {
110            let effective_burst = self.config.burst as f64 * speed_multiplier.max(1.0);
111            let effective_burst: u32 = if effective_burst.is_finite()
112                && effective_burst >= 0.0
113                && effective_burst <= u32::MAX as f64
114            {
115                effective_burst as u32
116            } else {
117                u32::MAX
118            };
119            self.tokens = self.tokens.saturating_add(added).min(effective_burst);
120            self.last_refill = now;
121        }
122    }
123
124    /// Attempt to acquire a single token with default speed (1.0).
125    pub fn try_acquire(&mut self) -> Result<()> {
126        self.try_acquire_scaled(1.0)
127    }
128
129    /// Attempt to acquire a single token with a speed multiplier.
130    pub fn try_acquire_scaled(&mut self, speed_multiplier: f64) -> Result<()> {
131        self.refill(speed_multiplier);
132        if self.tokens == 0 {
133            Err(anyhow!("tool rate limit exceeded"))
134        } else {
135            self.tokens -= 1;
136            Ok(())
137        }
138    }
139}
140
141/// Public alias for benchmark compatibility
142pub type RateLimiter = PerToolRateLimiter;
143
144use crate::types::CompactStr;
145use hashbrown::HashMap;
146/// Global rate limiter instance used by all tools.
147///
148/// The `lazy_static` pattern is avoided to keep the dependency surface low;
149/// instead we rely on `once_cell::sync::Lazy` which is already a transitive
150/// dependency of the project.
151use once_cell::sync::Lazy;
152
153pub static GLOBAL_RATE_LIMITER: Lazy<Mutex<RateLimiterInner>> =
154    Lazy::new(|| Mutex::new(RateLimiterInner::new()));
155
156/// Per-tool rate limiter for finer-grained control.
157/// Each tool gets its own token bucket, allowing different rate limits per tool.
158pub struct PerToolRateLimiter {
159    /// Per-tool token buckets. Key is tool name.
160    buckets: HashMap<CompactStr, RateLimiterInner>,
161    /// Default config for new tool buckets.
162    default_config: RateLimiterConfig,
163}
164
165impl Default for PerToolRateLimiter {
166    fn default() -> Self {
167        Self::new()
168    }
169}
170
171impl PerToolRateLimiter {
172    /// Create a new per-tool rate limiter with default configuration.
173    pub fn new() -> Self {
174        Self {
175            buckets: HashMap::new(),
176            default_config: RateLimiterConfig::default(),
177        }
178    }
179
180    pub fn new_with_config(config: RateLimiterConfig) -> Self {
181        Self {
182            buckets: HashMap::new(),
183            default_config: config,
184        }
185    }
186
187    /// Try to acquire a token for a specific tool.
188    /// Returns `Ok(())` if allowed, `Err` if rate limited.
189    pub fn try_acquire_for(&mut self, tool_name: &str) -> Result<()> {
190        self.try_acquire_for_scaled(tool_name, 1.0)
191    }
192
193    /// Try to acquire a token with a priority multiplier.
194    pub fn try_acquire_for_scaled(&mut self, tool_name: &str, multiplier: f64) -> Result<()> {
195        let bucket = self
196            .buckets
197            .entry(CompactStr::from(tool_name))
198            .or_insert_with(|| RateLimiterInner::new_with_config(self.default_config));
199        bucket.try_acquire_scaled(multiplier)
200    }
201
202    /// Alias for try_acquire_for (used by benchmarks)
203    pub fn acquire(&mut self, tool_name: &str) -> Result<()> {
204        self.try_acquire_for(tool_name)
205    }
206
207    /// Check if a tool is currently rate limited without consuming a token.
208    pub fn is_limited(&mut self, tool_name: &str) -> bool {
209        if let Some(bucket) = self.buckets.get_mut(tool_name) {
210            bucket.refill(1.0); // Assume default refresher for check
211            bucket.tokens == 0
212        } else {
213            false
214        }
215    }
216
217    /// Reset the rate limiter for a specific tool.
218    pub fn reset_tool(&mut self, tool_name: &str) {
219        if let Some(bucket) = self.buckets.get_mut(tool_name) {
220            bucket.tokens = bucket.config.burst;
221            bucket.last_refill = Instant::now();
222        }
223    }
224}
225
226/// Global per-tool rate limiter instance.
227pub static PER_TOOL_RATE_LIMITER: Lazy<Mutex<PerToolRateLimiter>> =
228    Lazy::new(|| Mutex::new(PerToolRateLimiter::new()));
229
230/// Public API – try to acquire permission for a tool call.
231///
232/// Returns `Ok(())` when the call is allowed, otherwise an error.
233pub fn try_acquire() -> Result<()> {
234    let mut guard: MutexGuard<'_, RateLimiterInner> = GLOBAL_RATE_LIMITER
235        .lock()
236        .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}