spark_server/
rate_limiter.rs

1// SPDX-License-Identifier: AGPL-3.0-only
2
3//! Per-identity token-bucket rate limiter.
4//!
5//! Two independent buckets per key (identity): one metered in **requests**,
6//! one in **tokens**. Each bucket refills linearly toward its cap based on
7//! elapsed wall time.
8//!
9//! Identity resolution order (first match wins):
10//! 1. `Authorization: Bearer <token>` — the authenticated client's key.
11//! 2. First entry of `X-Forwarded-For` — when Atlas sits behind a reverse
12//!    proxy or load balancer. Trusted because Atlas is typically deployed
13//!    behind a tenant-operated proxy.
14//! 3. Socket peer address — fallback for unauthenticated direct calls.
15//!
16//! Env configuration (all default 0 = disabled, pure passthrough):
17//!   ATLAS_RATE_LIMIT_RPM       — requests per minute cap
18//!   ATLAS_RATE_LIMIT_TPM       — tokens per minute cap
19//!   ATLAS_RATE_LIMIT_BURST_RPM — max request burst (default = RPM)
20//!   ATLAS_RATE_LIMIT_BURST_TPM — max token burst   (default = TPM)
21//!
22//! The limiter keeps the static "effectively unlimited" headers
23//! byte-for-byte when both RPM and TPM are 0 so existing deployments see no
24//! behavior change.
25
26use std::collections::HashMap;
27use std::sync::Arc;
28use std::time::{Duration, Instant};
29
30use parking_lot::Mutex;
31
32/// Configuration for the limiter. Zero means "disabled" for that bucket.
33#[derive(Clone, Copy, Debug)]
34pub struct RateLimitConfig {
35    pub rpm: u64,
36    pub tpm: u64,
37    pub burst_rpm: u64,
38    pub burst_tpm: u64,
39}
40
41impl RateLimitConfig {
42    /// # Errors
43    /// When any `ATLAS_RATE_LIMIT_*` variable is set to something that is not
44    /// a whole number. That used to fall through to the default, and the
45    /// default here is 0 — *the limit is off*. An operator who typed
46    /// `ATLAS_RATE_LIMIT_RPM=1oo` got an unlimited server that started
47    /// cleanly and said nothing.
48    pub fn from_env() -> Result<Self, String> {
49        Self::from_raw(
50            std::env::var("ATLAS_RATE_LIMIT_RPM").ok().as_deref(),
51            std::env::var("ATLAS_RATE_LIMIT_TPM").ok().as_deref(),
52            std::env::var("ATLAS_RATE_LIMIT_BURST_RPM").ok().as_deref(),
53            std::env::var("ATLAS_RATE_LIMIT_BURST_TPM").ok().as_deref(),
54        )
55    }
56
57    /// The decision, without the environment.
58    ///
59    /// Split out so the refusals are testable: `set_var` is process-global and
60    /// would race every other test in this binary, so the environment is read
61    /// by [`Self::from_env`] and judged here.
62    ///
63    /// # Errors
64    /// As [`Self::from_env`].
65    pub fn from_raw(
66        rpm: Option<&str>,
67        tpm: Option<&str>,
68        burst_rpm: Option<&str>,
69        burst_tpm: Option<&str>,
70    ) -> Result<Self, String> {
71        use crate::env_config::parse_min;
72        let rpm = parse_min(
73            "ATLAS_RATE_LIMIT_RPM",
74            rpm,
75            0,
76            "requests per minute per client; 0 disables the request-rate limit",
77        )?
78        .unwrap_or(0);
79        let tpm = parse_min(
80            "ATLAS_RATE_LIMIT_TPM",
81            tpm,
82            0,
83            "tokens per minute per client; 0 disables the token-rate limit",
84        )?
85        .unwrap_or(0);
86        // Burst defaults to the sustained rate, so an unset burst is not the
87        // same as `0` and must stay `None` until here.
88        let burst_rpm = parse_min(
89            "ATLAS_RATE_LIMIT_BURST_RPM",
90            burst_rpm,
91            0,
92            "request-bucket depth; defaults to ATLAS_RATE_LIMIT_RPM",
93        )?
94        .unwrap_or(rpm);
95        let burst_tpm = parse_min(
96            "ATLAS_RATE_LIMIT_BURST_TPM",
97            burst_tpm,
98            0,
99            "token-bucket depth; defaults to ATLAS_RATE_LIMIT_TPM",
100        )?
101        .unwrap_or(tpm);
102        Ok(Self {
103            rpm,
104            tpm,
105            burst_rpm: burst_rpm.max(1),
106            burst_tpm: burst_tpm.max(1),
107        })
108    }
109
110    pub fn is_enabled(&self) -> bool {
111        self.rpm > 0 || self.tpm > 0
112    }
113
114    /// What to advertise as the token limit.
115    ///
116    /// `burst_tpm` floors at 1 so the bucket maths never divides by zero, but
117    /// with `tpm == 0` the token axis is NOT enforced — advertising a limit of
118    /// 1 tells a client honouring these headers that it has one token left,
119    /// when in fact it has no token limit at all. Report the same
120    /// "effectively unlimited" value the disabled path uses.
121    fn advertised_tpm(&self) -> u64 {
122        if self.tpm > 0 {
123            self.burst_tpm
124        } else {
125            1_000_000_000
126        }
127    }
128
129    /// The same, for the request axis.
130    fn advertised_rpm(&self) -> u64 {
131        if self.rpm > 0 {
132            self.burst_rpm
133        } else {
134            1_000_000
135        }
136    }
137
138    /// Remaining to advertise on each axis: the real figure when the axis is
139    /// enforced, and one consistent with `advertised_*` when it is not. A
140    /// limit of "unlimited" beside a remaining of 1 is a contradiction the
141    /// client has to resolve, and it will resolve it the cautious way.
142    fn advertised_remaining_tpm(&self, avail: u64) -> u64 {
143        if self.tpm > 0 { avail } else { 999_999_999 }
144    }
145
146    fn advertised_remaining_rpm(&self, avail: u64) -> u64 {
147        if self.rpm > 0 { avail } else { 999_999 }
148    }
149}
150
151/// Snapshot of a bucket's remaining budget — used to populate the
152/// `x-ratelimit-*-remaining` / `-reset` response headers.
153#[derive(Clone, Copy, Debug)]
154pub struct BucketSnapshot {
155    pub limit: u64,
156    pub remaining: u64,
157    /// Seconds until this bucket fully refills.
158    pub reset_secs: u64,
159}
160
161#[derive(Clone, Copy, Debug)]
162pub struct RateDecision {
163    pub allowed: bool,
164    pub requests: BucketSnapshot,
165    pub tokens: BucketSnapshot,
166    /// Seconds client should wait before retrying (Retry-After header).
167    /// Zero when `allowed`.
168    pub retry_after_secs: u64,
169    /// Which bucket triggered the denial — used for the error message.
170    pub denied_by: Option<DenialReason>,
171}
172
173#[derive(Clone, Copy, Debug)]
174pub enum DenialReason {
175    Requests,
176    Tokens,
177}
178
179/// Per-request context carried from the rate-limit middleware into the
180/// handler so the streaming true-up can refund over-estimated tokens
181/// once the actual usage is known. Injected into
182/// `Request::extensions_mut()` by the middleware when the limiter is
183/// enabled; extracted by handlers that want to refund.
184#[derive(Clone, Debug)]
185pub struct RequestContext {
186    pub identity: String,
187    /// Tokens reserved at admission time (conservative upper bound).
188    pub reserved_tokens: u64,
189}
190
191struct Bucket {
192    /// Tokens/requests currently available. f64 for fractional refill.
193    available: f64,
194    /// Last refill tick.
195    last_refill: Instant,
196}
197
198impl Bucket {
199    fn new(burst: u64) -> Self {
200        Self {
201            available: burst as f64,
202            last_refill: Instant::now(),
203        }
204    }
205
206    /// Refill based on `rate_per_sec`, then try to debit `cost`. Returns
207    /// true if the debit succeeded. Caps at `burst`.
208    fn try_consume(&mut self, cost: f64, rate_per_sec: f64, burst: f64, now: Instant) -> bool {
209        let dt = now
210            .saturating_duration_since(self.last_refill)
211            .as_secs_f64();
212        if dt > 0.0 {
213            self.available = (self.available + dt * rate_per_sec).min(burst);
214            self.last_refill = now;
215        }
216        if self.available >= cost {
217            self.available -= cost;
218            true
219        } else {
220            false
221        }
222    }
223
224    fn snapshot(&self, rate_per_sec: f64, burst: f64, now: Instant) -> (f64, u64) {
225        let dt = now
226            .saturating_duration_since(self.last_refill)
227            .as_secs_f64();
228        let available = (self.available + dt * rate_per_sec).min(burst);
229        // Seconds until fully refilled.
230        let deficit = (burst - available).max(0.0);
231        let reset = if rate_per_sec > 0.0 {
232            (deficit / rate_per_sec).ceil() as u64
233        } else {
234            0
235        };
236        (available, reset)
237    }
238
239    /// Add tokens back (used when a streaming request admits with a
240    /// reservation and actual consumption was lower).
241    fn refund(&mut self, amount: f64, burst: f64) {
242        self.available = (self.available + amount).min(burst);
243    }
244}
245
246struct KeyState {
247    requests: Bucket,
248    tokens: Bucket,
249}
250
251/// Shared concurrent rate-limiter state.
252pub struct RateLimiter {
253    cfg: RateLimitConfig,
254    inner: Mutex<HashMap<String, KeyState>>,
255    /// Last-scrubbed timestamp for idle-entry cleanup. We sweep once per
256    /// `SCRUB_INTERVAL` on the admission hot path.
257    last_scrub: Mutex<Instant>,
258}
259
260const SCRUB_INTERVAL: Duration = Duration::from_secs(120);
261/// Keys with no requests for this long are dropped from the map.
262const IDLE_EVICT: Duration = Duration::from_secs(600);
263/// Cap the per-key map at this size. Prevents OOM under a DoS where a
264/// malicious client rotates the Bearer header every request to balloon
265/// the map past `SCRUB_INTERVAL`. When the cap is hit, force an
266/// out-of-band scrub regardless of the timer.
267const MAX_KEYS: usize = 100_000;
268
269impl RateLimiter {
270    /// # Errors
271    /// As [`RateLimitConfig::from_env`].
272    pub fn from_env() -> Result<Arc<Self>, String> {
273        Ok(Arc::new(Self {
274            cfg: RateLimitConfig::from_env()?,
275            inner: Mutex::new(HashMap::new()),
276            last_scrub: Mutex::new(Instant::now()),
277        }))
278    }
279
280    #[cfg(test)]
281    pub fn with_config(cfg: RateLimitConfig) -> Arc<Self> {
282        Arc::new(Self {
283            cfg,
284            inner: Mutex::new(HashMap::new()),
285            last_scrub: Mutex::new(Instant::now()),
286        })
287    }
288
289    pub fn config(&self) -> RateLimitConfig {
290        self.cfg
291    }
292
293    /// Admission check for a new request. `estimated_tokens` is the
294    /// caller's best guess at how many tokens this call will burn
295    /// (prompt + max completion). Streaming callers pass their worst-case
296    /// so the reservation is conservative; on completion they call
297    /// `refund_tokens` with the true-up.
298    pub fn admit(&self, key: &str, estimated_tokens: u64) -> RateDecision {
299        let now = Instant::now();
300        self.scrub_if_due(now);
301
302        let rpm = self.cfg.rpm;
303        let tpm = self.cfg.tpm;
304
305        // When disabled, return effectively-unlimited snapshots.
306        if !self.cfg.is_enabled() {
307            return RateDecision {
308                allowed: true,
309                requests: BucketSnapshot {
310                    limit: 1_000_000,
311                    remaining: 999_999,
312                    reset_secs: 0,
313                },
314                tokens: BucketSnapshot {
315                    limit: 1_000_000_000,
316                    remaining: 999_999_999,
317                    reset_secs: 0,
318                },
319                retry_after_secs: 0,
320                denied_by: None,
321            };
322        }
323
324        let req_rate = rpm as f64 / 60.0;
325        let tok_rate = tpm as f64 / 60.0;
326        let req_burst = self.cfg.burst_rpm as f64;
327        let tok_burst = self.cfg.burst_tpm as f64;
328
329        let mut map = self.inner.lock();
330        // DoS guard: if the map has grown past MAX_KEYS without natural
331        // scrubbing kicking in, force-evict every idle entry now. The
332        // periodic scrub still runs every SCRUB_INTERVAL on its own.
333        if map.len() >= MAX_KEYS && !map.contains_key(key) {
334            map.retain(|_, state| {
335                state.requests.last_refill.elapsed() < IDLE_EVICT
336                    || state.tokens.last_refill.elapsed() < IDLE_EVICT
337            });
338            // If the cap is still hit (every key is genuinely active),
339            // fail-open for the new key — better to admit than to OOM.
340            // Real production with this many distinct identities should
341            // have an upstream gateway shaping traffic.
342        }
343        let state = map.entry(key.to_string()).or_insert_with(|| KeyState {
344            requests: Bucket::new(self.cfg.burst_rpm),
345            tokens: Bucket::new(self.cfg.burst_tpm),
346        });
347
348        // Request bucket.
349        let req_allowed = if rpm > 0 {
350            state.requests.try_consume(1.0, req_rate, req_burst, now)
351        } else {
352            true
353        };
354        if !req_allowed {
355            let (req_avail, req_reset) = state.requests.snapshot(req_rate, req_burst, now);
356            let (tok_avail, tok_reset) = state.tokens.snapshot(tok_rate, tok_burst, now);
357            return RateDecision {
358                allowed: false,
359                requests: BucketSnapshot {
360                    limit: self.cfg.advertised_rpm(),
361                    remaining: self.cfg.advertised_remaining_rpm(req_avail.max(0.0) as u64),
362                    reset_secs: req_reset,
363                },
364                tokens: BucketSnapshot {
365                    limit: self.cfg.advertised_tpm(),
366                    remaining: self.cfg.advertised_remaining_tpm(tok_avail.max(0.0) as u64),
367                    reset_secs: tok_reset,
368                },
369                retry_after_secs: req_reset.max(1),
370                denied_by: Some(DenialReason::Requests),
371            };
372        }
373
374        // Token bucket. Zero-cost debit when TPM is disabled.
375        let tok_allowed = if tpm > 0 {
376            state
377                .tokens
378                .try_consume(estimated_tokens as f64, tok_rate, tok_burst, now)
379        } else {
380            true
381        };
382        if !tok_allowed {
383            // Refund the request we just consumed — we're going to deny.
384            if rpm > 0 {
385                state.requests.refund(1.0, req_burst);
386            }
387            let (req_avail, req_reset) = state.requests.snapshot(req_rate, req_burst, now);
388            let (tok_avail, tok_reset) = state.tokens.snapshot(tok_rate, tok_burst, now);
389            return RateDecision {
390                allowed: false,
391                requests: BucketSnapshot {
392                    limit: self.cfg.advertised_rpm(),
393                    remaining: self.cfg.advertised_remaining_rpm(req_avail.max(0.0) as u64),
394                    reset_secs: req_reset,
395                },
396                tokens: BucketSnapshot {
397                    limit: self.cfg.advertised_tpm(),
398                    remaining: self.cfg.advertised_remaining_tpm(tok_avail.max(0.0) as u64),
399                    reset_secs: tok_reset,
400                },
401                retry_after_secs: tok_reset.max(1),
402                denied_by: Some(DenialReason::Tokens),
403            };
404        }
405
406        let (req_avail, req_reset) = state.requests.snapshot(req_rate, req_burst, now);
407        let (tok_avail, tok_reset) = state.tokens.snapshot(tok_rate, tok_burst, now);
408        RateDecision {
409            allowed: true,
410            requests: BucketSnapshot {
411                limit: self.cfg.advertised_rpm(),
412                remaining: self.cfg.advertised_remaining_rpm(req_avail.max(0.0) as u64),
413                reset_secs: req_reset,
414            },
415            tokens: BucketSnapshot {
416                limit: self.cfg.advertised_tpm(),
417                remaining: self.cfg.advertised_remaining_tpm(tok_avail.max(0.0) as u64),
418                reset_secs: tok_reset,
419            },
420            retry_after_secs: 0,
421            denied_by: None,
422        }
423    }
424
425    /// Called after a streaming request completes with `(reserved - actual)`
426    /// so over-estimated reservations don't burn the token bucket forever.
427    pub fn refund_tokens(&self, key: &str, amount: u64) {
428        if amount == 0 || !self.cfg.is_enabled() || self.cfg.tpm == 0 {
429            return;
430        }
431        let mut map = self.inner.lock();
432        if let Some(state) = map.get_mut(key) {
433            state
434                .tokens
435                .refund(amount as f64, self.cfg.burst_tpm as f64);
436        }
437    }
438
439    fn scrub_if_due(&self, now: Instant) {
440        let mut last = self.last_scrub.lock();
441        if now.saturating_duration_since(*last) < SCRUB_INTERVAL {
442            return;
443        }
444        *last = now;
445        drop(last);
446        let mut map = self.inner.lock();
447        map.retain(|_, state| {
448            state.requests.last_refill.elapsed() < IDLE_EVICT
449                || state.tokens.last_refill.elapsed() < IDLE_EVICT
450        });
451    }
452}
453
454#[path = "rate_limiter/identity.rs"]
455mod identity;
456pub use identity::extract_identity;
457
458#[cfg(test)]
459#[path = "rate_limiter/tests.rs"]
460mod tests;
461
462#[cfg(test)]
463#[path = "rate_limiter/advertised_tests.rs"]
464mod advertised_limit_tests;