use std::collections::HashMap;
use std::hash::Hash;
use std::sync::Mutex;
use std::time::{Duration, Instant};
pub struct TokenBucket {
capacity: u32,
tokens: f64,
refill_rate: f64,
last_refill: Instant,
}
impl TokenBucket {
pub fn new(capacity: u32, refill_rate: f64) -> Self {
Self {
capacity,
tokens: f64::from(capacity),
refill_rate,
last_refill: Instant::now(),
}
}
pub fn last_refill(&self) -> Instant {
self.last_refill
}
pub fn tokens(&self) -> f64 {
self.tokens
}
pub fn capacity(&self) -> u32 {
self.capacity
}
pub fn try_acquire(&mut self) -> bool {
let now = Instant::now();
let elapsed = now.duration_since(self.last_refill).as_secs_f64();
let cap = f64::from(self.capacity);
self.tokens = (self.tokens + elapsed * self.refill_rate).min(cap);
self.last_refill = now;
if self.tokens >= 1.0 {
self.tokens -= 1.0;
true
} else {
false
}
}
}
pub fn sweep<K: Eq + Hash>(map: &Mutex<HashMap<K, TokenBucket>>, max_age: Duration) {
let now = Instant::now();
let mut guard = map.lock().expect("ratelimit map mutex poisoned");
guard.retain(|_, b| {
let idle = now.duration_since(b.last_refill()) >= max_age;
let full = b.tokens() >= f64::from(b.capacity());
!(idle && full)
});
}
#[cfg(test)]
mod tests {
use super::*;
use std::thread::sleep;
use std::time::Duration;
#[test]
fn full_bucket_grants_capacity_then_denies() {
let mut b = TokenBucket::new(5, 1.0);
for i in 0..5 {
assert!(b.try_acquire(), "acquire {i} should succeed on full bucket");
}
assert!(!b.try_acquire(), "6th acquire should fail when drained");
}
#[test]
fn token_regenerates_after_refill_interval() {
let mut b = TokenBucket::new(1, 10.0);
assert!(b.try_acquire(), "initial token");
assert!(!b.try_acquire(), "bucket drained");
sleep(Duration::from_millis(150));
assert!(b.try_acquire(), "token should regenerate after interval");
}
#[test]
fn sweep_drops_idle_full_buckets_keeps_active_or_drained() {
let map: Mutex<HashMap<&'static str, TokenBucket>> = Mutex::new(HashMap::new());
{
let mut guard = map.lock().unwrap();
let past = Instant::now() - Duration::from_secs(600);
let mut b1 = TokenBucket::new(5, 1.0);
b1.last_refill = past;
guard.insert("idle-full", b1);
let b2 = TokenBucket::new(5, 1.0);
guard.insert("active", b2);
let mut b3 = TokenBucket::new(5, 1.0);
b3.tokens = 2.0;
b3.last_refill = past;
guard.insert("drained", b3);
}
sweep(&map, Duration::from_secs(60));
let guard = map.lock().unwrap();
assert!(
!guard.contains_key("idle-full"),
"idle full bucket should be evicted"
);
assert!(
guard.contains_key("active"),
"recently-touched bucket must remain"
);
assert!(
guard.contains_key("drained"),
"drained-but-old bucket must remain — it still carries state"
);
}
#[test]
fn tokens_cap_at_capacity() {
let mut b = TokenBucket::new(2, 100.0);
sleep(Duration::from_millis(200));
assert!(b.try_acquire(), "first cap-bounded token");
assert!(b.try_acquire(), "second cap-bounded token");
assert!(
!b.try_acquire(),
"3rd acquire must fail — tokens should not have accumulated past capacity"
);
}
}