use std::collections::HashMap;
use std::net::IpAddr;
use std::sync::Mutex;
use std::time::{Duration, Instant};
#[derive(Debug)]
pub struct IpRateLimiter {
max_events: u32,
window: Duration,
max_tracked_ips: usize,
buckets: Mutex<HashMap<IpAddr, (u32, Instant)>>,
}
impl IpRateLimiter {
#[must_use]
pub fn new(max_events: u32, window: Duration) -> Self {
Self {
max_events: max_events.max(1),
window,
max_tracked_ips: max_tracked_ips_from_env(),
buckets: Mutex::new(HashMap::new()),
}
}
#[must_use]
pub fn from_env(var: &str, default_max: u32, default_window_secs: u64) -> Self {
let max = std::env::var(var)
.ok()
.and_then(|s| s.parse().ok())
.unwrap_or(default_max);
let window_secs = std::env::var(format!("{var}_WINDOW_SECS"))
.ok()
.and_then(|s| s.parse().ok())
.unwrap_or(default_window_secs);
Self::new(max, Duration::from_secs(window_secs.max(1)))
}
pub fn allow(&self, ip: IpAddr) -> bool {
let mut guard = self.buckets.lock().expect("rate limit lock poisoned");
let now = Instant::now();
self.evict_stale(&mut guard, now);
let entry = guard.entry(ip).or_insert((0, now));
if now.duration_since(entry.1) >= self.window {
*entry = (0, now);
}
if entry.0 >= self.max_events {
return false;
}
entry.0 += 1;
true
}
#[must_use]
pub fn is_blocked(&self, ip: IpAddr) -> bool {
let mut guard = self.buckets.lock().expect("rate limit lock poisoned");
let now = Instant::now();
self.evict_stale(&mut guard, now);
let Some((count, start)) = guard.get(&ip) else {
return false;
};
if now.duration_since(*start) >= self.window {
return false;
}
*count >= self.max_events
}
fn evict_stale(&self, guard: &mut HashMap<IpAddr, (u32, Instant)>, now: Instant) {
guard.retain(|_, (_, start)| now.duration_since(*start) < self.window);
if guard.len() <= self.max_tracked_ips {
return;
}
let mut entries: Vec<_> = guard.iter().map(|(ip, (_, start))| (*ip, *start)).collect();
entries.sort_by_key(|(_, start)| *start);
let remove = guard.len().saturating_sub(self.max_tracked_ips);
for (ip, _) in entries.into_iter().take(remove) {
guard.remove(&ip);
}
}
}
fn max_tracked_ips_from_env() -> usize {
std::env::var("NAUTALID_RATE_LIMIT_MAX_IPS")
.ok()
.and_then(|s| s.parse().ok())
.unwrap_or(10_000)
.max(256)
}
#[cfg(test)]
mod tests {
use super::*;
use std::net::{IpAddr, Ipv4Addr};
#[test]
fn blocks_after_max_events() {
let lim = IpRateLimiter::new(2, Duration::from_secs(60));
let ip = IpAddr::V4(Ipv4Addr::LOCALHOST);
assert!(lim.allow(ip));
assert!(lim.allow(ip));
assert!(!lim.allow(ip));
assert!(lim.is_blocked(ip));
}
#[test]
fn distinct_ips_independent() {
let lim = IpRateLimiter::new(1, Duration::from_secs(60));
let a = IpAddr::V4(Ipv4Addr::new(1, 2, 3, 4));
let b = IpAddr::V4(Ipv4Addr::new(5, 6, 7, 8));
assert!(lim.allow(a));
assert!(!lim.allow(a));
assert!(lim.allow(b));
}
#[test]
fn evicts_stale_ips_before_tracking_new() {
let lim = IpRateLimiter::new(5, Duration::from_millis(1));
let ip = IpAddr::V4(Ipv4Addr::LOCALHOST);
assert!(lim.allow(ip));
std::thread::sleep(Duration::from_millis(2));
assert!(lim.allow(ip));
}
}