use std::collections::HashMap;
use std::sync::Mutex;
use std::time::Instant;
#[derive(Clone, Copy, Debug)]
pub struct RateLimitConfig {
pub capacity: u32,
pub refill_per_sec: u32,
}
impl RateLimitConfig {
#[must_use]
pub fn disabled(&self) -> bool {
self.capacity == 0
}
}
struct Bucket {
tokens: f64,
last: Instant,
}
const MAX_KEY_BYTES: usize = 512;
const PRUNE_ABOVE: usize = 4_096;
const MAX_BUCKETS: usize = 65_536;
pub struct RateLimiters {
config: RateLimitConfig,
buckets: Mutex<HashMap<String, Bucket>>,
prune_above: usize,
max_buckets: usize,
}
impl std::fmt::Debug for RateLimiters {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
let live = self.buckets.lock().ok().map(|b| b.len());
f.debug_struct("RateLimiters")
.field("config", &self.config)
.field("live_buckets", &live)
.finish_non_exhaustive()
}
}
impl RateLimiters {
#[must_use]
pub fn new(config: RateLimitConfig) -> Self {
Self {
config,
buckets: Mutex::new(HashMap::new()),
prune_above: PRUNE_ABOVE,
max_buckets: MAX_BUCKETS,
}
}
#[cfg(test)]
fn with_bounds(config: RateLimitConfig, prune_above: usize, max_buckets: usize) -> Self {
Self {
config,
buckets: Mutex::new(HashMap::new()),
prune_above,
max_buckets,
}
}
pub fn allow(&self, key: &str) -> bool {
if self.config.disabled() {
return true;
}
let cap = f64::from(self.config.capacity);
let refill = f64::from(self.config.refill_per_sec);
let now = Instant::now();
let key = Self::bucket_key(key);
let mut buckets = self.buckets.lock().expect("rate-limit mutex poisoned");
if buckets.len() >= self.prune_above {
buckets.retain(|_, b| {
let refilled = now
.duration_since(b.last)
.as_secs_f64()
.mul_add(refill, b.tokens)
.min(cap);
refilled < cap
});
}
if buckets.len() >= self.max_buckets && !buckets.contains_key(&key) {
return false;
}
let bucket = buckets.entry(key).or_insert(Bucket {
tokens: cap,
last: now,
});
let elapsed = now.duration_since(bucket.last).as_secs_f64();
bucket.tokens = elapsed.mul_add(refill, bucket.tokens).min(cap);
bucket.last = now;
if bucket.tokens >= 1.0 {
bucket.tokens -= 1.0;
true
} else {
false
}
}
fn bucket_key(key: &str) -> String {
use std::hash::{Hash, Hasher};
if key.len() <= MAX_KEY_BYTES {
return key.to_string();
}
let mut h = std::collections::hash_map::DefaultHasher::new();
key.hash(&mut h);
format!("oversize-{:016x}", h.finish())
}
#[cfg(test)]
fn longest_key(&self) -> usize {
self.buckets
.lock()
.expect("rate-limit mutex poisoned")
.keys()
.map(String::len)
.max()
.unwrap_or(0)
}
#[cfg(test)]
fn tracked(&self) -> usize {
self.buckets
.lock()
.expect("rate-limit mutex poisoned")
.len()
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn disabled_always_admits_and_allocates_nothing() {
let rl = RateLimiters::new(RateLimitConfig {
capacity: 0,
refill_per_sec: 0,
});
for _ in 0..1000 {
assert!(rl.allow("k"));
}
assert_eq!(rl.tracked(), 0, "disabled never builds a bucket");
}
#[test]
fn spends_the_burst_then_rejects() {
let rl = RateLimiters::new(RateLimitConfig {
capacity: 3,
refill_per_sec: 0,
});
assert!(rl.allow("k"));
assert!(rl.allow("k"));
assert!(rl.allow("k"));
assert!(!rl.allow("k"), "burst is spent");
}
#[test]
fn buckets_are_per_credential() {
let rl = RateLimiters::new(RateLimitConfig {
capacity: 1,
refill_per_sec: 0,
});
assert!(rl.allow("a"));
assert!(!rl.allow("a"), "a's single token is spent");
assert!(rl.allow("b"), "b has its own bucket");
assert_eq!(rl.tracked(), 2);
}
#[test]
fn an_oversize_credential_does_not_become_an_oversize_key() {
let rl = RateLimiters::new(RateLimitConfig {
capacity: 1,
refill_per_sec: 0,
});
let huge = "z".repeat(1_000_000);
assert!(rl.allow(&huge));
assert_eq!(rl.tracked(), 1);
assert!(
rl.longest_key() < MAX_KEY_BYTES + 64,
"an oversize credential must not be stored verbatim"
);
assert!(!rl.allow(&huge), "the bounded key still keys a real bucket");
}
#[test]
fn distinct_credentials_cannot_grow_the_map_without_bound() {
let rl = RateLimiters::with_bounds(
RateLimitConfig {
capacity: 1,
refill_per_sec: 1_000_000,
},
16,
1_024,
);
for i in 0..2_000 {
assert!(rl.allow(&format!("attacker-{i}")));
}
assert!(
rl.tracked() <= 16,
"map grew to {} entries; pruning is not reclaiming",
rl.tracked()
);
}
#[test]
fn pruning_a_full_bucket_is_lossless() {
let rl = RateLimiters::with_bounds(
RateLimitConfig {
capacity: 2,
refill_per_sec: 100,
},
1,
1_024,
);
assert!(rl.allow("k"));
assert!(rl.allow("k"));
std::thread::sleep(std::time::Duration::from_millis(50));
assert!(rl.allow("k"), "first token back after a full refill");
assert!(
rl.allow("k"),
"and the second — the whole burst, as if fresh"
);
}
#[test]
fn a_spending_flood_is_shed_rather_than_grown() {
let rl = RateLimiters::with_bounds(
RateLimitConfig {
capacity: 4,
refill_per_sec: 0,
},
1_000_000, 32,
);
for i in 0..500 {
rl.allow(&format!("attacker-{i}"));
}
assert_eq!(rl.tracked(), 32, "the ceiling is what stops the growth");
}
#[test]
fn refills_over_time() {
let rl = RateLimiters::new(RateLimitConfig {
capacity: 1,
refill_per_sec: 100,
});
assert!(rl.allow("k"));
assert!(!rl.allow("k"), "token spent, and 10ms have not passed");
std::thread::sleep(std::time::Duration::from_millis(50));
assert!(rl.allow("k"), "token refilled after the sleep");
}
}