use crate::store::Cache;
use rustlavel_core::Result;
use std::sync::Arc;
use std::time::{Duration, SystemTime, UNIX_EPOCH};
const NAMESPACE: &str = "rustlavel:throttle:";
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct RateLimit {
pub limit: u64,
pub used: u64,
pub remaining: u64,
pub reset_after: Duration,
pub exceeded: bool,
}
impl RateLimit {
pub fn retry_after_seconds(&self) -> u64 {
self.reset_after.as_secs().max(1)
}
pub fn reset_at(&self) -> u64 {
now_millis().div_euclid(1000) + self.reset_after.as_secs()
}
}
#[derive(Clone)]
pub struct RateLimiter {
store: Arc<dyn Cache>,
}
impl RateLimiter {
pub fn new(store: Arc<dyn Cache>) -> Self {
RateLimiter { store }
}
pub fn with_driver(store: impl Cache) -> Self {
RateLimiter { store: Arc::new(store) }
}
pub async fn attempt(&self, key: &str, limit: u64, window: Duration) -> Result<RateLimit> {
let window_millis = window.as_millis().max(1) as u64;
let now = now_millis();
let slot = now / window_millis;
let counter = format!("{NAMESPACE}{key}:{slot}");
let ttl = Duration::from_millis(window_millis + 1_000);
let used = self.store.increment_within(&counter, 1, ttl).await?.max(0) as u64;
let window_ends = (slot + 1) * window_millis;
let reset_after = Duration::from_millis(window_ends.saturating_sub(now));
Ok(RateLimit {
limit,
used,
remaining: limit.saturating_sub(used),
reset_after,
exceeded: used > limit,
})
}
pub async fn too_many(&self, key: &str, limit: u64, window: Duration) -> Result<bool> {
Ok(self.used(key, window).await? >= limit)
}
pub async fn used(&self, key: &str, window: Duration) -> Result<u64> {
let window_millis = window.as_millis().max(1) as u64;
let slot = now_millis() / window_millis;
let counter = format!("{NAMESPACE}{key}:{slot}");
Ok(self
.store
.get(&counter)
.await?
.and_then(|value| value.as_i64())
.unwrap_or(0)
.max(0) as u64)
}
pub async fn clear(&self, key: &str, window: Duration) -> Result<()> {
let window_millis = window.as_millis().max(1) as u64;
let slot = now_millis() / window_millis;
self.store.forget(&format!("{NAMESPACE}{key}:{slot}")).await?;
Ok(())
}
}
fn now_millis() -> u64 {
SystemTime::now().duration_since(UNIX_EPOCH).unwrap_or_default().as_millis() as u64
}
#[cfg(test)]
mod tests {
use super::*;
use crate::memory::MemoryStore;
fn limiter() -> RateLimiter {
RateLimiter::with_driver(MemoryStore::new())
}
#[tokio::test]
async fn the_first_attempts_are_allowed_and_the_next_one_is_not() {
let limiter = limiter();
let window = Duration::from_secs(60);
for expected_remaining in (0..3).rev() {
let outcome = limiter.attempt("ada", 3, window).await.unwrap();
assert!(!outcome.exceeded);
assert_eq!(outcome.remaining, expected_remaining);
}
let refused = limiter.attempt("ada", 3, window).await.unwrap();
assert!(refused.exceeded);
assert_eq!(refused.remaining, 0);
assert_eq!(refused.used, 4);
}
#[tokio::test]
async fn two_keys_are_counted_separately() {
let limiter = limiter();
let window = Duration::from_secs(60);
limiter.attempt("ada", 1, window).await.unwrap();
limiter.attempt("ada", 1, window).await.unwrap();
assert!(limiter.attempt("ada", 1, window).await.unwrap().exceeded);
assert!(!limiter.attempt("grace", 1, window).await.unwrap().exceeded);
}
#[tokio::test]
async fn a_window_that_passes_lets_the_client_back_in() {
let limiter = limiter();
let window = Duration::from_millis(80);
limiter.attempt("ada", 1, window).await.unwrap();
assert!(limiter.attempt("ada", 1, window).await.unwrap().exceeded);
tokio::time::sleep(Duration::from_millis(180)).await;
assert!(!limiter.attempt("ada", 1, window).await.unwrap().exceeded);
}
#[tokio::test]
async fn retry_after_is_never_zero_seconds() {
let limiter = limiter();
let outcome = limiter.attempt("ada", 1, Duration::from_millis(200)).await.unwrap();
assert!(outcome.reset_after < Duration::from_millis(201));
assert_eq!(outcome.retry_after_seconds(), 1);
assert!(outcome.reset_at() >= now_millis() / 1000);
}
#[tokio::test]
async fn too_many_reports_the_state_without_spending_an_attempt() {
let limiter = limiter();
let window = Duration::from_secs(60);
limiter.attempt("ada", 2, window).await.unwrap();
assert!(!limiter.too_many("ada", 2, window).await.unwrap());
assert_eq!(limiter.used("ada", window).await.unwrap(), 1);
limiter.attempt("ada", 2, window).await.unwrap();
assert!(limiter.too_many("ada", 2, window).await.unwrap());
assert_eq!(limiter.used("ada", window).await.unwrap(), 2);
}
#[tokio::test]
async fn clearing_a_key_gives_the_whole_window_back() {
let limiter = limiter();
let window = Duration::from_secs(60);
limiter.attempt("ada", 1, window).await.unwrap();
assert!(limiter.attempt("ada", 1, window).await.unwrap().exceeded);
limiter.clear("ada", window).await.unwrap();
assert!(!limiter.attempt("ada", 1, window).await.unwrap().exceeded);
}
#[tokio::test]
async fn a_limiter_key_cannot_collide_with_an_ordinary_cache_entry() {
let store = MemoryStore::new();
store.forever("ada", rustlavel_core::Json::from("a cached value")).await.unwrap();
let limiter = RateLimiter::new(Arc::new(store.clone()));
limiter.attempt("ada", 5, Duration::from_secs(60)).await.unwrap();
assert_eq!(
store.get("ada").await.unwrap(),
Some(rustlavel_core::Json::from("a cached value")),
"the limiter must not have trampled the cached value"
);
}
#[tokio::test]
async fn concurrent_attempts_never_let_more_than_the_limit_through() {
let limiter = limiter();
let window = Duration::from_secs(60);
let mut tasks = Vec::new();
for _ in 0..40 {
let limiter = limiter.clone();
tasks.push(tokio::spawn(async move {
limiter.attempt("shared", 10, window).await.unwrap().exceeded
}));
}
let mut allowed = 0;
for task in tasks {
if !task.await.unwrap() {
allowed += 1;
}
}
assert_eq!(allowed, 10, "exactly the limit may pass, whatever the interleaving");
}
}