ai_crew_sync/
ratelimit.rs1use std::{
15 collections::HashMap,
16 sync::{Arc, Mutex},
17 time::{Duration, Instant},
18};
19
20const IDLE_EVICTION: Duration = Duration::from_secs(600);
23
24const SWEEP_EVERY: usize = 512;
27
28const MAX_TRACKED: usize = 10_000;
33
34struct Bucket {
35 tokens: f64,
36 last: Instant,
37}
38
39struct Inner {
40 buckets: HashMap<String, Bucket>,
41 since_sweep: usize,
42}
43
44#[derive(Clone)]
46pub struct RateLimiter {
47 inner: Arc<Mutex<Inner>>,
48 refill_per_sec: f64,
50 burst: f64,
52}
53
54pub struct Throttled {
56 pub retry_after_secs: u64,
57}
58
59impl RateLimiter {
60 pub fn new(per_minute: u32) -> Option<Self> {
62 if per_minute == 0 {
63 return None;
64 }
65 let burst = ((per_minute as f64) / 10.0).max(10.0);
70 Some(Self {
71 inner: Arc::new(Mutex::new(Inner {
72 buckets: HashMap::new(),
73 since_sweep: 0,
74 })),
75 refill_per_sec: per_minute as f64 / 60.0,
76 burst,
77 })
78 }
79
80 pub fn check(&self, key: &str) -> Result<(), Throttled> {
82 let now = Instant::now();
83 let mut inner = match self.inner.lock() {
84 Ok(guard) => guard,
85 Err(poisoned) => poisoned.into_inner(),
89 };
90
91 inner.since_sweep += 1;
92 if inner.since_sweep >= SWEEP_EVERY {
93 inner.since_sweep = 0;
94 inner
95 .buckets
96 .retain(|_, b| now.duration_since(b.last) < IDLE_EVICTION);
97 }
98
99 let burst = self.burst;
100 let refill = self.refill_per_sec;
101 let bucket = if let Some(existing) = inner.buckets.get_mut(key) {
104 existing
105 } else {
106 if inner.buckets.len() >= MAX_TRACKED {
107 inner
108 .buckets
109 .retain(|_, b| now.duration_since(b.last) < IDLE_EVICTION);
110 }
111 if inner.buckets.len() >= MAX_TRACKED {
112 tracing::warn!(
113 tracked = inner.buckets.len(),
114 "rate limiter is at its tracking ceiling; rejecting unknown tokens"
115 );
116 return Err(Throttled {
117 retry_after_secs: 60,
118 });
119 }
120 inner.buckets.entry(key.to_owned()).or_insert(Bucket {
121 tokens: burst,
122 last: now,
123 })
124 };
125
126 let elapsed = now.duration_since(bucket.last).as_secs_f64();
127 bucket.tokens = (bucket.tokens + elapsed * refill).min(burst);
128 bucket.last = now;
129
130 if bucket.tokens >= 1.0 {
131 bucket.tokens -= 1.0;
132 return Ok(());
133 }
134
135 let deficit = 1.0 - bucket.tokens;
137 Err(Throttled {
138 retry_after_secs: (deficit / refill).ceil().max(1.0) as u64,
139 })
140 }
141}
142
143#[cfg(test)]
144mod tests {
145 use super::*;
146
147 #[test]
148 fn disabled_when_zero() {
149 assert!(RateLimiter::new(0).is_none());
150 }
151
152 #[test]
153 fn allows_a_burst_then_throttles() {
154 let rl = RateLimiter::new(60).expect("enabled");
156 for i in 0..10 {
157 assert!(rl.check("k").is_ok(), "burst request {i} should pass");
158 }
159 let throttled = rl.check("k");
160 assert!(
161 throttled.is_err(),
162 "the 11th immediate request is throttled"
163 );
164 assert!(throttled.expect_err("throttled").retry_after_secs >= 1);
165 }
166
167 #[test]
168 fn keys_are_independent() {
169 let rl = RateLimiter::new(60).expect("enabled");
170 for _ in 0..10 {
171 let _ = rl.check("a");
172 }
173 assert!(
174 rl.check("b").is_ok(),
175 "one caller's budget must not affect another's"
176 );
177 }
178
179 #[test]
180 fn tracking_is_bounded() {
181 let rl = RateLimiter::new(6000).expect("enabled");
182 for i in 0..(MAX_TRACKED + 500) {
183 let _ = rl.check(&format!("token-{i}"));
184 }
185 let tracked = rl.inner.lock().expect("lock").buckets.len();
186 assert!(
187 tracked <= MAX_TRACKED,
188 "tracked {tracked}, cap {MAX_TRACKED}"
189 );
190 }
191
192 #[test]
193 fn refills_over_time() {
194 let rl = RateLimiter::new(600).expect("enabled"); for _ in 0..60 {
196 let _ = rl.check("k");
197 }
198 assert!(rl.check("k").is_err(), "bucket is drained");
199 std::thread::sleep(Duration::from_millis(250));
200 assert!(
201 rl.check("k").is_ok(),
202 "a quarter second refills 2.5 tokens at 10/s"
203 );
204 }
205}