mod middleware;
pub use middleware::rate_limit_middleware;
use redis::aio::ConnectionManager;
#[derive(Clone, Debug)]
pub struct RateLimitTier {
pub name: String,
pub requests_per_window: u64,
pub window_secs: u64,
}
#[derive(Clone, Debug)]
pub struct RateLimitConfig {
pub requests_per_window: u64,
pub window_secs: u64,
pub enabled: bool,
}
impl RateLimitConfig {
pub fn from_env() -> Self {
let requests_per_window = std::env::var("RATE_LIMIT_REQUESTS_PER_WINDOW")
.ok()
.and_then(|v| v.parse().ok())
.unwrap_or(100);
let window_secs = std::env::var("RATE_LIMIT_WINDOW_SECS")
.ok()
.and_then(|v| v.parse().ok())
.unwrap_or(60);
let enabled = std::env::var("RATE_LIMIT_ENABLED")
.ok()
.and_then(|v| v.parse().ok())
.unwrap_or(true);
Self {
requests_per_window,
window_secs,
enabled,
}
}
}
const SLIDING_WINDOW_SCRIPT: &str = r#"
local curr_key = KEYS[1]
local prev_key = KEYS[2]
local limit = tonumber(ARGV[1])
local window = tonumber(ARGV[2])
local now = tonumber(ARGV[3])
local window_start = math.floor(now / window) * window
local elapsed = now - window_start
local weight = (window - elapsed) / window
local prev_count = tonumber(redis.call('GET', prev_key) or '0') or 0
local curr_count = tonumber(redis.call('INCR', curr_key)) or 0
if curr_count == 1 then
redis.call('EXPIRE', curr_key, window * 2)
end
local estimated = math.floor(prev_count * weight) + curr_count
local remaining = math.max(0, limit - estimated)
local reset_at = window_start + window
if estimated > limit then
redis.call('DECR', curr_key)
return {0, remaining, reset_at}
end
return {1, remaining, reset_at}
"#;
impl RateLimitTier {
pub fn new(name: impl Into<String>, requests_per_window: u64, window_secs: u64) -> Self {
Self {
name: name.into(),
requests_per_window,
window_secs,
}
}
}
#[derive(Clone)]
pub struct RateLimiter {
redis: ConnectionManager,
config: RateLimitConfig,
}
pub struct RateLimitResult {
pub allowed: bool,
pub remaining: u64,
pub reset_at: u64,
}
impl RateLimiter {
pub fn new(redis: ConnectionManager, config: RateLimitConfig) -> Self {
Self { redis, config }
}
#[tracing::instrument(skip(self))]
pub async fn check_with_config(
&self,
key: &str,
tier_name: &str,
requests_per_window: u64,
window_secs: u64,
) -> Result<RateLimitResult, redis::RedisError> {
let now = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.expect("system clock before epoch")
.as_secs();
let current_window = (now / window_secs) * window_secs;
let previous_window = current_window - window_secs;
let curr_key = format!("rl:{tier_name}:{key}:{current_window}");
let prev_key = format!("rl:{tier_name}:{key}:{previous_window}");
let mut conn = self.redis.clone();
let result: Vec<i64> = redis::Script::new(SLIDING_WINDOW_SCRIPT)
.key(curr_key)
.key(prev_key)
.arg(requests_per_window)
.arg(window_secs)
.arg(now)
.invoke_async(&mut conn)
.await?;
Ok(RateLimitResult {
allowed: result[0] == 1,
remaining: result[1] as u64,
reset_at: result[2] as u64,
})
}
pub fn is_enabled(&self) -> bool {
self.config.enabled
}
}