use std::collections::HashMap;
use std::net::IpAddr;
use std::time::{Duration, Instant};
pub struct RateLimiter {
hits: HashMap<IpAddr, (u32, Instant)>,
max_per_window: u32,
window: Duration,
max_tracked: usize,
}
impl RateLimiter {
pub fn new(max_per_window: u32, window: Duration, max_tracked: usize) -> Self {
Self {
hits: HashMap::new(),
max_per_window,
window,
max_tracked: max_tracked.max(1),
}
}
pub fn allow(&mut self, ip: IpAddr) -> bool {
self.allow_at(ip, Instant::now())
}
fn allow_at(&mut self, ip: IpAddr, now: Instant) -> bool {
if self.hits.len() >= self.max_tracked {
let window = self.window;
self.hits
.retain(|_, (_, seen)| now.duration_since(*seen) <= window);
if self.hits.len() >= self.max_tracked && !self.hits.contains_key(&ip) {
return false;
}
}
let entry = self.hits.entry(ip).or_insert((0, now));
if now.duration_since(entry.1) > self.window {
*entry = (0, now); }
if entry.0 >= self.max_per_window {
return false;
}
entry.0 += 1;
true
}
pub fn tracked(&self) -> usize {
self.hits.len()
}
}
#[cfg(test)]
mod tests {
use super::*;
fn ip(n: u8) -> IpAddr {
IpAddr::from([10, 0, 0, n])
}
#[test]
fn allows_up_to_the_limit_then_denies() {
let mut rl = RateLimiter::new(3, Duration::from_secs(60), 1000);
let t = Instant::now();
assert!(rl.allow_at(ip(1), t));
assert!(rl.allow_at(ip(1), t));
assert!(rl.allow_at(ip(1), t));
assert!(!rl.allow_at(ip(1), t), "la 4ª en la ventana se deniega");
}
#[test]
fn window_resets_after_expiry() {
let mut rl = RateLimiter::new(1, Duration::from_secs(60), 1000);
let t0 = Instant::now();
assert!(rl.allow_at(ip(1), t0));
assert!(!rl.allow_at(ip(1), t0), "consumida la cuota");
let t1 = t0 + Duration::from_secs(120);
assert!(rl.allow_at(ip(1), t1));
}
#[test]
fn independent_ips_have_independent_quotas() {
let mut rl = RateLimiter::new(1, Duration::from_secs(60), 1000);
let t = Instant::now();
assert!(rl.allow_at(ip(1), t));
assert!(rl.allow_at(ip(2), t), "otra IP tiene su propia cuota");
}
#[test]
fn caps_tracked_ips_and_denies_new_ones_when_full() {
let mut rl = RateLimiter::new(10, Duration::from_secs(60), 4);
let t = Instant::now();
for n in 0..4 {
assert!(rl.allow_at(ip(n), t));
}
assert_eq!(rl.tracked(), 4);
assert!(!rl.allow_at(ip(99), t), "IP nueva con el mapa lleno: denegada");
assert_eq!(rl.tracked(), 4, "el mapa no crece más allá del tope");
}
#[test]
fn expired_entries_are_evicted_to_make_room() {
let mut rl = RateLimiter::new(10, Duration::from_secs(60), 4);
let t0 = Instant::now();
for n in 0..4 {
assert!(rl.allow_at(ip(n), t0));
}
let t1 = t0 + Duration::from_secs(600);
assert!(rl.allow_at(ip(99), t1));
assert!(rl.tracked() <= 4);
}
}