use std::sync::Arc;
use std::time::Duration;
use rainier_middleware::{Hit, RateLimitStore};
use rainier_support::{BoxFuture, Result};
use crate::cache::Cache;
pub struct CacheRateLimiter {
cache: Arc<dyn Cache>,
prefix: String,
}
impl CacheRateLimiter {
pub fn new(cache: Arc<dyn Cache>) -> Self {
Self { cache, prefix: "rate:".to_string() }
}
#[must_use = "this returns a configured limiter rather than configuring in place"]
pub fn with_prefix(mut self, prefix: impl Into<String>) -> Self {
self.prefix = prefix.into();
self
}
pub fn cache(&self) -> &Arc<dyn Cache> {
&self.cache
}
fn counter(&self, key: &str) -> String {
format!("{}{}", self.prefix, key)
}
fn deadline(&self, key: &str) -> String {
format!("{}{}:until", self.prefix, key)
}
}
impl RateLimitStore for CacheRateLimiter {
fn hit<'a>(&'a self, key: &'a str, window: Duration) -> BoxFuture<'a, Result<Hit>> {
Box::pin(async move {
let counter = self.counter(key);
let deadline = self.deadline(key);
let opened = self.cache.add(&counter, b"0", Some(window)).await?;
let ends_at = if opened {
let ends_at = now_millis() + window.as_millis() as u64;
let _ =
self.cache.add(&deadline, ends_at.to_string().as_bytes(), Some(window)).await;
Some(ends_at)
} else {
read_millis(self.cache.get(&deadline).await?)
};
let count = self.cache.increment(&counter, 1).await?;
Ok(Hit { count: count.max(0) as u32, resets_in: remaining(ends_at, window) })
})
}
fn peek<'a>(&'a self, key: &'a str) -> BoxFuture<'a, Result<Option<Hit>>> {
Box::pin(async move {
let Some(raw) = self.cache.get(&self.counter(key)).await? else {
return Ok(None);
};
let count = String::from_utf8_lossy(&raw).trim().parse::<u32>().unwrap_or(0);
let ends_at = read_millis(self.cache.get(&self.deadline(key)).await?);
Ok(Some(Hit { count, resets_in: remaining(ends_at, Duration::ZERO) }))
})
}
fn clear<'a>(&'a self, key: &'a str) -> BoxFuture<'a, Result<()>> {
Box::pin(async move {
self.cache.forget(&self.counter(key)).await?;
self.cache.forget(&self.deadline(key)).await?;
Ok(())
})
}
fn is_shared(&self) -> bool {
self.cache.is_shared()
}
fn name(&self) -> &str {
self.cache.name()
}
}
fn now_millis() -> u64 {
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|since| since.as_millis() as u64)
.unwrap_or(0)
}
fn read_millis(stored: Option<Vec<u8>>) -> Option<u64> {
String::from_utf8(stored?).ok()?.trim().parse().ok()
}
fn remaining(ends_at: Option<u64>, fallback: Duration) -> Duration {
match ends_at {
Some(ends_at) => Duration::from_millis(ends_at.saturating_sub(now_millis())),
None => fallback,
}
}
impl std::fmt::Debug for CacheRateLimiter {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("CacheRateLimiter")
.field("cache", &self.cache.name())
.field("shared", &self.cache.is_shared())
.finish()
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::MemoryCache;
fn limiter() -> CacheRateLimiter {
CacheRateLimiter::new(Arc::new(MemoryCache::new()))
}
#[tokio::test]
async fn hits_count_up() {
let limiter = limiter();
let window = Duration::from_secs(60);
for expected in 1..=3 {
assert_eq!(limiter.hit("ada", window).await.unwrap().count, expected);
}
}
#[tokio::test]
async fn keys_are_counted_separately() {
let limiter = limiter();
let window = Duration::from_secs(60);
limiter.hit("ada", window).await.unwrap();
limiter.hit("ada", window).await.unwrap();
assert_eq!(limiter.hit("grace", window).await.unwrap().count, 1);
assert_eq!(limiter.peek("ada").await.unwrap().unwrap().count, 2);
}
#[tokio::test]
async fn the_window_expires_on_its_own() {
let limiter = limiter();
let window = Duration::from_millis(40);
limiter.hit("ada", window).await.unwrap();
limiter.hit("ada", window).await.unwrap();
tokio::time::sleep(Duration::from_millis(60)).await;
assert!(limiter.peek("ada").await.unwrap().is_none());
assert_eq!(limiter.hit("ada", window).await.unwrap().count, 1);
}
#[tokio::test]
async fn a_later_hit_cannot_extend_the_window() {
let limiter = limiter();
let window = Duration::from_millis(120);
limiter.hit("ada", window).await.unwrap();
for _ in 0..3 {
tokio::time::sleep(Duration::from_millis(20)).await;
let hit = limiter.hit("ada", window).await.unwrap();
assert!(hit.count > 1, "a hit inside the window opened a new one");
}
tokio::time::sleep(Duration::from_millis(100)).await;
assert!(limiter.peek("ada").await.unwrap().is_none());
}
#[tokio::test]
async fn clearing_restores_the_allowance() {
let limiter = limiter();
let window = Duration::from_secs(60);
limiter.hit("ada", window).await.unwrap();
limiter.hit("ada", window).await.unwrap();
limiter.clear("ada").await.unwrap();
assert!(limiter.peek("ada").await.unwrap().is_none());
assert_eq!(limiter.hit("ada", window).await.unwrap().count, 1);
}
#[tokio::test]
async fn it_reports_the_sharedness_of_its_cache() {
assert!(!limiter().is_shared());
assert_eq!(limiter().name(), "memory");
}
#[tokio::test]
async fn peeking_at_nothing_is_none() {
assert!(limiter().peek("nobody").await.unwrap().is_none());
}
#[tokio::test]
async fn the_reset_time_counts_down_rather_than_reporting_the_whole_window() {
let limiter = limiter();
let window = Duration::from_millis(400);
let first = limiter.hit("ada", window).await.unwrap();
assert!(first.resets_in <= window);
tokio::time::sleep(Duration::from_millis(150)).await;
let second = limiter.hit("ada", window).await.unwrap();
assert!(
second.resets_in < first.resets_in,
"{:?} should be less than {:?}",
second.resets_in,
first.resets_in
);
assert!(second.resets_in < Duration::from_millis(300), "{:?}", second.resets_in);
}
#[tokio::test]
async fn a_missing_deadline_falls_back_rather_than_reporting_zero() {
let cache: Arc<dyn Cache> = Arc::new(MemoryCache::new());
let limiter = CacheRateLimiter::new(Arc::clone(&cache));
let window = Duration::from_secs(60);
limiter.hit("ada", window).await.unwrap();
cache.forget("rate:ada:until").await.unwrap();
let hit = limiter.hit("ada", window).await.unwrap();
assert_eq!(hit.count, 2, "the counter itself is untouched");
assert_eq!(hit.resets_in, window, "it falls back to the whole window");
}
}