use std::collections::HashMap;
use std::sync::{Arc, Mutex};
use std::time::Instant;
use crate::domain::{RateLimit, Verdict, check_rate_limit as check};
const DEFAULT_CAPACITY: usize = 4096;
#[derive(Debug)]
struct Window {
opened: Instant,
seen: u32,
}
#[derive(Clone, Debug)]
pub struct RateLimiter {
policy: RateLimit,
capacity: usize,
windows: Arc<Mutex<HashMap<Arc<str>, Window>>>,
}
impl RateLimiter {
#[must_use]
pub fn new(policy: RateLimit) -> Self {
Self::with_capacity(policy, DEFAULT_CAPACITY)
}
#[must_use]
pub fn with_capacity(policy: RateLimit, capacity: usize) -> Self {
Self {
policy,
capacity: capacity.max(1),
windows: Arc::new(Mutex::new(HashMap::new())),
}
}
#[must_use]
pub const fn policy(&self) -> &RateLimit {
&self.policy
}
#[must_use]
pub fn check(&self, subject: &str) -> Verdict {
self.check_at(subject, Instant::now())
}
#[must_use]
pub fn check_at(&self, subject: &str, now: Instant) -> Verdict {
let Ok(mut windows) = self.windows.lock() else {
return Verdict::Allowed {
remaining: self.policy.limit(),
};
};
if windows.len() >= self.capacity {
self.sweep(&mut windows, now);
}
let entry = windows.entry(Arc::from(subject)).or_insert(Window {
opened: now,
seen: 0,
});
let elapsed = now.saturating_duration_since(entry.opened);
if elapsed >= self.policy.window() {
entry.opened = now;
entry.seen = 0;
}
let verdict = check(
&self.policy,
entry.seen,
now.saturating_duration_since(entry.opened),
);
if matches!(verdict, Verdict::Allowed { .. }) {
entry.seen = entry.seen.saturating_add(1);
}
verdict
}
fn sweep(&self, windows: &mut HashMap<Arc<str>, Window>, now: Instant) {
windows.retain(|_, window| {
now.saturating_duration_since(window.opened) < self.policy.window()
});
if windows.len() >= self.capacity {
windows.clear();
}
}
#[must_use]
pub fn tracked(&self) -> usize {
self.windows.lock().map_or(0, |windows| windows.len())
}
}
#[cfg(test)]
mod tests {
use core::num::NonZeroU32;
use core::time::Duration;
use std::time::Instant;
use crate::domain::{RateLimit, Verdict};
use super::RateLimiter;
type TestError = Box<dyn std::error::Error>;
fn limiter_policy(limit: u32, window: Duration) -> Result<RateLimit, TestError> {
Ok(RateLimit::new(
NonZeroU32::new(limit).ok_or("a fixture asked for a zero limit")?,
window,
))
}
fn limiter(limit: u32, window: Duration) -> Result<RateLimiter, TestError> {
Ok(RateLimiter::new(RateLimit::new(
NonZeroU32::new(limit).ok_or("a fixture asked for a zero limit")?,
window,
)))
}
#[test]
fn subjects_do_not_spend_each_others_allowance() -> Result<(), TestError> {
let limiter = limiter(2, Duration::from_secs(60))?;
let now = Instant::now();
for _ in 0..2 {
assert!(
matches!(limiter.check_at("alice", now), Verdict::Allowed { .. }),
"alice should be within her own allowance"
);
}
assert_matches::assert_matches!(limiter.check_at("alice", now), Verdict::Limited { .. });
assert!(
matches!(limiter.check_at("bob", now), Verdict::Allowed { .. }),
"bob must not inherit alice's spent window"
);
Ok(())
}
#[test]
fn a_refusal_does_not_consume_allowance() -> Result<(), TestError> {
let limiter = limiter(1, Duration::from_secs(60))?;
let now = Instant::now();
assert_matches::assert_matches!(limiter.check_at("alice", now), Verdict::Allowed { .. });
for _ in 0..3 {
assert_matches::assert_matches!(
limiter.check_at("alice", now),
Verdict::Limited { .. }
);
}
Ok(())
}
#[test]
fn the_allowance_comes_back_after_the_window() -> Result<(), TestError> {
let limiter = limiter(1, Duration::from_secs(60))?;
let start = Instant::now();
assert_matches::assert_matches!(limiter.check_at("alice", start), Verdict::Allowed { .. });
assert_matches::assert_matches!(
limiter.check_at("alice", start + Duration::from_secs(30)),
Verdict::Limited { .. }
);
assert!(
matches!(
limiter.check_at("alice", start + Duration::from_secs(61)),
Verdict::Allowed { .. }
),
"a spent window must reopen on its own"
);
Ok(())
}
#[test]
fn the_table_stays_bounded_under_distinct_subjects() -> Result<(), TestError> {
let limiter =
RateLimiter::with_capacity(limiter_policy(10, Duration::from_secs(3600))?, 16);
let now = Instant::now();
for index in 0..5000u32 {
let _ = limiter.check_at(&format!("subject-{index}"), now);
}
assert!(
limiter.tracked() <= 16,
"the table grew to {} subjects under 5000 distinct names",
limiter.tracked()
);
Ok(())
}
#[test]
fn sweeping_drops_expired_subjects_and_keeps_live_ones() -> Result<(), TestError> {
let limiter = RateLimiter::with_capacity(limiter_policy(5, Duration::from_secs(60))?, 4);
let start = Instant::now();
for index in 0..3 {
let _ = limiter.check_at(&format!("old-{index}"), start);
}
assert_eq!(limiter.tracked(), 3, "three subjects so far");
let _ = limiter.check_at("live", start + Duration::from_secs(90));
assert_eq!(limiter.tracked(), 4, "the table is now full");
let _ = limiter.check_at("trigger", start + Duration::from_secs(90));
assert_eq!(
limiter.tracked(),
2,
"the sweep should have kept only the two live subjects"
);
Ok(())
}
}