use chrono::{DateTime, Utc};
use std::collections::{HashMap, VecDeque};
use std::sync::Mutex;
#[derive(Debug, Clone)]
pub struct RateLimitConfig {
pub requests_per_minute: u32,
pub requests_per_hour: u32,
pub tokens_per_day: u64,
}
impl Default for RateLimitConfig {
fn default() -> Self {
Self {
requests_per_minute: 60,
requests_per_hour: 1000,
tokens_per_day: 1_000_000,
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum RateLimitResult {
Allow,
Deny {
reason: DenyReason,
retry_after: u64,
},
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum DenyReason {
MinuteRateExceeded,
HourRateExceeded,
DailyTokenQuotaExceeded,
}
impl std::fmt::Display for DenyReason {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::MinuteRateExceeded => write!(f, "requests per minute limit exceeded"),
Self::HourRateExceeded => write!(f, "requests per hour limit exceeded"),
Self::DailyTokenQuotaExceeded => write!(f, "daily token quota exceeded"),
}
}
}
#[derive(Debug, Clone)]
pub struct KeyUsageStats {
pub requests_this_minute: u32,
pub requests_this_hour: u32,
pub tokens_today: u64,
pub config: RateLimitConfig,
}
#[derive(Debug, Clone)]
struct SlidingWindow {
timestamps: VecDeque<DateTime<Utc>>,
window_seconds: i64,
}
impl SlidingWindow {
fn new(window_seconds: i64) -> Self {
Self {
timestamps: VecDeque::new(),
window_seconds,
}
}
fn count(&mut self, now: DateTime<Utc>) -> u32 {
let cutoff = now - chrono::Duration::seconds(self.window_seconds);
while let Some(front) = self.timestamps.front() {
if *front < cutoff {
self.timestamps.pop_front();
} else {
break;
}
}
self.timestamps.len() as u32
}
fn record(&mut self, now: DateTime<Utc>) {
self.timestamps.push_back(now);
}
fn seconds_until_slot(&self, now: DateTime<Utc>) -> u64 {
if let Some(front) = self.timestamps.front() {
let expires_at = *front + chrono::Duration::seconds(self.window_seconds);
if expires_at > now {
(expires_at - now).num_seconds().max(1) as u64
} else {
1
}
} else {
0
}
}
fn is_idle(&self, now: DateTime<Utc>) -> bool {
match self.timestamps.back() {
Some(last) => {
let idle_since = now - *last;
idle_since.num_seconds() >= self.window_seconds
}
None => true,
}
}
}
struct KeyState {
minute_window: SlidingWindow,
hour_window: SlidingWindow,
daily_tokens: u64,
daily_reset: DateTime<Utc>,
}
impl KeyState {
fn new(now: DateTime<Utc>) -> Self {
Self {
minute_window: SlidingWindow::new(60),
hour_window: SlidingWindow::new(3600),
daily_tokens: 0,
daily_reset: next_day_start(now),
}
}
fn maybe_reset_daily(&mut self, now: DateTime<Utc>) {
if now >= self.daily_reset {
self.daily_tokens = 0;
self.daily_reset = next_day_start(now);
}
}
}
fn next_day_start(now: DateTime<Utc>) -> DateTime<Utc> {
let tomorrow = now.date_naive().succ_opt().unwrap_or(now.date_naive());
#[allow(clippy::unwrap_used)]
tomorrow.and_hms_opt(0, 0, 0).unwrap().and_utc()
}
pub struct PerKeyRateLimiter {
limiters: Mutex<HashMap<String, KeyState>>,
default_config: RateLimitConfig,
custom_configs: HashMap<String, RateLimitConfig>,
}
impl PerKeyRateLimiter {
pub fn new(default_config: RateLimitConfig) -> Self {
Self {
limiters: Mutex::new(HashMap::new()),
default_config,
custom_configs: HashMap::new(),
}
}
pub fn with_custom_limit(mut self, key: &str, config: RateLimitConfig) -> Self {
self.custom_configs.insert(key.to_string(), config);
self
}
fn config_for(&self, api_key: &str) -> &RateLimitConfig {
self.custom_configs
.get(api_key)
.unwrap_or(&self.default_config)
}
pub fn check(&self, api_key: &str) -> RateLimitResult {
self.check_at(api_key, Utc::now())
}
pub fn check_at(&self, api_key: &str, now: DateTime<Utc>) -> RateLimitResult {
let config = self.config_for(api_key).clone();
let mut map = self
.limiters
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
let state = map
.entry(api_key.to_string())
.or_insert_with(|| KeyState::new(now));
state.maybe_reset_daily(now);
let minute_count = state.minute_window.count(now);
if minute_count >= config.requests_per_minute {
let retry_after = state.minute_window.seconds_until_slot(now);
return RateLimitResult::Deny {
reason: DenyReason::MinuteRateExceeded,
retry_after,
};
}
let hour_count = state.hour_window.count(now);
if hour_count >= config.requests_per_hour {
let retry_after = state.hour_window.seconds_until_slot(now);
return RateLimitResult::Deny {
reason: DenyReason::HourRateExceeded,
retry_after,
};
}
if state.daily_tokens >= config.tokens_per_day {
let retry_after = (state.daily_reset - now).num_seconds().max(1) as u64;
return RateLimitResult::Deny {
reason: DenyReason::DailyTokenQuotaExceeded,
retry_after,
};
}
state.minute_window.record(now);
state.hour_window.record(now);
RateLimitResult::Allow
}
pub fn record_usage(&self, api_key: &str, tokens: u64) {
self.record_usage_at(api_key, tokens, Utc::now());
}
pub fn record_usage_at(&self, api_key: &str, tokens: u64, now: DateTime<Utc>) {
let mut map = self
.limiters
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
let state = map
.entry(api_key.to_string())
.or_insert_with(|| KeyState::new(now));
state.maybe_reset_daily(now);
state.daily_tokens = state.daily_tokens.saturating_add(tokens);
}
pub fn stats(&self, api_key: &str) -> Option<KeyUsageStats> {
self.stats_at(api_key, Utc::now())
}
pub fn stats_at(&self, api_key: &str, now: DateTime<Utc>) -> Option<KeyUsageStats> {
let mut map = self
.limiters
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
let state = map.get_mut(api_key)?;
state.maybe_reset_daily(now);
Some(KeyUsageStats {
requests_this_minute: state.minute_window.count(now),
requests_this_hour: state.hour_window.count(now),
tokens_today: state.daily_tokens,
config: self.config_for(api_key).clone(),
})
}
pub fn cleanup(&self) {
self.cleanup_at(Utc::now());
}
pub fn cleanup_at(&self, now: DateTime<Utc>) {
let mut map = self
.limiters
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
map.retain(|_, state| {
!state.minute_window.is_idle(now) || !state.hour_window.is_idle(now)
});
}
}
#[cfg(test)]
#[allow(clippy::unwrap_used, clippy::expect_used)]
mod tests {
use super::*;
use chrono::TimeZone;
fn fixed_time() -> DateTime<Utc> {
Utc.with_ymd_and_hms(2026, 4, 1, 12, 0, 0).unwrap()
}
fn default_config() -> RateLimitConfig {
RateLimitConfig {
requests_per_minute: 5,
requests_per_hour: 100,
tokens_per_day: 10_000,
}
}
#[test]
fn test_under_limit_allows() {
let limiter = PerKeyRateLimiter::new(default_config());
let now = fixed_time();
for i in 0..5 {
let t = now + chrono::Duration::seconds(i);
assert_eq!(limiter.check_at("key-a", t), RateLimitResult::Allow);
}
}
#[test]
fn test_at_limit_denies() {
let limiter = PerKeyRateLimiter::new(default_config());
let now = fixed_time();
for i in 0..5 {
assert_eq!(
limiter.check_at("key-a", now + chrono::Duration::seconds(i)),
RateLimitResult::Allow
);
}
let result = limiter.check_at("key-a", now + chrono::Duration::seconds(5));
assert!(matches!(
result,
RateLimitResult::Deny {
reason: DenyReason::MinuteRateExceeded,
..
}
));
}
#[test]
fn test_over_limit_retry_after() {
let limiter = PerKeyRateLimiter::new(default_config());
let now = fixed_time();
for i in 0..5 {
limiter.check_at("key-a", now + chrono::Duration::seconds(i));
}
if let RateLimitResult::Deny { retry_after, .. } =
limiter.check_at("key-a", now + chrono::Duration::seconds(10))
{
assert!(retry_after > 0);
assert!(retry_after <= 60);
} else {
panic!("Expected Deny");
}
}
#[test]
fn test_per_key_isolation() {
let limiter = PerKeyRateLimiter::new(default_config());
let now = fixed_time();
for i in 0..5 {
limiter.check_at("key-a", now + chrono::Duration::seconds(i));
}
assert!(matches!(
limiter.check_at("key-a", now + chrono::Duration::seconds(5)),
RateLimitResult::Deny { .. }
));
assert_eq!(
limiter.check_at("key-b", now + chrono::Duration::seconds(5)),
RateLimitResult::Allow
);
}
#[test]
fn test_custom_config_per_key() {
let limiter = PerKeyRateLimiter::new(default_config()).with_custom_limit(
"premium-key",
RateLimitConfig {
requests_per_minute: 10,
requests_per_hour: 500,
tokens_per_day: 100_000,
},
);
let now = fixed_time();
for i in 0..5 {
assert_eq!(
limiter.check_at("basic-key", now + chrono::Duration::seconds(i)),
RateLimitResult::Allow
);
}
assert!(matches!(
limiter.check_at("basic-key", now + chrono::Duration::seconds(5)),
RateLimitResult::Deny { .. }
));
for i in 0..10 {
assert_eq!(
limiter.check_at("premium-key", now + chrono::Duration::seconds(i)),
RateLimitResult::Allow
);
}
assert!(matches!(
limiter.check_at("premium-key", now + chrono::Duration::seconds(10)),
RateLimitResult::Deny { .. }
));
}
#[test]
fn test_sliding_window_expiry() {
let limiter = PerKeyRateLimiter::new(default_config());
let now = fixed_time();
for i in 0..5 {
limiter.check_at("key-a", now + chrono::Duration::seconds(i));
}
assert!(matches!(
limiter.check_at("key-a", now + chrono::Duration::seconds(10)),
RateLimitResult::Deny { .. }
));
let later = now + chrono::Duration::seconds(61);
assert_eq!(limiter.check_at("key-a", later), RateLimitResult::Allow);
}
#[test]
fn test_token_quota_tracking() {
let limiter = PerKeyRateLimiter::new(RateLimitConfig {
requests_per_minute: 100,
requests_per_hour: 1000,
tokens_per_day: 1000,
});
let now = fixed_time();
assert_eq!(limiter.check_at("key-a", now), RateLimitResult::Allow);
limiter.record_usage_at("key-a", 1000, now);
let result = limiter.check_at("key-a", now + chrono::Duration::seconds(1));
assert!(matches!(
result,
RateLimitResult::Deny {
reason: DenyReason::DailyTokenQuotaExceeded,
..
}
));
}
#[test]
fn test_daily_reset() {
let limiter = PerKeyRateLimiter::new(RateLimitConfig {
requests_per_minute: 100,
requests_per_hour: 1000,
tokens_per_day: 500,
});
let now = fixed_time();
limiter.check_at("key-a", now);
limiter.record_usage_at("key-a", 500, now);
assert!(matches!(
limiter.check_at("key-a", now + chrono::Duration::seconds(1)),
RateLimitResult::Deny {
reason: DenyReason::DailyTokenQuotaExceeded,
..
}
));
let tomorrow = now + chrono::Duration::hours(13); assert_eq!(limiter.check_at("key-a", tomorrow), RateLimitResult::Allow);
let stats = limiter.stats_at("key-a", tomorrow).unwrap();
assert_eq!(stats.tokens_today, 0);
}
#[test]
fn test_cleanup_expired_entries() {
let limiter = PerKeyRateLimiter::new(default_config());
let now = fixed_time();
limiter.check_at("key-a", now);
limiter.check_at("key-b", now);
assert!(limiter.stats_at("key-a", now).is_some());
assert!(limiter.stats_at("key-b", now).is_some());
let later = now + chrono::Duration::hours(2);
limiter.cleanup_at(later);
assert!(limiter.stats_at("key-a", later).is_none());
assert!(limiter.stats_at("key-b", later).is_none());
}
#[test]
fn test_cleanup_keeps_active() {
let limiter = PerKeyRateLimiter::new(default_config());
let now = fixed_time();
limiter.check_at("key-a", now);
let recent = now + chrono::Duration::seconds(30);
limiter.check_at("key-b", recent);
let cleanup_time = now + chrono::Duration::seconds(65);
limiter.cleanup_at(cleanup_time);
assert!(limiter.stats_at("key-a", cleanup_time).is_some());
let much_later = now + chrono::Duration::hours(2);
limiter.cleanup_at(much_later);
assert!(limiter.stats_at("key-a", much_later).is_none());
}
#[test]
fn test_stats_accuracy() {
let limiter = PerKeyRateLimiter::new(default_config());
let now = fixed_time();
for i in 0..3 {
limiter.check_at("key-a", now + chrono::Duration::seconds(i));
}
limiter.record_usage_at("key-a", 42, now);
let stats = limiter
.stats_at("key-a", now + chrono::Duration::seconds(3))
.unwrap();
assert_eq!(stats.requests_this_minute, 3);
assert_eq!(stats.requests_this_hour, 3);
assert_eq!(stats.tokens_today, 42);
assert_eq!(stats.config.requests_per_minute, 5);
}
#[test]
fn test_hour_limit_enforced() {
let config = RateLimitConfig {
requests_per_minute: 100, requests_per_hour: 10, tokens_per_day: 1_000_000,
};
let limiter = PerKeyRateLimiter::new(config);
let now = fixed_time();
for i in 0..10 {
let t = now + chrono::Duration::minutes(i);
assert_eq!(limiter.check_at("key-a", t), RateLimitResult::Allow);
}
let t = now + chrono::Duration::minutes(10);
assert!(matches!(
limiter.check_at("key-a", t),
RateLimitResult::Deny {
reason: DenyReason::HourRateExceeded,
..
}
));
}
#[test]
fn test_concurrent_access() {
use std::sync::Arc;
use std::thread;
let limiter = Arc::new(PerKeyRateLimiter::new(RateLimitConfig {
requests_per_minute: 1000,
requests_per_hour: 10_000,
tokens_per_day: 1_000_000,
}));
let mut handles = vec![];
for t in 0..10 {
let limiter = limiter.clone();
handles.push(thread::spawn(move || {
let key = format!("thread-key-{}", t % 3);
for _ in 0..100 {
let _ = limiter.check(&key);
}
}));
}
for h in handles {
h.join().unwrap();
}
}
#[test]
fn test_stats_unknown_key() {
let limiter = PerKeyRateLimiter::new(default_config());
assert!(limiter.stats("nonexistent").is_none());
}
#[test]
fn test_record_usage_creates_entry() {
let limiter = PerKeyRateLimiter::new(default_config());
let now = fixed_time();
limiter.record_usage_at("new-key", 500, now);
let stats = limiter.stats_at("new-key", now).unwrap();
assert_eq!(stats.tokens_today, 500);
assert_eq!(stats.requests_this_minute, 0);
}
#[test]
fn test_token_saturation() {
let limiter = PerKeyRateLimiter::new(default_config());
let now = fixed_time();
limiter.record_usage_at("key-a", u64::MAX - 10, now);
limiter.record_usage_at("key-a", 100, now);
let stats = limiter.stats_at("key-a", now).unwrap();
assert_eq!(stats.tokens_today, u64::MAX);
}
#[test]
fn test_default_config() {
let config = RateLimitConfig::default();
assert_eq!(config.requests_per_minute, 60);
assert_eq!(config.requests_per_hour, 1000);
assert_eq!(config.tokens_per_day, 1_000_000);
}
#[test]
fn test_deny_reason_display() {
assert_eq!(
DenyReason::MinuteRateExceeded.to_string(),
"requests per minute limit exceeded"
);
assert_eq!(
DenyReason::HourRateExceeded.to_string(),
"requests per hour limit exceeded"
);
assert_eq!(
DenyReason::DailyTokenQuotaExceeded.to_string(),
"daily token quota exceeded"
);
}
}