1use std::collections::HashMap;
23use std::sync::Arc;
24use std::time::{Duration, Instant};
25
26use parking_lot::Mutex;
27
28#[derive(Clone, Copy, Debug)]
31pub struct RateLimitConfig {
32 pub rpm: u64,
33 pub tpm: u64,
34 pub burst_rpm: u64,
35 pub burst_tpm: u64,
36}
37
38impl RateLimitConfig {
39 pub fn from_env() -> Result<Self, String> {
45 Self::from_raw(
46 std::env::var("METRALE_RATE_LIMIT_RPM").ok().as_deref(),
47 std::env::var("METRALE_RATE_LIMIT_TPM").ok().as_deref(),
48 std::env::var("METRALE_RATE_LIMIT_BURST_RPM")
49 .ok()
50 .as_deref(),
51 std::env::var("METRALE_RATE_LIMIT_BURST_TPM")
52 .ok()
53 .as_deref(),
54 )
55 }
56
57 pub fn from_raw(
63 rpm: Option<&str>,
64 tpm: Option<&str>,
65 burst_rpm: Option<&str>,
66 burst_tpm: Option<&str>,
67 ) -> Result<Self, String> {
68 use crate::env_config::parse_min;
69 let rpm = parse_min(
70 "METRALE_RATE_LIMIT_RPM",
71 rpm,
72 0,
73 "requests per minute per client; 0 disables the request-rate limit",
74 )?
75 .unwrap_or(0);
76 let tpm = parse_min(
77 "METRALE_RATE_LIMIT_TPM",
78 tpm,
79 0,
80 "tokens per minute per client; 0 disables the token-rate limit",
81 )?
82 .unwrap_or(0);
83 let burst_rpm = parse_min(
86 "METRALE_RATE_LIMIT_BURST_RPM",
87 burst_rpm,
88 0,
89 "request-bucket depth; defaults to METRALE_RATE_LIMIT_RPM",
90 )?
91 .unwrap_or(rpm);
92 let burst_tpm = parse_min(
93 "METRALE_RATE_LIMIT_BURST_TPM",
94 burst_tpm,
95 0,
96 "token-bucket depth; defaults to METRALE_RATE_LIMIT_TPM",
97 )?
98 .unwrap_or(tpm);
99 Ok(Self {
100 rpm,
101 tpm,
102 burst_rpm: burst_rpm.max(1),
103 burst_tpm: burst_tpm.max(1),
104 })
105 }
106
107 pub fn is_enabled(&self) -> bool {
108 self.rpm > 0 || self.tpm > 0
109 }
110
111 fn advertised_tpm(&self) -> u64 {
115 if self.tpm > 0 {
116 self.burst_tpm
117 } else {
118 1_000_000_000
119 }
120 }
121
122 fn advertised_rpm(&self) -> u64 {
124 if self.rpm > 0 {
125 self.burst_rpm
126 } else {
127 1_000_000
128 }
129 }
130
131 fn advertised_remaining_tpm(&self, avail: u64) -> u64 {
134 if self.tpm > 0 { avail } else { 999_999_999 }
135 }
136
137 fn advertised_remaining_rpm(&self, avail: u64) -> u64 {
138 if self.rpm > 0 { avail } else { 999_999 }
139 }
140}
141
142#[derive(Clone, Copy, Debug)]
145pub struct BucketSnapshot {
146 pub limit: u64,
147 pub remaining: u64,
148 pub reset_secs: u64,
151}
152
153#[derive(Clone, Copy, Debug)]
154pub struct RateDecision {
155 pub allowed: bool,
156 pub requests: BucketSnapshot,
157 pub tokens: BucketSnapshot,
158 pub retry_after_secs: u64,
161 pub denied_by: Option<DenialReason>,
163}
164
165#[derive(Clone, Copy, Debug)]
166pub enum DenialReason {
167 Requests,
168 Tokens,
169}
170
171#[derive(Clone, Debug)]
177pub struct RequestContext {
178 pub identity: String,
179 pub reserved_tokens: u64,
180}
181
182struct Bucket {
183 available: f64,
185 last_refill: Instant,
187}
188
189impl Bucket {
190 fn new(burst: u64) -> Self {
191 Self {
192 available: burst as f64,
193 last_refill: Instant::now(),
194 }
195 }
196
197 fn try_consume(&mut self, cost: f64, rate_per_sec: f64, burst: f64, now: Instant) -> bool {
200 let dt = now
201 .saturating_duration_since(self.last_refill)
202 .as_secs_f64();
203 if dt > 0.0 {
204 self.available = (self.available + dt * rate_per_sec).min(burst);
205 self.last_refill = now;
206 }
207 if self.available >= cost {
208 self.available -= cost;
209 true
210 } else {
211 false
212 }
213 }
214
215 fn snapshot(&self, rate_per_sec: f64, burst: f64, now: Instant) -> (f64, u64) {
216 let dt = now
217 .saturating_duration_since(self.last_refill)
218 .as_secs_f64();
219 let available = (self.available + dt * rate_per_sec).min(burst);
220 let deficit = (burst - available).max(0.0);
221 let reset = if rate_per_sec > 0.0 {
222 (deficit / rate_per_sec).ceil() as u64
223 } else {
224 0
225 };
226 (available, reset)
227 }
228
229 fn refund(&mut self, amount: f64, burst: f64) {
231 self.available = (self.available + amount).min(burst);
232 }
233}
234
235struct KeyState {
236 requests: Bucket,
237 tokens: Bucket,
238}
239
240pub struct RateLimiter {
242 cfg: RateLimitConfig,
243 inner: Mutex<HashMap<String, KeyState>>,
244 last_scrub: Mutex<Instant>,
247}
248
249const SCRUB_INTERVAL: Duration = Duration::from_secs(120);
250const IDLE_EVICT: Duration = Duration::from_secs(600);
253const MAX_KEYS: usize = 100_000;
257
258impl RateLimiter {
259 pub fn from_env() -> Result<Arc<Self>, String> {
264 Ok(Arc::new(Self {
265 cfg: RateLimitConfig::from_env()?,
266 inner: Mutex::new(HashMap::new()),
267 last_scrub: Mutex::new(Instant::now()),
268 }))
269 }
270
271 #[cfg(test)]
272 pub fn with_config(cfg: RateLimitConfig) -> Arc<Self> {
273 Arc::new(Self {
274 cfg,
275 inner: Mutex::new(HashMap::new()),
276 last_scrub: Mutex::new(Instant::now()),
277 })
278 }
279
280 pub fn config(&self) -> RateLimitConfig {
281 self.cfg
282 }
283
284 pub fn admit(&self, key: &str, estimated_tokens: u64) -> RateDecision {
289 let now = Instant::now();
290 self.scrub_if_due(now);
291
292 let rpm = self.cfg.rpm;
293 let tpm = self.cfg.tpm;
294
295 if !self.cfg.is_enabled() {
296 return RateDecision {
297 allowed: true,
298 requests: BucketSnapshot {
299 limit: 1_000_000,
300 remaining: 999_999,
301 reset_secs: 0,
302 },
303 tokens: BucketSnapshot {
304 limit: 1_000_000_000,
305 remaining: 999_999_999,
306 reset_secs: 0,
307 },
308 retry_after_secs: 0,
309 denied_by: None,
310 };
311 }
312
313 let req_rate = rpm as f64 / 60.0;
314 let tok_rate = tpm as f64 / 60.0;
315 let req_burst = self.cfg.burst_rpm as f64;
316 let tok_burst = self.cfg.burst_tpm as f64;
317
318 let mut map = self.inner.lock();
319 if map.len() >= MAX_KEYS && !map.contains_key(key) {
321 map.retain(|_, state| {
322 state.requests.last_refill.elapsed() < IDLE_EVICT
323 || state.tokens.last_refill.elapsed() < IDLE_EVICT
324 });
325 }
326 let state = map.entry(key.to_string()).or_insert_with(|| KeyState {
327 requests: Bucket::new(self.cfg.burst_rpm),
328 tokens: Bucket::new(self.cfg.burst_tpm),
329 });
330
331 let req_allowed = if rpm > 0 {
332 state.requests.try_consume(1.0, req_rate, req_burst, now)
333 } else {
334 true
335 };
336 if !req_allowed {
337 let (req_avail, req_reset) = state.requests.snapshot(req_rate, req_burst, now);
338 let (tok_avail, tok_reset) = state.tokens.snapshot(tok_rate, tok_burst, now);
339 return RateDecision {
340 allowed: false,
341 requests: BucketSnapshot {
342 limit: self.cfg.advertised_rpm(),
343 remaining: self.cfg.advertised_remaining_rpm(req_avail.max(0.0) as u64),
344 reset_secs: req_reset,
345 },
346 tokens: BucketSnapshot {
347 limit: self.cfg.advertised_tpm(),
348 remaining: self.cfg.advertised_remaining_tpm(tok_avail.max(0.0) as u64),
349 reset_secs: tok_reset,
350 },
351 retry_after_secs: req_reset.max(1),
352 denied_by: Some(DenialReason::Requests),
353 };
354 }
355
356 let tok_allowed = if tpm > 0 {
357 state
358 .tokens
359 .try_consume(estimated_tokens as f64, tok_rate, tok_burst, now)
360 } else {
361 true
362 };
363 if !tok_allowed {
364 if rpm > 0 {
367 state.requests.refund(1.0, req_burst);
368 }
369 let (req_avail, req_reset) = state.requests.snapshot(req_rate, req_burst, now);
370 let (tok_avail, tok_reset) = state.tokens.snapshot(tok_rate, tok_burst, now);
371 return RateDecision {
372 allowed: false,
373 requests: BucketSnapshot {
374 limit: self.cfg.advertised_rpm(),
375 remaining: self.cfg.advertised_remaining_rpm(req_avail.max(0.0) as u64),
376 reset_secs: req_reset,
377 },
378 tokens: BucketSnapshot {
379 limit: self.cfg.advertised_tpm(),
380 remaining: self.cfg.advertised_remaining_tpm(tok_avail.max(0.0) as u64),
381 reset_secs: tok_reset,
382 },
383 retry_after_secs: tok_reset.max(1),
384 denied_by: Some(DenialReason::Tokens),
385 };
386 }
387
388 let (req_avail, req_reset) = state.requests.snapshot(req_rate, req_burst, now);
389 let (tok_avail, tok_reset) = state.tokens.snapshot(tok_rate, tok_burst, now);
390 RateDecision {
391 allowed: true,
392 requests: BucketSnapshot {
393 limit: self.cfg.advertised_rpm(),
394 remaining: self.cfg.advertised_remaining_rpm(req_avail.max(0.0) as u64),
395 reset_secs: req_reset,
396 },
397 tokens: BucketSnapshot {
398 limit: self.cfg.advertised_tpm(),
399 remaining: self.cfg.advertised_remaining_tpm(tok_avail.max(0.0) as u64),
400 reset_secs: tok_reset,
401 },
402 retry_after_secs: 0,
403 denied_by: None,
404 }
405 }
406
407 pub fn refund_tokens(&self, key: &str, amount: u64) {
410 if amount == 0 || !self.cfg.is_enabled() || self.cfg.tpm == 0 {
411 return;
412 }
413 let mut map = self.inner.lock();
414 if let Some(state) = map.get_mut(key) {
415 state
416 .tokens
417 .refund(amount as f64, self.cfg.burst_tpm as f64);
418 }
419 }
420
421 fn scrub_if_due(&self, now: Instant) {
422 let mut last = self.last_scrub.lock();
423 if now.saturating_duration_since(*last) < SCRUB_INTERVAL {
424 return;
425 }
426 *last = now;
427 drop(last);
428 let mut map = self.inner.lock();
429 map.retain(|_, state| {
430 state.requests.last_refill.elapsed() < IDLE_EVICT
431 || state.tokens.last_refill.elapsed() < IDLE_EVICT
432 });
433 }
434}
435
436#[path = "rate_limiter/identity.rs"]
437mod identity;
438pub use identity::extract_identity;
439
440#[cfg(test)]
441#[path = "rate_limiter/tests.rs"]
442mod tests;
443
444#[cfg(test)]
445#[path = "rate_limiter/advertised_tests.rs"]
446mod advertised_limit_tests;