featherbit/ratelimit/
mod.rs1use std::collections::HashMap;
12use std::sync::Arc;
13use std::time::{Duration, Instant};
14
15use async_trait::async_trait;
16use dashmap::DashMap;
17
18#[derive(Debug, Clone)]
20pub struct WindowResult {
21 pub allowed: bool,
23 pub remaining: u64,
25 pub reset: Duration,
27 pub limit: u64,
29}
30
31#[derive(Debug)]
34pub struct CounterError(pub String);
35
36impl std::fmt::Display for CounterError {
37 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
38 write!(f, "counter store error: {}", self.0)
39 }
40}
41
42#[async_trait]
44pub trait CounterStore: Send + Sync {
45 async fn incr_fixed_window(
48 &self,
49 key: &str,
50 limit: u64,
51 window: Duration,
52 ) -> Result<WindowResult, CounterError>;
53}
54
55#[derive(Default)]
62pub struct LocalCounterStore {
63 windows: DashMap<String, (Instant, u64)>,
64}
65
66#[async_trait]
67impl CounterStore for LocalCounterStore {
68 async fn incr_fixed_window(
69 &self,
70 key: &str,
71 limit: u64,
72 window: Duration,
73 ) -> Result<WindowResult, CounterError> {
74 let now = Instant::now();
75 let mut entry = self
76 .windows
77 .entry(key.to_string())
78 .or_insert_with(|| (now, 0));
79
80 let (start, count) = *entry;
81 let (start, count) = if now.duration_since(start) >= window {
82 (now, 0)
83 } else {
84 (start, count)
85 };
86
87 let allowed = count < limit;
88 let new_count = if allowed { count + 1 } else { count };
89 *entry = (start, new_count);
90
91 let elapsed = now.duration_since(start);
92 Ok(WindowResult {
93 allowed,
94 remaining: limit.saturating_sub(new_count),
95 reset: window.saturating_sub(elapsed),
96 limit,
97 })
98 }
99}
100
101pub struct CounterStoreRegistry {
107 stores: HashMap<String, Arc<dyn CounterStore>>,
108}
109
110impl Default for CounterStoreRegistry {
111 fn default() -> Self {
112 let mut stores: HashMap<String, Arc<dyn CounterStore>> = HashMap::new();
113 stores.insert("local".to_string(), Arc::new(LocalCounterStore::default()));
114 Self { stores }
115 }
116}
117
118impl CounterStoreRegistry {
119 pub fn get(&self, policy: &str) -> Result<Arc<dyn CounterStore>, String> {
121 self.stores.get(policy).cloned().ok_or_else(|| {
122 format!("unknown rate-limit policy '{}' — supported: {}", policy, {
123 let mut names: Vec<&str> = self.stores.keys().map(String::as_str).collect();
124 names.sort_unstable();
125 names.join(", ")
126 })
127 })
128 }
129}
130
131#[cfg(test)]
132mod tests {
133 use super::*;
134
135 #[tokio::test]
136 async fn test_fixed_window_counts_and_rejects() {
137 let store = LocalCounterStore::default();
138 let window = Duration::from_secs(60);
139
140 for i in 0..3 {
141 let r = store.incr_fixed_window("k", 3, window).await.unwrap();
142 assert!(r.allowed, "request {} should pass", i);
143 assert_eq!(r.remaining, 2 - i);
144 assert_eq!(r.limit, 3);
145 }
146 let r = store.incr_fixed_window("k", 3, window).await.unwrap();
147 assert!(!r.allowed);
148 assert_eq!(r.remaining, 0);
149 assert!(r.reset <= window);
150
151 let r = store.incr_fixed_window("other", 3, window).await.unwrap();
153 assert!(r.allowed);
154 }
155
156 #[tokio::test]
157 async fn test_window_resets_after_expiry() {
158 let store = LocalCounterStore::default();
159 let window = Duration::from_millis(30);
160
161 let r = store.incr_fixed_window("k", 1, window).await.unwrap();
162 assert!(r.allowed);
163 let r = store.incr_fixed_window("k", 1, window).await.unwrap();
164 assert!(!r.allowed);
165
166 tokio::time::sleep(Duration::from_millis(40)).await;
167 let r = store.incr_fixed_window("k", 1, window).await.unwrap();
168 assert!(r.allowed, "window should have reset");
169 }
170
171 #[tokio::test]
172 async fn test_registry_lookup() {
173 let registry = CounterStoreRegistry::default();
174 assert!(registry.get("local").is_ok());
175 let err = match registry.get("redis") {
176 Ok(_) => panic!("'redis' is not in this registry (it resolves via stores:)"),
177 Err(e) => e,
178 };
179 assert!(err.contains("unknown rate-limit policy"), "{err}");
180 }
181}