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` | 600 | Max total dispatches/min across all tools |
14//! | `WM_DISPATCH_TOOL_RPM` | 240 | Default per-tool RPM limit |
15//! | `WM_DISPATCH_BURST` | 20 | 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 = 600;
27pub const DEFAULT_TOOL_RPM: u64 = 240;
28pub const DEFAULT_BURST: u64 = 20;
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    // Negative fixture (Q04 batch 3): a mutant that counts rejected calls as
408    // requests, or that drains/restores burst tokens on rejection, would pass
409    // the boolean-only tests above but must fail here.
410    #[test]
411    fn rejected_acquire_does_not_consume_budget_or_burst() {
412        let w = SlidingWindow::new(1, 60_000, 1);
413        assert!(w.try_acquire(), "base permit");
414        assert!(w.try_acquire(), "burst permit");
415        let rate_at_limit = w.current_rate();
416        for _ in 0..5 {
417            assert!(!w.try_acquire(), "no permits remain");
418        }
419        assert_eq!(
420            w.current_rate(),
421            rate_at_limit,
422            "rejections must not count as requests"
423        );
424        assert_eq!(
425            w.burst_tokens.load(Ordering::Relaxed),
426            0,
427            "rejections must not restore or drain burst tokens"
428        );
429    }
430
431    #[test]
432    fn rate_limiter_per_tool() {
433        let limiter = RateLimiter::new(1000, 5, 0);
434        for _ in 0..5 {
435            assert!(limiter.try_acquire("test_tool").is_ok());
436        }
437        // Per-tool limit hit
438        assert!(limiter.try_acquire("test_tool").is_err());
439        // Different tool still works
440        assert!(limiter.try_acquire("other_tool").is_ok());
441    }
442
443    #[test]
444    fn rate_limiter_override() {
445        let limiter = RateLimiter::new(1000, 5, 0);
446        limiter.set_override("special_tool", 2);
447        assert!(limiter.try_acquire("special_tool").is_ok());
448        assert!(limiter.try_acquire("special_tool").is_ok());
449        assert!(limiter.try_acquire("special_tool").is_err());
450    }
451
452    #[test]
453    fn current_rate_tracks_acquires() {
454        let w = SlidingWindow::new(100, 60_000, 0);
455        assert!(w.current_rate() < 0.01);
456        w.try_acquire();
457        w.try_acquire();
458        w.try_acquire();
459        assert!(w.current_rate() >= 3.0);
460    }
461
462    #[test]
463    fn default_rate_limiter() {
464        let limiter = RateLimiter::default();
465        assert!(limiter.try_acquire("any_tool").is_ok());
466    }
467
468    // ── RateLimiterConfig tests ─────────────────────────────────────
469
470    #[test]
471    fn config_defaults_match_legacy_values() {
472        let config = RateLimiterConfig::default();
473        assert_eq!(config.global_rpm, 600);
474        assert_eq!(config.default_tool_rpm, 240);
475        assert_eq!(config.burst_allowance, 20);
476        assert!(config.tool_overrides.is_empty());
477    }
478
479    #[test]
480    fn config_from_env_applies_overrides() {
481        let config = RateLimiterConfig::from_env_impl(
482            Some("5000".to_string()),
483            Some("250".to_string()),
484            Some("40".to_string()),
485            Some("wm:2000, memory.search: 120 ,badtool:xyz".to_string()),
486        );
487        assert_eq!(config.global_rpm, 5000);
488        assert_eq!(config.default_tool_rpm, 250);
489        assert_eq!(config.burst_allowance, 40);
490        assert_eq!(config.tool_overrides.get("wm"), Some(&2000));
491        assert_eq!(config.tool_overrides.get("memory.search"), Some(&120));
492        assert!(!config.tool_overrides.contains_key("badtool"));
493    }
494
495    #[test]
496    fn config_from_env_ignores_invalid_values() {
497        let config = RateLimiterConfig::from_env_impl(
498            Some("not-a-number".to_string()),
499            Some("0".to_string()),
500            None,
501            None,
502        );
503        assert_eq!(
504            config.global_rpm, DEFAULT_GLOBAL_RPM,
505            "invalid rpm keeps default"
506        );
507        assert_eq!(config.default_tool_rpm, 0, "valid 0 means unlimited");
508        assert_eq!(config.burst_allowance, DEFAULT_BURST);
509    }
510
511    #[test]
512    fn config_from_env_empty_overrides_ignored() {
513        let config = RateLimiterConfig::from_env_impl(None, None, None, Some(String::new()));
514        assert!(config.tool_overrides.is_empty());
515    }
516
517    #[test]
518    fn rate_limiter_from_config_applies_overrides() {
519        let config = RateLimiterConfig {
520            global_rpm: 100_000,
521            default_tool_rpm: 5,
522            burst_allowance: 0,
523            tool_overrides: std::collections::HashMap::from([("wm".to_string(), 5000)]),
524        };
525        let limiter = RateLimiter::from_config(&config);
526        // Other tools stay at the default cap...
527        for _ in 0..5 {
528            assert!(limiter.try_acquire("other_tool").is_ok());
529        }
530        assert!(
531            limiter.try_acquire("other_tool").is_err(),
532            "default cap (5) enforced for non-overridden tools"
533        );
534        // ...while the overridden tool gets its higher cap.
535        for _ in 0..5000 {
536            assert!(limiter.try_acquire("wm").is_ok());
537        }
538        assert!(
539            limiter.try_acquire("wm").is_err(),
540            "override cap (5000) should be enforced after burst"
541        );
542    }
543
544    // ── Property-based tests (proptest) ─────────────────────────────
545
546    use proptest::prelude::*;
547
548    #[test]
549    fn empty_tool_name_is_limited_in_its_own_bucket() {
550        // An empty name is not a bypass: it must use a stable per-tool bucket
551        // and must not consume the different named tool's allowance.
552        let limiter = RateLimiter::new(100_000, 2, 0);
553        assert!(limiter.try_acquire("").is_ok());
554        assert!(limiter.try_acquire("").is_ok());
555        assert!(limiter.try_acquire("").is_err());
556        assert!(limiter.try_acquire("other_tool").is_ok());
557    }
558
559    #[test]
560    fn zero_max_means_unlimited() {
561        let w = SlidingWindow::new(0, 60_000, 0);
562        // 0 = unlimited: should always allow
563        for _ in 0..1000 {
564            assert!(w.try_acquire());
565        }
566    }
567
568    proptest! {
569        /// try_acquire() must never panic with arbitrary tool names.
570        #[test]
571        fn try_acquire_never_panics(tool_name in ".*") {
572            let limiter = RateLimiter::new(100_000, 10_000, 1_000);
573            let _ = limiter.try_acquire(&tool_name);
574        }
575
576        /// try_acquire() with very long tool name must not panic.
577        #[test]
578        fn try_acquire_long_name(n in 1usize..1000) {
579            let limiter = RateLimiter::new(100_000, 10_000, 1_000);
580            let name = "x".repeat(n);
581            let _ = limiter.try_acquire(&name);
582        }
583
584        /// try_acquire() with non-ASCII tool names must not panic.
585        #[test]
586        fn try_acquire_non_ascii(tool_name in r"[^\x00-\x7F]*") {
587            let limiter = RateLimiter::new(100_000, 10_000, 1_000);
588            let _ = limiter.try_acquire(&tool_name);
589        }
590
591        /// current_rate is always non-negative.
592        #[test]
593        fn current_rate_non_negative(max in 1u64..1000, burst in 0u64..100) {
594            let w = SlidingWindow::new(max, 60_000, burst);
595            w.try_acquire();
596            let rate = w.current_rate();
597            prop_assert!(rate >= 0.0, "current_rate must be >= 0, got {rate}");
598        }
599    }
600}