use std::sync::atomic::{AtomicU64, Ordering};
use std::time::Duration;
use async_trait::async_trait;
use cratefield_core::{Config, Decision, RateLimitError, RateLimiter};
use redis::aio::ConnectionManager;
const DEFAULT_LIMIT: u64 = 100;
const DEFAULT_PERIOD_SECS: u64 = 60;
const SLIDING_WINDOW: &str = r"
redis.call('ZREMRANGEBYSCORE', KEYS[1], '-inf', tonumber(ARGV[1]) - tonumber(ARGV[2]))
local count = redis.call('ZCARD', KEYS[1])
if count < tonumber(ARGV[3]) then
redis.call('ZADD', KEYS[1], ARGV[1], ARGV[1] .. ':' .. ARGV[4])
redis.call('PEXPIRE', KEYS[1], ARGV[2])
return {1, 0}
end
local oldest = redis.call('ZRANGE', KEYS[1], 0, 0, 'WITHSCORES')
local retry = tonumber(ARGV[2])
if oldest[2] then
retry = tonumber(oldest[2]) + tonumber(ARGV[2]) - tonumber(ARGV[1])
end
redis.call('PEXPIRE', KEYS[1], ARGV[2])
return {0, math.max(retry, 0)}
";
pub struct RedisRateLimiter {
conn: ConnectionManager,
script: redis::Script,
limit: u64,
window_ms: u64,
nonce: AtomicU64,
}
impl RedisRateLimiter {
pub fn new(conn: ConnectionManager, limit: u64, window: Duration) -> Self {
Self {
conn,
script: redis::Script::new(SLIDING_WINDOW),
limit,
window_ms: u64::try_from(window.as_millis()).unwrap_or(1).max(1),
nonce: AtomicU64::new(0),
}
}
#[must_use]
pub fn from_env(conn: ConnectionManager, config: &dyn Config) -> Self {
let limit = config
.get("RATE_LIMIT_MAX")
.and_then(|raw| raw.parse().ok())
.unwrap_or(DEFAULT_LIMIT);
let period_secs = config
.get("RATE_LIMIT_PERIOD_SECS")
.and_then(|raw| raw.parse().ok())
.unwrap_or(DEFAULT_PERIOD_SECS);
Self::new(conn, limit, Duration::from_secs(period_secs))
}
}
fn now_ms() -> u64 {
u64::try_from(time::OffsetDateTime::now_utc().unix_timestamp_nanos() / 1_000_000).unwrap_or(0)
}
#[async_trait]
impl RateLimiter for RedisRateLimiter {
async fn limit(&self, key: &str) -> Result<Decision, RateLimitError> {
let mut conn = self.conn.clone();
let timestamp_ms = now_ms();
let member_nonce = self.nonce.fetch_add(1, Ordering::Relaxed);
let outcome: Vec<i64> = self
.script
.key(format!("rl:{key}"))
.arg(timestamp_ms)
.arg(self.window_ms)
.arg(self.limit)
.arg(member_nonce)
.invoke_async(&mut conn)
.await
.map_err(|err| RateLimitError::Transport(err.to_string()))?;
let (allowed, retry_ms) = (
outcome.first().copied().unwrap_or(0),
outcome.get(1).copied().unwrap_or(0),
);
Ok(Decision {
ok: allowed == 1,
retry_after: if retry_ms > 0 {
Some(Duration::from_millis(retry_ms.unsigned_abs()))
} else {
None
},
quota: None,
})
}
}