1use std::collections::HashMap;
21use std::sync::atomic::{AtomicU64, Ordering};
22use std::sync::{Arc, RwLock};
23use std::time::{SystemTime, UNIX_EPOCH};
24
25pub const DEFAULT_GLOBAL_RPM: u64 = 600;
27pub const DEFAULT_TOOL_RPM: u64 = 240;
28pub const DEFAULT_BURST: u64 = 20;
29
30#[derive(Debug, Clone, PartialEq, Eq)]
35pub struct RateLimiterConfig {
36 pub global_rpm: u64,
38 pub default_tool_rpm: u64,
40 pub burst_allowance: u64,
42 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 #[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 #[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
128pub 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 #[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 pub fn try_acquire(&self) -> bool {
168 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 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 self.burst_tokens.fetch_add(1, Ordering::Relaxed);
204 }
205
206 false
207 }
208
209 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
253pub 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 #[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 #[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 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 pub fn try_acquire(&self, tool: &str) -> Result<(), u64> {
312 if !self.global_window.try_acquire() {
314 return Err(self.window_ms / 2);
315 }
316
317 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 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 assert!(w.try_acquire());
401 assert!(w.try_acquire());
402 assert!(w.try_acquire());
403 assert!(!w.try_acquire());
405 }
406
407 #[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 assert!(limiter.try_acquire("test_tool").is_err());
439 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 #[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 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 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 use proptest::prelude::*;
547
548 #[test]
549 fn empty_tool_name_is_limited_in_its_own_bucket() {
550 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 for _ in 0..1000 {
564 assert!(w.try_acquire());
565 }
566 }
567
568 proptest! {
569 #[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 #[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 #[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 #[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}