use std::time::Instant;
use dashmap::DashMap;
#[derive(Debug)]
struct TokenBucket {
tokens: f64,
capacity: f64,
refill_rate: f64,
last_refill: Instant,
}
impl TokenBucket {
#[cfg(test)]
fn new(rpm: u32) -> Self {
Self::new_with_window(rpm, 60)
}
fn new_with_window(rpm: u32, window_secs: u64) -> Self {
let capacity = f64::from(rpm);
let window = window_secs.max(1) as f64;
Self {
tokens: capacity,
capacity,
refill_rate: capacity / window,
last_refill: Instant::now(),
}
}
fn try_consume(&mut self) -> Result<(), u64> {
self.refill();
if self.tokens >= 1.0 {
self.tokens -= 1.0;
Ok(())
} else {
let deficit = 1.0 - self.tokens;
let wait_secs = (deficit / self.refill_rate).ceil() as u64;
Err(wait_secs.max(1))
}
}
fn refill(&mut self) {
let now = Instant::now();
let elapsed = now.duration_since(self.last_refill).as_secs_f64();
self.tokens = (self.tokens + elapsed * self.refill_rate).min(self.capacity);
self.last_refill = now;
}
}
pub struct RateLimiter {
buckets: DashMap<String, TokenBucket>,
rpm: u32,
window_secs: u64,
}
impl RateLimiter {
pub fn new(rpm: u32) -> Self {
let window_secs = std::env::var("AA_RATE_LIMIT_WINDOW_SECS")
.ok()
.and_then(|v| v.parse::<u64>().ok())
.unwrap_or(60);
Self::new_with_window(rpm, window_secs)
}
pub fn new_with_window(rpm: u32, window_secs: u64) -> Self {
Self {
buckets: DashMap::new(),
rpm,
window_secs: window_secs.max(1),
}
}
pub fn check(&self, key_id: &str) -> Result<(), u64> {
let mut bucket = self
.buckets
.entry(key_id.to_string())
.or_insert_with(|| TokenBucket::new_with_window(self.rpm, self.window_secs));
bucket.try_consume()
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_token_bucket_allows_within_limit() {
let mut bucket = TokenBucket::new(100);
for _ in 0..100 {
assert!(bucket.try_consume().is_ok());
}
}
#[test]
fn test_token_bucket_rejects_over_limit() {
let mut bucket = TokenBucket::new(10);
for _ in 0..10 {
bucket.try_consume().unwrap();
}
let result = bucket.try_consume();
assert!(result.is_err(), "should reject when tokens exhausted");
let retry_after = result.unwrap_err();
assert!(retry_after >= 1, "retry_after should be at least 1 second");
}
#[test]
fn test_token_bucket_refills_over_time() {
let mut bucket = TokenBucket::new(60); for _ in 0..60 {
bucket.try_consume().unwrap();
}
assert!(bucket.try_consume().is_err());
bucket.last_refill = Instant::now() - std::time::Duration::from_secs(2);
assert!(bucket.try_consume().is_ok(), "should have tokens after refill");
}
#[test]
fn test_rate_limiter_per_key_isolation() {
let limiter = RateLimiter::new(5);
for _ in 0..5 {
limiter.check("key-a").unwrap();
}
assert!(limiter.check("key-a").is_err(), "key-a should be exhausted");
assert!(limiter.check("key-b").is_ok(), "key-b should be independent");
}
}