use super::*;
fn limiter() -> KeyedRateLimiter {
KeyedRateLimiter::new(1000.0, 3)
}
#[test]
fn keys_are_independent() {
let k = limiter();
for _ in 0..3 {
assert_eq!(k.try_acquire_at(0, "acct-a", 1), Acquire::Ok);
}
assert!(matches!(
k.try_acquire_at(0, "acct-a", 1),
Acquire::Retry(_)
));
assert_eq!(k.try_acquire_at(0, "acct-b", 1), Acquire::Ok);
}
#[test]
fn per_key_refill_follows_the_clock() {
let k = limiter();
for _ in 0..3 {
assert_eq!(k.try_acquire_at(0, "sym-ESU5", 1), Acquire::Ok);
}
assert!(matches!(
k.try_acquire_at(0, "sym-ESU5", 1),
Acquire::Retry(_)
));
assert_eq!(k.try_acquire_at(1_000_000, "sym-ESU5", 1), Acquire::Ok);
assert!(matches!(
k.try_acquire_at(1_500_000, "sym-ESU5", 1),
Acquire::Retry(_)
));
}
#[test]
fn retry_after_matches_the_base_limiter() {
let k = limiter();
for _ in 0..3 {
k.try_acquire_at(0, "acct-a", 1);
}
match k.try_acquire_at(0, "acct-a", 1) {
Acquire::Retry(d) => assert_eq!(d, Duration::from_nanos(1_000_000)),
other => panic!("expected a retry-after, got {other:?}"),
}
}
#[test]
fn weighted_draw_costs_n_periods() {
let k = limiter();
assert_eq!(k.try_acquire_at(0, "acct-a", 3), Acquire::Ok);
assert!(matches!(
k.try_acquire_at(0, "acct-a", 1),
Acquire::Retry(_)
));
}
#[test]
fn weight_above_burst_is_unattainable() {
let k = limiter();
assert_eq!(
k.try_acquire_at(0, "acct-a", 4),
Acquire::Unattainable { burst_capacity: 3 }
);
assert!(k.is_empty());
}
#[test]
fn zero_weight_is_a_free_probe() {
let k = limiter();
assert_eq!(k.try_acquire_at(0, "acct-a", 0), Acquire::Ok);
assert!(k.is_empty(), "a zero-weight probe tracks no state");
}
#[test]
fn tracked_key_count_follows_granted_keys() {
let k = limiter();
for _ in 0..3 {
k.try_acquire_at(0, "hot", 1);
}
k.try_acquire_at(0, "warm", 1);
assert_eq!(k.len(), 2);
assert!(matches!(
k.try_acquire_at(0, "never-granted", 9),
Acquire::Unattainable { .. }
));
assert_eq!(k.len(), 2);
}
#[test]
fn time_until_ready_does_not_spend() {
let k = limiter();
assert_eq!(k.time_until_ready_at(0, "acct-a", 3), Some(Duration::ZERO));
assert_eq!(k.time_until_ready_at(0, "acct-a", 3), Some(Duration::ZERO));
assert!(k.is_empty());
assert_eq!(k.try_acquire_at(0, "acct-a", 3), Acquire::Ok);
assert_eq!(
k.time_until_ready_at(0, "acct-a", 1),
Some(Duration::from_nanos(1_000_000))
);
assert_eq!(k.time_until_ready_at(0, "acct-a", 9), None);
}
#[test]
fn retain_active_evicts_idle_keys_only() {
let k = limiter();
k.try_acquire_at(0, "idle", 1); k.try_acquire_at(5_000_000, "busy", 3); assert_eq!(k.len(), 2);
assert_eq!(k.retain_active_at(5_000_000), 1, "the idle key goes");
assert_eq!(k.len(), 1);
for _ in 0..3 {
assert_eq!(k.try_acquire_at(5_000_000, "idle", 1), Acquire::Ok);
}
}
#[test]
fn forget_and_clear_drop_state() {
let k = limiter();
k.try_acquire_at(0, "a", 3);
k.try_acquire_at(0, "b", 3);
assert!(k.forget("a"));
assert!(!k.forget("a"), "forgetting twice is not an error");
assert_eq!(k.len(), 1);
assert_eq!(k.try_acquire_at(0, "a", 3), Acquire::Ok, "reset by forget");
k.clear();
assert!(k.is_empty());
}
#[test]
fn config_accessors_round_trip() {
let k = KeyedRateLimiter::with_shards(2000.0, 8, 4);
assert!((k.rate_per_sec() - 2000.0).abs() < 1.0);
assert_eq!(k.burst_capacity(), 8);
assert_eq!(k.shard_count(), 4);
assert_eq!(KeyedRateLimiter::with_shards(1000.0, 1, 0).shard_count(), 1);
assert!(k.now_ns() < 1_000_000_000, "clock starts at the origin");
}
#[test]
fn concurrent_keys_do_not_double_spend() {
use std::sync::Arc;
use std::sync::atomic::{AtomicUsize, Ordering};
use std::thread;
let k = Arc::new(KeyedRateLimiter::new(10.0, 50));
let granted = Arc::new(AtomicUsize::new(0));
let mut handles = Vec::new();
for _ in 0..8 {
let k = k.clone();
let granted = granted.clone();
handles.push(thread::spawn(move || {
for i in 0..500 {
if k.try_acquire(&format!("key-{}", i % 4)) {
granted.fetch_add(1, Ordering::Relaxed);
}
}
}));
}
for h in handles {
h.join().unwrap();
}
let g = granted.load(Ordering::Relaxed);
assert!(
g >= 200,
"4 keys x burst 50 should all be spendable, got {g}"
);
assert!(
g <= 210,
"8 threads must not double-spend 4 bursts, got {g}"
);
assert_eq!(k.len(), 4);
}