Skip to main content

wm_dispatch/
rate_limiter.rs

1//! Atomic sliding-window rate limiter for tool dispatch.
2//!
3//! Provides O(1) per-check rate limiting using lock-free atomics.
4//! Per-tool and global RPM enforcement with burst allowance.
5//!
6//! # Configuration
7//!
8//! Limits are configurable via `RateLimiterConfig` (defaults in
9//! [`RateLimiterConfig::default`]) or the environment:
10//!
11//! | Variable | Default | Description |
12//! |----------|---------|-------------|
13//! | `WM_DISPATCH_GLOBAL_RPM` | 300 | Max total dispatches/min across all tools |
14//! | `WM_DISPATCH_TOOL_RPM` | 60 | Default per-tool RPM limit |
15//! | `WM_DISPATCH_BURST` | 10 | Extra burst capacity per tool |
16//! | `WM_DISPATCH_TOOL_OVERRIDES` | — | `tool:rpm,tool2:rpm2` per-tool overrides |
17//!
18//! Ported from v2-reference/safety/rate_limiter.rs — PyO3 and lazy_static removed.
19
20use std::collections::HashMap;
21use std::sync::atomic::{AtomicU64, Ordering};
22use std::sync::{Arc, RwLock};
23use std::time::{SystemTime, UNIX_EPOCH};
24
25/// Default limits — the values used by [`RateLimiter::default`].
26pub const DEFAULT_GLOBAL_RPM: u64 = 300;
27pub const DEFAULT_TOOL_RPM: u64 = 60;
28pub const DEFAULT_BURST: u64 = 10;
29
30/// Configuration for a [`RateLimiter`].
31///
32/// Built from `RateLimiterConfig::default()`, optionally overridden by
33/// `WM_DISPATCH_*` environment variables (see module docs).
34#[derive(Debug, Clone, PartialEq, Eq)]
35pub struct RateLimiterConfig {
36    /// Max total dispatches per minute across all tools (0 = unlimited).
37    pub global_rpm: u64,
38    /// Default per-tool dispatches per minute (0 = unlimited).
39    pub default_tool_rpm: u64,
40    /// Extra burst capacity per tool window.
41    pub burst_allowance: u64,
42    /// Per-tool RPM overrides: tool name → RPM.
43    pub tool_overrides: HashMap<String, u64>,
44}
45
46impl Default for RateLimiterConfig {
47    fn default() -> Self {
48        Self {
49            global_rpm: DEFAULT_GLOBAL_RPM,
50            default_tool_rpm: DEFAULT_TOOL_RPM,
51            burst_allowance: DEFAULT_BURST,
52            tool_overrides: HashMap::new(),
53        }
54    }
55}
56
57impl RateLimiterConfig {
58    /// Build a config from `WM_DISPATCH_*` environment variables.
59    ///
60    /// Unset variables keep their defaults. Malformed values are ignored with
61    /// a warning (a bad env var should not take the system down).
62    #[must_use]
63    pub fn from_env() -> Self {
64        Self::from_env_impl(
65            std::env::var("WM_DISPATCH_GLOBAL_RPM").ok(),
66            std::env::var("WM_DISPATCH_TOOL_RPM").ok(),
67            std::env::var("WM_DISPATCH_BURST").ok(),
68            std::env::var("WM_DISPATCH_TOOL_OVERRIDES").ok(),
69        )
70    }
71
72    /// Pure parsing used by [`Self::from_env`]; testable without touching
73    /// process environment state.
74    #[must_use]
75    fn from_env_impl(
76        global_rpm: Option<String>,
77        tool_rpm: Option<String>,
78        burst: Option<String>,
79        overrides: Option<String>,
80    ) -> Self {
81        let mut config = Self::default();
82        if let Some(v) = global_rpm {
83            if let Ok(rpm) = v.parse::<u64>() {
84                config.global_rpm = rpm;
85            } else {
86                tracing::warn!("WM_DISPATCH_GLOBAL_RPM invalid ({v}), keeping default");
87            }
88        }
89        if let Some(v) = tool_rpm {
90            if let Ok(rpm) = v.parse::<u64>() {
91                config.default_tool_rpm = rpm;
92            } else {
93                tracing::warn!("WM_DISPATCH_TOOL_RPM invalid ({v}), keeping default");
94            }
95        }
96        if let Some(v) = burst {
97            if let Ok(burst) = v.parse::<u64>() {
98                config.burst_allowance = burst;
99            } else {
100                tracing::warn!("WM_DISPATCH_BURST invalid ({v}), keeping default");
101            }
102        }
103        if let Some(v) = overrides {
104            for pair in v.split(',') {
105                let pair = pair.trim();
106                if pair.is_empty() {
107                    continue;
108                }
109                let Some((tool, rpm)) = pair.split_once(':') else {
110                    tracing::warn!(
111                        "WM_DISPATCH_TOOL_OVERRIDES entry '{pair}' missing ':' — skipping"
112                    );
113                    continue;
114                };
115                if let Ok(rpm) = rpm.trim().parse::<u64>() {
116                    config.tool_overrides.insert(tool.trim().to_string(), rpm);
117                } else {
118                    tracing::warn!(
119                        "WM_DISPATCH_TOOL_OVERRIDES entry '{pair}' has invalid rpm — skipping"
120                    );
121                }
122            }
123        }
124        config
125    }
126}
127
128/// A sliding-window counter using two half-windows for smooth transitions.
129///
130/// This avoids the "boundary spike" problem of fixed-window counters
131/// by weighting the previous and current window counts proportionally.
132pub struct SlidingWindow {
133    current_count: AtomicU64,
134    previous_count: AtomicU64,
135    current_window_start: AtomicU64,
136    window_ms: u64,
137    max_requests: u64,
138    burst_allowance: u64,
139    burst_tokens: AtomicU64,
140    last_refill: AtomicU64,
141}
142
143impl SlidingWindow {
144    /// Create a new sliding window with the given limits.
145    ///
146    /// - `max_requests`: Maximum requests per window before burst is consumed.
147    /// - `window_ms`: Window duration in milliseconds (e.g. 60_000 for RPM).
148    /// - `burst_allowance`: Extra capacity above `max_requests` for short bursts.
149    #[must_use]
150    pub fn new(max_requests: u64, window_ms: u64, burst_allowance: u64) -> Self {
151        let now = current_time_ms();
152        Self {
153            current_count: AtomicU64::new(0),
154            previous_count: AtomicU64::new(0),
155            current_window_start: AtomicU64::new(now),
156            window_ms,
157            max_requests,
158            burst_allowance,
159            burst_tokens: AtomicU64::new(burst_allowance),
160            last_refill: AtomicU64::new(now),
161        }
162    }
163
164    /// Try to acquire a permit. Returns `true` if allowed, `false` if rate-limited.
165    ///
166    /// A `max_requests` of 0 means **unlimited** (no rate limiting).
167    pub fn try_acquire(&self) -> bool {
168        // 0 = unlimited per documentation and RateLimiterConfig convention
169        if self.max_requests == 0 {
170            return true;
171        }
172
173        let now = current_time_ms();
174        self.maybe_rotate(now);
175        self.maybe_refill_burst(now);
176
177        let window_start = self.current_window_start.load(Ordering::Relaxed);
178        let elapsed = now.saturating_sub(window_start);
179        let weight = if self.window_ms > 0 {
180            (elapsed as f64 / self.window_ms as f64).min(1.0)
181        } else {
182            1.0
183        };
184
185        let prev = self.previous_count.load(Ordering::Relaxed) as f64;
186        let curr = self.current_count.load(Ordering::Relaxed) as f64;
187        let estimated = prev.mul_add(1.0 - weight, curr);
188
189        if estimated < self.max_requests as f64 {
190            self.current_count.fetch_add(1, Ordering::Relaxed);
191            return true;
192        }
193
194        // Try burst tokens
195        let tokens = self.burst_tokens.load(Ordering::Relaxed);
196        if tokens > 0 {
197            let prev_tokens = self.burst_tokens.fetch_sub(1, Ordering::Relaxed);
198            if prev_tokens > 0 {
199                self.current_count.fetch_add(1, Ordering::Relaxed);
200                return true;
201            }
202            // Restore if we went negative
203            self.burst_tokens.fetch_add(1, Ordering::Relaxed);
204        }
205
206        false
207    }
208
209    /// Get current estimated request count (weighted across windows).
210    pub fn current_rate(&self) -> f64 {
211        let now = current_time_ms();
212        let window_start = self.current_window_start.load(Ordering::Relaxed);
213        let elapsed = now.saturating_sub(window_start);
214        let weight = if self.window_ms > 0 {
215            (elapsed as f64 / self.window_ms as f64).min(1.0)
216        } else {
217            1.0
218        };
219        let prev = self.previous_count.load(Ordering::Relaxed) as f64;
220        let curr = self.current_count.load(Ordering::Relaxed) as f64;
221        prev.mul_add(1.0 - weight, curr)
222    }
223
224    fn maybe_rotate(&self, now: u64) {
225        let window_start = self.current_window_start.load(Ordering::Relaxed);
226        if now.saturating_sub(window_start) >= self.window_ms {
227            let current = self.current_count.load(Ordering::Relaxed);
228            self.previous_count.store(current, Ordering::Relaxed);
229            self.current_count.store(0, Ordering::Relaxed);
230            self.current_window_start.store(now, Ordering::Relaxed);
231        }
232    }
233
234    fn maybe_refill_burst(&self, now: u64) {
235        let last = self.last_refill.load(Ordering::Relaxed);
236        if now.saturating_sub(last) >= self.window_ms {
237            let current_tokens = self.burst_tokens.load(Ordering::Relaxed);
238            if current_tokens < self.burst_allowance {
239                self.burst_tokens.fetch_add(1, Ordering::Relaxed);
240            }
241            self.last_refill.store(now, Ordering::Relaxed);
242        }
243    }
244}
245
246fn current_time_ms() -> u64 {
247    SystemTime::now()
248        .duration_since(UNIX_EPOCH)
249        .unwrap_or_default()
250        .as_millis() as u64
251}
252
253/// Rate limiter managing per-tool and global windows.
254pub struct RateLimiter {
255    tool_windows: RwLock<HashMap<String, Arc<SlidingWindow>>>,
256    global_window: SlidingWindow,
257    default_tool_rpm: u64,
258    window_ms: u64,
259    burst_allowance: u64,
260    overrides: RwLock<HashMap<String, u64>>,
261}
262
263impl RateLimiter {
264    /// Create a new rate limiter.
265    ///
266    /// - `global_rpm`: Maximum total requests per minute across all tools.
267    /// - `default_tool_rpm`: Default per-tool RPM limit.
268    /// - `burst_allowance`: Extra burst capacity per tool.
269    #[must_use]
270    pub fn new(global_rpm: u64, default_tool_rpm: u64, burst_allowance: u64) -> Self {
271        Self {
272            tool_windows: RwLock::new(HashMap::new()),
273            global_window: SlidingWindow::new(
274                global_rpm,
275                60_000,
276                burst_allowance.saturating_mul(2),
277            ),
278            default_tool_rpm,
279            window_ms: 60_000,
280            burst_allowance,
281            overrides: RwLock::new(HashMap::new()),
282        }
283    }
284
285    /// Create a rate limiter from a [`RateLimiterConfig`].
286    ///
287    /// Per-tool overrides from the config are applied immediately.
288    #[must_use]
289    pub fn from_config(config: &RateLimiterConfig) -> Self {
290        let limiter = Self::new(
291            config.global_rpm,
292            config.default_tool_rpm,
293            config.burst_allowance,
294        );
295        for (tool, rpm) in &config.tool_overrides {
296            limiter.set_override(tool, *rpm);
297        }
298        limiter
299    }
300
301    /// Set a per-tool RPM override.
302    pub fn set_override(&self, tool: &str, rpm: u64) {
303        if let Ok(mut guard) = self.overrides.write() {
304            guard.insert(tool.to_string(), rpm);
305        }
306    }
307
308    /// Try to acquire a permit for a tool invocation.
309    ///
310    /// Returns `Ok(())` if allowed, `Err(retry_after_ms)` if rate-limited.
311    pub fn try_acquire(&self, tool: &str) -> Result<(), u64> {
312        // Check global limit first
313        if !self.global_window.try_acquire() {
314            return Err(self.window_ms / 2);
315        }
316
317        // Get or create per-tool window
318        let window = {
319            let read_guard = self
320                .tool_windows
321                .read()
322                .unwrap_or_else(std::sync::PoisonError::into_inner);
323            if let Some(w) = read_guard.get(tool) {
324                Arc::clone(w)
325            } else {
326                drop(read_guard);
327                let rpm = self
328                    .overrides
329                    .read()
330                    .unwrap_or_else(std::sync::PoisonError::into_inner)
331                    .get(tool)
332                    .copied()
333                    .unwrap_or(self.default_tool_rpm);
334                let new_window = Arc::new(SlidingWindow::new(
335                    rpm,
336                    self.window_ms,
337                    self.burst_allowance,
338                ));
339                if let Ok(mut write_guard) = self.tool_windows.write() {
340                    write_guard.insert(tool.to_string(), Arc::clone(&new_window));
341                }
342                new_window
343            }
344        };
345
346        if window.try_acquire() {
347            Ok(())
348        } else {
349            Err(self.window_ms / 4)
350        }
351    }
352
353    /// Get statistics for all tracked tools.
354    pub fn stats(&self) -> HashMap<String, f64> {
355        let mut result = HashMap::new();
356        result.insert("global_rate".to_string(), self.global_window.current_rate());
357        if let Ok(guard) = self.tool_windows.read() {
358            for (tool, window) in guard.iter() {
359                result.insert(format!("tool:{tool}"), window.current_rate());
360            }
361        }
362        result
363    }
364}
365
366impl Default for RateLimiter {
367    fn default() -> Self {
368        Self::from_config(&RateLimiterConfig::default())
369    }
370}
371
372#[cfg(test)]
373mod tests {
374    use super::*;
375
376    #[test]
377    fn sliding_window_allows_under_limit() {
378        let w = SlidingWindow::new(10, 60_000, 0);
379        for _ in 0..10 {
380            assert!(w.try_acquire());
381        }
382    }
383
384    #[test]
385    fn sliding_window_blocks_over_limit() {
386        let w = SlidingWindow::new(5, 60_000, 0);
387        for _ in 0..5 {
388            assert!(w.try_acquire());
389        }
390        assert!(!w.try_acquire());
391    }
392
393    #[test]
394    fn burst_allowance_allows_extra() {
395        let w = SlidingWindow::new(5, 60_000, 3);
396        for _ in 0..5 {
397            assert!(w.try_acquire());
398        }
399        // Burst should allow 3 more
400        assert!(w.try_acquire());
401        assert!(w.try_acquire());
402        assert!(w.try_acquire());
403        // Now truly blocked
404        assert!(!w.try_acquire());
405    }
406
407    #[test]
408    fn rate_limiter_per_tool() {
409        let limiter = RateLimiter::new(1000, 5, 0);
410        for _ in 0..5 {
411            assert!(limiter.try_acquire("test_tool").is_ok());
412        }
413        // Per-tool limit hit
414        assert!(limiter.try_acquire("test_tool").is_err());
415        // Different tool still works
416        assert!(limiter.try_acquire("other_tool").is_ok());
417    }
418
419    #[test]
420    fn rate_limiter_override() {
421        let limiter = RateLimiter::new(1000, 5, 0);
422        limiter.set_override("special_tool", 2);
423        assert!(limiter.try_acquire("special_tool").is_ok());
424        assert!(limiter.try_acquire("special_tool").is_ok());
425        assert!(limiter.try_acquire("special_tool").is_err());
426    }
427
428    #[test]
429    fn current_rate_tracks_acquires() {
430        let w = SlidingWindow::new(100, 60_000, 0);
431        assert!(w.current_rate() < 0.01);
432        w.try_acquire();
433        w.try_acquire();
434        w.try_acquire();
435        assert!(w.current_rate() >= 3.0);
436    }
437
438    #[test]
439    fn default_rate_limiter() {
440        let limiter = RateLimiter::default();
441        assert!(limiter.try_acquire("any_tool").is_ok());
442    }
443
444    // ── RateLimiterConfig tests ─────────────────────────────────────
445
446    #[test]
447    fn config_defaults_match_legacy_values() {
448        let config = RateLimiterConfig::default();
449        assert_eq!(config.global_rpm, 300);
450        assert_eq!(config.default_tool_rpm, 60);
451        assert_eq!(config.burst_allowance, 10);
452        assert!(config.tool_overrides.is_empty());
453    }
454
455    #[test]
456    fn config_from_env_applies_overrides() {
457        let config = RateLimiterConfig::from_env_impl(
458            Some("5000".to_string()),
459            Some("250".to_string()),
460            Some("40".to_string()),
461            Some("wm:2000, memory.search: 120 ,badtool:xyz".to_string()),
462        );
463        assert_eq!(config.global_rpm, 5000);
464        assert_eq!(config.default_tool_rpm, 250);
465        assert_eq!(config.burst_allowance, 40);
466        assert_eq!(config.tool_overrides.get("wm"), Some(&2000));
467        assert_eq!(config.tool_overrides.get("memory.search"), Some(&120));
468        assert!(!config.tool_overrides.contains_key("badtool"));
469    }
470
471    #[test]
472    fn config_from_env_ignores_invalid_values() {
473        let config = RateLimiterConfig::from_env_impl(
474            Some("not-a-number".to_string()),
475            Some("0".to_string()),
476            None,
477            None,
478        );
479        assert_eq!(
480            config.global_rpm, DEFAULT_GLOBAL_RPM,
481            "invalid rpm keeps default"
482        );
483        assert_eq!(config.default_tool_rpm, 0, "valid 0 means unlimited");
484        assert_eq!(config.burst_allowance, DEFAULT_BURST);
485    }
486
487    #[test]
488    fn config_from_env_empty_overrides_ignored() {
489        let config = RateLimiterConfig::from_env_impl(None, None, None, Some(String::new()));
490        assert!(config.tool_overrides.is_empty());
491    }
492
493    #[test]
494    fn rate_limiter_from_config_applies_overrides() {
495        let config = RateLimiterConfig {
496            global_rpm: 100_000,
497            default_tool_rpm: 5,
498            burst_allowance: 0,
499            tool_overrides: std::collections::HashMap::from([("wm".to_string(), 5000)]),
500        };
501        let limiter = RateLimiter::from_config(&config);
502        // Other tools stay at the default cap...
503        for _ in 0..5 {
504            assert!(limiter.try_acquire("other_tool").is_ok());
505        }
506        assert!(
507            limiter.try_acquire("other_tool").is_err(),
508            "default cap (5) enforced for non-overridden tools"
509        );
510        // ...while the overridden tool gets its higher cap.
511        for _ in 0..5000 {
512            assert!(limiter.try_acquire("wm").is_ok());
513        }
514        assert!(
515            limiter.try_acquire("wm").is_err(),
516            "override cap (5000) should be enforced after burst"
517        );
518    }
519
520    // ── Property-based tests (proptest) ─────────────────────────────
521
522    use proptest::prelude::*;
523
524    #[test]
525    fn empty_tool_name_is_limited_in_its_own_bucket() {
526        // An empty name is not a bypass: it must use a stable per-tool bucket
527        // and must not consume the different named tool's allowance.
528        let limiter = RateLimiter::new(100_000, 2, 0);
529        assert!(limiter.try_acquire("").is_ok());
530        assert!(limiter.try_acquire("").is_ok());
531        assert!(limiter.try_acquire("").is_err());
532        assert!(limiter.try_acquire("other_tool").is_ok());
533    }
534
535    #[test]
536    fn zero_max_means_unlimited() {
537        let w = SlidingWindow::new(0, 60_000, 0);
538        // 0 = unlimited: should always allow
539        for _ in 0..1000 {
540            assert!(w.try_acquire());
541        }
542    }
543
544    proptest! {
545        /// try_acquire() must never panic with arbitrary tool names.
546        #[test]
547        fn try_acquire_never_panics(tool_name in ".*") {
548            let limiter = RateLimiter::new(100_000, 10_000, 1_000);
549            let _ = limiter.try_acquire(&tool_name);
550        }
551
552        /// try_acquire() with very long tool name must not panic.
553        #[test]
554        fn try_acquire_long_name(n in 1usize..1000) {
555            let limiter = RateLimiter::new(100_000, 10_000, 1_000);
556            let name = "x".repeat(n);
557            let _ = limiter.try_acquire(&name);
558        }
559
560        /// try_acquire() with non-ASCII tool names must not panic.
561        #[test]
562        fn try_acquire_non_ascii(tool_name in r"[^\x00-\x7F]*") {
563            let limiter = RateLimiter::new(100_000, 10_000, 1_000);
564            let _ = limiter.try_acquire(&tool_name);
565        }
566
567        /// current_rate is always non-negative.
568        #[test]
569        fn current_rate_non_negative(max in 1u64..1000, burst in 0u64..100) {
570            let w = SlidingWindow::new(max, 60_000, burst);
571            w.try_acquire();
572            let rate = w.current_rate();
573            prop_assert!(rate >= 0.0, "current_rate must be >= 0, got {rate}");
574        }
575    }
576}