use std::collections::HashMap;
use std::sync::Mutex;
use std::time::{Duration, Instant};
use rainier_support::{BoxFuture, Result};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct Hit {
pub count: u32,
pub resets_in: Duration,
}
pub trait RateLimitStore: Send + Sync + 'static {
fn hit<'a>(&'a self, key: &'a str, window: Duration) -> BoxFuture<'a, Result<Hit>>;
fn peek<'a>(&'a self, key: &'a str) -> BoxFuture<'a, Result<Option<Hit>>>;
fn clear<'a>(&'a self, key: &'a str) -> BoxFuture<'a, Result<()>>;
fn is_shared(&self) -> bool;
fn name(&self) -> &str;
}
#[derive(Debug, Default)]
pub struct MemoryRateLimitStore {
windows: Mutex<HashMap<String, Window>>,
}
#[derive(Debug, Clone, Copy)]
struct Window {
count: u32,
started: Instant,
length: Duration,
}
impl Window {
fn is_live(&self, now: Instant) -> bool {
now.duration_since(self.started) < self.length
}
fn resets_in(&self, now: Instant) -> Duration {
self.length.saturating_sub(now.duration_since(self.started))
}
}
impl MemoryRateLimitStore {
pub fn new() -> Self {
Self::default()
}
pub fn tracked(&self) -> usize {
self.windows.lock().expect("rate limit lock poisoned").len()
}
}
impl RateLimitStore for MemoryRateLimitStore {
fn hit<'a>(&'a self, key: &'a str, window: Duration) -> BoxFuture<'a, Result<Hit>> {
Box::pin(async move {
let now = Instant::now();
let mut windows = self.windows.lock().expect("rate limit lock poisoned");
windows.retain(|_, existing| existing.is_live(now));
let existing = windows.entry(key.to_string()).or_insert(Window {
count: 0,
started: now,
length: window,
});
existing.count += 1;
Ok(Hit { count: existing.count, resets_in: existing.resets_in(now) })
})
}
fn peek<'a>(&'a self, key: &'a str) -> BoxFuture<'a, Result<Option<Hit>>> {
Box::pin(async move {
let now = Instant::now();
let windows = self.windows.lock().expect("rate limit lock poisoned");
Ok(windows
.get(key)
.filter(|window| window.is_live(now))
.map(|window| Hit { count: window.count, resets_in: window.resets_in(now) }))
})
}
fn clear<'a>(&'a self, key: &'a str) -> BoxFuture<'a, Result<()>> {
Box::pin(async move {
self.windows.lock().expect("rate limit lock poisoned").remove(key);
Ok(())
})
}
fn is_shared(&self) -> bool {
false
}
fn name(&self) -> &str {
"memory"
}
}
#[cfg(test)]
mod tests {
use super::*;
fn store() -> MemoryRateLimitStore {
MemoryRateLimitStore::new()
}
#[tokio::test]
async fn hits_count_up_within_a_window() {
let store = store();
let window = Duration::from_secs(60);
for expected in 1..=3 {
let hit = store.hit("ada", window).await.unwrap();
assert_eq!(hit.count, expected);
}
}
#[tokio::test]
async fn keys_are_counted_separately() {
let store = store();
let window = Duration::from_secs(60);
store.hit("ada", window).await.unwrap();
store.hit("ada", window).await.unwrap();
let grace = store.hit("grace", window).await.unwrap();
assert_eq!(grace.count, 1);
assert_eq!(store.peek("ada").await.unwrap().unwrap().count, 2);
}
#[tokio::test]
async fn a_window_expires_and_the_allowance_comes_back() {
let store = store();
let window = Duration::from_millis(30);
store.hit("ada", window).await.unwrap();
store.hit("ada", window).await.unwrap();
tokio::time::sleep(Duration::from_millis(50)).await;
assert_eq!(store.hit("ada", window).await.unwrap().count, 1);
}
#[tokio::test]
async fn an_expired_window_reads_as_nothing_rather_than_as_a_stale_count() {
let store = store();
let window = Duration::from_millis(30);
store.hit("ada", window).await.unwrap();
tokio::time::sleep(Duration::from_millis(50)).await;
assert!(store.peek("ada").await.unwrap().is_none());
}
#[tokio::test]
async fn dead_keys_are_evicted_rather_than_accumulating() {
let store = store();
let window = Duration::from_millis(30);
for i in 0..100 {
store.hit(&format!("ip:{i}"), window).await.unwrap();
}
assert_eq!(store.tracked(), 100);
tokio::time::sleep(Duration::from_millis(50)).await;
store.hit("ip:fresh", window).await.unwrap();
assert_eq!(store.tracked(), 1, "the expired keys were not evicted");
}
#[tokio::test]
async fn clearing_restores_the_whole_allowance() {
let store = store();
let window = Duration::from_secs(60);
store.hit("ada", window).await.unwrap();
store.hit("ada", window).await.unwrap();
store.clear("ada").await.unwrap();
assert!(store.peek("ada").await.unwrap().is_none());
assert_eq!(store.hit("ada", window).await.unwrap().count, 1);
}
#[tokio::test]
async fn the_reset_time_counts_down() {
let store = store();
let window = Duration::from_secs(60);
let first = store.hit("ada", window).await.unwrap();
tokio::time::sleep(Duration::from_millis(20)).await;
let second = store.hit("ada", window).await.unwrap();
assert!(
second.resets_in < first.resets_in,
"{:?} !< {:?}",
second.resets_in,
first.resets_in
);
assert_eq!(second.count, 2);
}
#[test]
fn it_says_it_is_not_shared() {
assert!(!store().is_shared());
assert_eq!(store().name(), "memory");
}
}