metrale_server/
rate_limiter.rs

1// SPDX-License-Identifier: MIT OR Apache-2.0
2
3//! 2026-09-26: Per-identity token-bucket rate limiter for `/v1/*` requests: one bucket metered in requests and one in tokens, each refilling linearly with elapsed time up to its burst.
4//!
5//! The identity (`extract_identity`) is, first match wins: a hash of the
6//! `Authorization: Bearer` token, the first `X-Forwarded-For` entry, then the
7//! peer IP. `X-Forwarded-For` is trusted as sent.
8//!
9//! Configuration, read once at startup (`RateLimitConfig::from_env`): unset
10//! `METRALE_RATE_LIMIT_RPM` and `METRALE_RATE_LIMIT_TPM` mean 0, which
11//! disables that bucket; `METRALE_RATE_LIMIT_BURST_RPM` and
12//! `METRALE_RATE_LIMIT_BURST_TPM` default to the matching rate. With both
13//! rates 0 every request is admitted and the headers carry fixed "unlimited"
14//! values.
15//!
16//! Owner: server rate limiter.
17//! Invariants:
18//! - A bucket never holds more than its burst: refill and refund both cap at it.
19//! - A denied request consumes nothing: a request debited before a token
20//!   denial is refunded.
21
22use std::collections::HashMap;
23use std::sync::Arc;
24use std::time::{Duration, Instant};
25
26use parking_lot::Mutex;
27
28/// 2026-09-26: Limiter configuration. A rate of 0 disables that bucket; bursts
29/// are at least 1.
30#[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    /// 2026-09-26: Read the four `METRALE_RATE_LIMIT_*` variables.
40    ///
41    /// # Errors
42    /// When a variable is set to something that is not a whole number
43    /// (`env_config::parse_min`); unset or blank means the default.
44    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    /// 2026-09-26: [`Self::from_env`] without the environment, so tests need not
58    /// set process variables.
59    ///
60    /// # Errors
61    /// As [`Self::from_env`].
62    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        // 2026-09-26: An unset burst takes the rate; a set `0` is floored to 1
84        // below.
85        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    /// 2026-09-26: The token limit to advertise: the burst when the token
112    /// bucket is enforced, else the same "unlimited" value as the disabled path.
113    /// A floored burst of 1 on an unenforced axis would read as one token left.
114    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    /// 2026-09-26: As [`Self::advertised_tpm`], for the request bucket.
123    fn advertised_rpm(&self) -> u64 {
124        if self.rpm > 0 {
125            self.burst_rpm
126        } else {
127            1_000_000
128        }
129    }
130
131    /// 2026-09-26: Remaining budget to advertise: the real figure when the axis
132    /// is enforced, else a value consistent with the "unlimited" limit.
133    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/// 2026-09-26: One bucket's limit, remaining budget and reset time, for the
143/// `x-ratelimit-*` response headers.
144#[derive(Clone, Copy, Debug)]
145pub struct BucketSnapshot {
146    pub limit: u64,
147    pub remaining: u64,
148    /// 2026-09-26: Seconds until this bucket is full again (0 when its rate
149    /// is 0).
150    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    /// 2026-09-26: Seconds for the `Retry-After` header: at least 1 when
159    /// denied, 0 when allowed.
160    pub retry_after_secs: u64,
161    /// 2026-09-26: The bucket that denied the request; `None` when allowed.
162    pub denied_by: Option<DenialReason>,
163}
164
165#[derive(Clone, Copy, Debug)]
166pub enum DenialReason {
167    Requests,
168    Tokens,
169}
170
171/// 2026-09-26: What the rate-limit middleware stores in the request's
172/// extensions when the limiter is enabled and admitted the request: the
173/// identity and the tokens reserved (`max_seq_len`). The chat handlers use it
174/// to refund the unused part (`api/chat_blocking.rs`,
175/// `api/chat_stream/handle_done.rs`, `handle_error.rs`).
176#[derive(Clone, Debug)]
177pub struct RequestContext {
178    pub identity: String,
179    pub reserved_tokens: u64,
180}
181
182struct Bucket {
183    /// 2026-09-26: Budget left; fractional because refill is continuous.
184    available: f64,
185    /// 2026-09-26: When `available` was last refilled.
186    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    /// 2026-09-26: Refill at `rate_per_sec` up to `burst`, then debit `cost` if
198    /// it fits. Returns whether it was debited.
199    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    /// 2026-09-26: Add `amount` back, up to `burst`.
230    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
240/// 2026-09-26: The limiter, shared across requests.
241pub struct RateLimiter {
242    cfg: RateLimitConfig,
243    inner: Mutex<HashMap<String, KeyState>>,
244    /// 2026-09-26: When idle keys were last evicted; `admit` evicts again once
245    /// `SCRUB_INTERVAL` has passed.
246    last_scrub: Mutex<Instant>,
247}
248
249const SCRUB_INTERVAL: Duration = Duration::from_secs(120);
250/// 2026-09-26: A key whose buckets were both last refilled this long ago is
251/// evicted.
252const IDLE_EVICT: Duration = Duration::from_secs(600);
253/// 2026-09-26: Map size at which a new key forces an idle-key eviction
254/// before `SCRUB_INTERVAL` is due. It is not a hard cap: the new key is
255/// inserted even when nothing was evicted.
256const MAX_KEYS: usize = 100_000;
257
258impl RateLimiter {
259    /// 2026-09-26: A limiter configured from the environment.
260    ///
261    /// # Errors
262    /// As [`RateLimitConfig::from_env`].
263    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    /// 2026-09-26: Admit or deny one request from `key`, debiting one request
285    /// and `estimated_tokens` tokens from the enforced buckets. The middleware
286    /// passes `max_seq_len`; handlers refund the unused part with
287    /// [`Self::refund_tokens`].
288    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        // 2026-09-26: At `MAX_KEYS`, a new key first evicts every idle key.
320        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            // 2026-09-26: Return the request just debited: a denial costs
365            // nothing.
366            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    /// 2026-09-26: Return `amount` tokens to `key`'s token bucket, up to its
408    /// burst. No-op when the token bucket is disabled or the key was evicted.
409    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;