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