use std::collections::HashMap;
use std::sync::Mutex;
use std::time::{Duration, Instant};
#[derive(Debug)]
pub struct RateLimiter {
inner: Mutex<Inner>,
max_events: usize,
window: Duration,
}
#[derive(Debug, Default)]
struct Inner {
buckets: HashMap<String, Bucket>,
}
#[derive(Debug)]
struct Bucket {
count: usize,
window_start: Instant,
}
impl RateLimiter {
pub fn new(max_events: usize, window_secs: u64) -> Self {
Self {
inner: Mutex::new(Inner::default()),
max_events,
window: Duration::from_secs(window_secs),
}
}
pub fn allow(&self, key: &str) -> bool {
let now = Instant::now();
let mut inner = self.inner.lock().unwrap_or_else(|e| e.into_inner());
let bucket = inner
.buckets
.entry(key.to_string())
.or_insert_with(|| Bucket {
count: 0,
window_start: now,
});
if now.duration_since(bucket.window_start) >= self.window {
bucket.count = 0;
bucket.window_start = now;
}
if bucket.count >= self.max_events {
return false;
}
bucket.count += 1;
if inner.buckets.len() > 10_000 {
inner
.buckets
.retain(|_, b| now.duration_since(b.window_start) < self.window);
}
true
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn blocks_after_max() {
let limiter = RateLimiter::new(2, 60);
assert!(limiter.allow("1.2.3.4"));
assert!(limiter.allow("1.2.3.4"));
assert!(!limiter.allow("1.2.3.4"));
assert!(limiter.allow("5.6.7.8"));
}
#[test]
fn resets_after_window() {
let limiter = RateLimiter::new(1, 0);
assert!(limiter.allow("a"));
assert!(limiter.allow("a"));
}
#[test]
fn test_ratelimit_poison_safe() {
use std::sync::Arc;
use std::thread;
let limiter = Arc::new(RateLimiter::new(2, 60));
let limiter_clone = Arc::clone(&limiter);
let handle = thread::spawn(move || {
let _lock = limiter_clone.inner.lock().unwrap();
panic!("poisoning the lock");
});
let _ = handle.join();
assert!(limiter.allow("1.2.3.4"));
assert!(limiter.allow("1.2.3.4"));
assert!(!limiter.allow("1.2.3.4"));
}
#[test]
fn evicts_stale_keys_when_over_limit() {
let limiter = RateLimiter::new(1, 0);
for i in 0..=10_000usize {
limiter.allow(&i.to_string());
}
assert!(
limiter.allow("new_key_after_eviction"),
"limiter must work correctly after evicting stale keys"
);
}
#[test]
fn eviction_threshold_is_strict_greater_than_10000() {
let limiter = RateLimiter::new(1_000_000, 0);
for i in 0..10_000usize {
limiter.allow(&i.to_string());
}
let inner = limiter.inner.lock().unwrap();
assert_eq!(
inner.buckets.len(),
10_000,
"eviction must not trigger at exactly 10,000 buckets (threshold is strictly > 10_000)"
);
}
#[test]
fn retain_keeps_fresh_buckets_and_evicts_stale_ones() {
let limiter = RateLimiter::new(1_000_000, 60);
let now = Instant::now();
let stale_start = now - Duration::from_secs(120);
{
let mut inner = limiter.inner.lock().unwrap();
for i in 0..10_000usize {
inner.buckets.insert(
format!("stale_{i}"),
Bucket {
count: 1,
window_start: stale_start,
},
);
}
}
limiter.allow("fresh_key");
let inner = limiter.inner.lock().unwrap();
assert_eq!(
inner.buckets.len(),
1,
"after eviction, only the fresh key must remain"
);
assert!(
inner.buckets.contains_key("fresh_key"),
"the fresh key (within window) must be retained after eviction"
);
}
}