Skip to main content

tensor_wasm_api/
rate_limit.rs

1// SPDX-License-Identifier: Apache-2.0
2// Copyright 2026 Craton Software Company
3
4//! Per-token QPS + burst rate limiting (token-bucket).
5//!
6//! This module implements PATH-TO-V1 v0.4 exit-criterion *Rate limiting per
7//! token*. It layers behind [`crate::middleware::bearer_auth`]: every request
8//! that survives auth carries an [`AuthContext`] in its extensions, and the
9//! [`rate_limit`] middleware uses the contained [`TokenId`] to credit a
10//! per-token `TokenBucket`.
11//!
12//! ## Design
13//!
14//! ### Token-bucket variant
15//!
16//! We use a **refill-on-take** bucket (sometimes called *lazy refill*) rather
17//! than a background-tick refill. On every request:
18//!
19//! 1. Compute elapsed nanos since the bucket's `last_refill`.
20//! 2. Add `elapsed * qps / 1_000_000_000` permits to `tokens`, clamped at
21//!    `burst`.
22//! 3. If `tokens >= 1.0` deduct one and admit; otherwise reject with
23//!    `429 Too Many Requests` and the appropriate `Retry-After` value.
24//!
25//! **Tradeoff:** refill-on-take has zero background CPU cost and zero
26//! coordination overhead (no ticker task), at the price of *cold* buckets
27//! sitting in the [`DashMap`] until the process restarts. Since the
28//! allowlist of bearer tokens is bounded by configuration size
29//! (`TENSOR_WASM_API_TOKENS` is a finite comma-separated list), the cardinality
30//! is small and bounded — a future TTL eviction sweep is a non-goal for
31//! v0.4.0. A background-tick refiller would have been the wrong choice: it
32//! requires a per-bucket schedule, awakens for idle tokens, and either holds
33//! a global lock on the wake task or fragments scheduling per shard. The
34//! refill-on-take math is two adds and a clamp; the lock is held for
35//! microseconds.
36//!
37//! ### Sharding
38//!
39//! Buckets live in `Arc<DashMap<TokenId, Mutex<BucketState>>>`. DashMap
40//! provides shard-level read/write locks; the inner `std::sync::Mutex`
41//! serialises refill arithmetic for a single bucket. We use `std::sync::Mutex`
42//! rather than `parking_lot::Mutex` to avoid pulling a new dependency into
43//! `tensor-wasm-api`; the critical section is a handful of integer ops with
44//! no `await` points, so OS-mutex contention behaviour is acceptable.
45//!
46//! ### Clock injection
47//!
48//! Unit tests need deterministic refill behaviour without `tokio::time::sleep`
49//! (slow + flaky). The [`Clock`] trait abstracts "now". Production uses
50//! [`RealClock`]; tests inject [`ManualClock`] and advance it explicitly.
51//!
52//! ## Wiring
53//!
54//! [`crate::server::build_router`] layers [`rate_limit`] after `bearer_auth`
55//! and before any route handler. If [`RateLimitConfig::is_disabled`] returns
56//! `true` (qps == 0 or burst == 0) the layer is installed but short-circuits
57//! to a pass-through — equivalent to no rate limiting.
58
59use std::sync::{Arc, Mutex};
60use std::time::{Duration, Instant};
61
62use axum::extract::Request;
63use axum::http::{HeaderValue, StatusCode};
64use axum::middleware::Next;
65use axum::response::{IntoResponse, Response};
66use axum::Json;
67use dashmap::DashMap;
68use serde::Serialize;
69use serde_json::json;
70use tensor_wasm_core::types::TenantId;
71
72use crate::routes::ApiError;
73use crate::token_scope::TokenScope;
74
75/// Stable identifier for a bearer token within a single process lifetime.
76///
77/// We do **not** key the bucket map by the raw bearer token: doing so would
78/// store secret material in the rate-limiter data structure for the lifetime
79/// of the process. Instead we hash the token with the standard library's
80/// SipHash (via [`std::collections::hash_map::DefaultHasher`]). SipHash is
81/// keyed with process-local random state by the standard library, which is
82/// sufficient as a key-derivation step here — the only consumer is a
83/// [`DashMap`] lookup, never an authorization check.
84///
85/// In **dev mode** (empty allowlist) every request shares [`TokenId::DEV`]
86/// so a single shared bucket throttles dev-mode traffic exactly the same way
87/// as it would a single allowlisted production token.
88#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize)]
89#[serde(transparent)]
90pub struct TokenId(pub u64);
91
92impl TokenId {
93    /// Synthetic [`TokenId`] used in dev mode (no `TENSOR_WASM_API_TOKENS`).
94    pub const DEV: TokenId = TokenId(0);
95
96    /// Derive a [`TokenId`] from a bearer-token string. Uses the standard
97    /// library's randomly-seeded SipHash; the returned value is stable for
98    /// the lifetime of the process but unpredictable to outside callers.
99    pub fn from_bearer(token: &str) -> Self {
100        use std::collections::hash_map::DefaultHasher;
101        use std::hash::{Hash, Hasher};
102        let mut h = DefaultHasher::new();
103        // Domain-separate from non-bearer hashes by mixing in a fixed tag.
104        b"tensor-wasm-api/rate-limit/v1".hash(&mut h);
105        token.hash(&mut h);
106        // Force away from the dev sentinel in the (astronomically unlikely)
107        // collision case so a real allowlisted token can never alias `DEV`.
108        let v = h.finish();
109        TokenId(if v == Self::DEV.0 { 1 } else { v })
110    }
111}
112
113/// Per-request authentication context inserted into [`axum::http::Extensions`]
114/// by [`crate::middleware::bearer_auth`] after a successful auth check.
115///
116/// Downstream middleware (rate limiting, audit logging) and route handlers
117/// (tenant-scope authorization) consume this rather than re-parsing the
118/// `Authorization` header.
119#[derive(Debug, Clone)]
120pub struct AuthContext {
121    /// Stable identifier for the authenticated bearer token. See [`TokenId`].
122    pub token_id: TokenId,
123    /// Tenants this token is authorised to address. Populated by the bearer
124    /// auth middleware from the parsed [`crate::token_scope::ParsedTokens`]
125    /// map. Dev-mode contexts default to [`TokenScope::all`].
126    ///
127    /// Handlers that bind to a tenant call [`AuthContext::authorize_tenant`]
128    /// before doing per-tenant work.
129    pub scope: TokenScope,
130}
131
132impl AuthContext {
133    /// Construct an [`AuthContext`] for a successfully-authenticated token,
134    /// defaulting the scope to wildcard. Retained as a back-compat helper
135    /// for tests that pre-date scoped tokens; production code goes through
136    /// [`AuthContext::with_scope`].
137    pub fn for_token(token: &str) -> Self {
138        Self {
139            token_id: TokenId::from_bearer(token),
140            scope: TokenScope::all(),
141        }
142    }
143
144    /// Construct an [`AuthContext`] with an explicit scope.
145    pub fn with_scope(token: &str, scope: TokenScope) -> Self {
146        Self {
147            token_id: TokenId::from_bearer(token),
148            scope,
149        }
150    }
151
152    /// Construct the dev-mode pass-through context. Dev mode always grants
153    /// the wildcard scope — the operator already opted out of auth by
154    /// leaving the allowlist empty, so per-tenant gating would be theatre.
155    pub fn dev() -> Self {
156        Self {
157            token_id: TokenId::DEV,
158            scope: TokenScope::all(),
159        }
160    }
161
162    /// Return `Ok(())` if this token may address `tenant`, otherwise an
163    /// [`ApiError`] with `kind: "tenant_scope_denied"`. Routes that bind to
164    /// a tenant call this before doing any per-tenant work.
165    pub fn authorize_tenant(&self, tenant: TenantId) -> Result<(), ApiError> {
166        if self.scope.allows(tenant) {
167            Ok(())
168        } else {
169            Err(ApiError::forbidden(
170                "tenant_scope_denied",
171                format!(
172                    "bearer token is not scoped to tenant {}; \
173                     extend the token's tenant= clause in TENSOR_WASM_API_TOKENS",
174                    tenant.0,
175                ),
176            ))
177        }
178    }
179}
180
181/// Per-tenant (secondary) rate-limit configuration.
182///
183/// Sits in front of the per-token bucket as the *primary* defence against a
184/// noisy-neighbour tenant saturating a shared token's overall quota. The
185/// per-token bucket (see [`RateLimitConfig`]) is retained as a backstop.
186///
187/// Composite bucket key is `(TokenId, TenantId)` — so a single token used by
188/// two tenants does not let one tenant drain the other's allowance.
189///
190/// **Field semantics:**
191/// * `burst == 0` => disabled (this layer admits unconditionally). The per-
192///   token backstop still applies if it is itself configured.
193/// * `qps == 0.0` => non-zero burst is a one-shot allowance with **no
194///   refill**. Useful for tests; in production an operator who wants no
195///   per-tenant ceiling should set `burst = 0` to disable the layer outright.
196#[derive(Debug, Clone, Copy, PartialEq)]
197pub struct PerTenantRateLimitConfig {
198    /// Maximum burst — the per-tenant bucket capacity, in permits.
199    pub burst: u32,
200    /// Steady-state requests-per-second admitted per `(token, tenant)` pair.
201    /// `0.0` disables refill (the bucket drains and stays empty until process
202    /// restart).
203    pub qps: f64,
204}
205
206impl PerTenantRateLimitConfig {
207    /// Default per-tenant burst. Deliberately conservative so a misbehaving
208    /// tenant on a shared token cannot trample neighbours; operators tune
209    /// upward as their multi-tenant workload demands.
210    pub const DEFAULT_BURST: u32 = 20;
211
212    /// Default per-tenant steady-state QPS. Matches the conservative
213    /// [`DEFAULT_BURST`](Self::DEFAULT_BURST) shape — sized for the small
214    /// internal tenant fleet today; operators raise it as needed.
215    pub const DEFAULT_QPS: f64 = 10.0;
216
217    /// Disabled config: per-tenant layer admits unconditionally.
218    pub const fn disabled() -> Self {
219        Self { burst: 0, qps: 0.0 }
220    }
221
222    /// `true` if the per-tenant layer is disabled. Determined solely by
223    /// `burst == 0`: a non-zero burst with `qps == 0.0` is a valid (no-
224    /// refill) configuration, not a disabled one.
225    pub const fn is_disabled(&self) -> bool {
226        self.burst == 0
227    }
228}
229
230impl Default for PerTenantRateLimitConfig {
231    /// Default to the conservative active configuration
232    /// (`burst = 20`, `qps = 10.0`). Operators reach the fully-disabled
233    /// posture via [`PerTenantRateLimitConfig::disabled`].
234    fn default() -> Self {
235        Self {
236            burst: Self::DEFAULT_BURST,
237            qps: Self::DEFAULT_QPS,
238        }
239    }
240}
241
242/// Static configuration for the rate limiter.
243///
244/// Two layers, both enforced (whichever is tighter wins):
245///
246/// 1. **Per-tenant bucket** keyed on `(TokenId, TenantId)`
247///    ([`per_tenant_default`](Self::per_tenant_default)) — primary defence.
248///    Prevents one tenant from saturating a shared token's quota.
249/// 2. **Per-token bucket** keyed on `TokenId` ([`qps`](Self::qps),
250///    [`burst`](Self::burst)) — backstop. Caps aggregate usage by a single
251///    token across all tenants.
252///
253/// Token-level knobs come from `TENSOR_WASM_API_RATE_LIMIT_QPS` and
254/// `TENSOR_WASM_API_RATE_LIMIT_BURST` at server startup; per-tenant defaults
255/// to [`PerTenantRateLimitConfig::default`]. If both knobs are zero (or
256/// unset) the token-level backstop is disabled, but the per-tenant layer is
257/// still in force unless explicitly cleared — see
258/// [`RateLimitConfig::is_disabled`].
259///
260/// Note: this type is no longer `Eq` because [`PerTenantRateLimitConfig::qps`]
261/// is `f64`. Use `PartialEq` for comparisons in tests.
262#[derive(Debug, Clone, Copy, PartialEq)]
263pub struct RateLimitConfig {
264    /// Steady-state requests-per-second admitted per token (backstop layer).
265    pub qps: u32,
266    /// Maximum burst — the per-token bucket capacity, in permits.
267    pub burst: u32,
268    /// Default per-tenant configuration applied to every `(token, tenant)`
269    /// pair. The primary defence against a single tenant exhausting a
270    /// shared token's quota.
271    pub per_tenant_default: PerTenantRateLimitConfig,
272}
273
274impl RateLimitConfig {
275    /// Environment variable carrying the steady-state QPS allowance per token.
276    pub const ENV_QPS: &'static str = "TENSOR_WASM_API_RATE_LIMIT_QPS";
277
278    /// Environment variable carrying the burst (bucket capacity) per token.
279    pub const ENV_BURST: &'static str = "TENSOR_WASM_API_RATE_LIMIT_BURST";
280
281    /// Default QPS applied when `ENV_QPS` is unset but `ENV_BURST` is set.
282    pub const DEFAULT_QPS: u32 = 100;
283
284    /// Default burst applied when `ENV_BURST` is unset but `ENV_QPS` is set.
285    pub const DEFAULT_BURST: u32 = 200;
286
287    /// Disabled config: every layer off. The middleware is a pass-through.
288    pub const fn disabled() -> Self {
289        Self {
290            qps: 0,
291            burst: 0,
292            per_tenant_default: PerTenantRateLimitConfig::disabled(),
293        }
294    }
295
296    /// `true` if every layer of the limiter is disabled and the middleware
297    /// would unconditionally admit. Used by [`rate_limit`] to short-circuit
298    /// the bucket lookup entirely.
299    pub const fn is_disabled(&self) -> bool {
300        self.is_token_layer_disabled() && self.per_tenant_default.is_disabled()
301    }
302
303    /// `true` if the per-token (backstop) layer is disabled.
304    pub const fn is_token_layer_disabled(&self) -> bool {
305        self.qps == 0 || self.burst == 0
306    }
307
308    /// Load from the process environment.
309    ///
310    /// * Both vars unset / either `"0"` / either unparseable => token-layer
311    ///   disabled. The per-tenant layer still defaults to
312    ///   [`PerTenantRateLimitConfig::default`].
313    /// * Otherwise: missing-but-other-side-set falls back to
314    ///   [`DEFAULT_QPS`](Self::DEFAULT_QPS) / [`DEFAULT_BURST`](Self::DEFAULT_BURST).
315    pub fn from_env() -> Self {
316        let per_tenant_default = PerTenantRateLimitConfig::default();
317        let qps_raw = std::env::var(Self::ENV_QPS).ok();
318        let burst_raw = std::env::var(Self::ENV_BURST).ok();
319        if qps_raw.is_none() && burst_raw.is_none() {
320            return Self {
321                qps: 0,
322                burst: 0,
323                per_tenant_default,
324            };
325        }
326        let qps = qps_raw
327            .as_deref()
328            .map(|s| s.trim().parse::<u32>().unwrap_or(0))
329            .unwrap_or(Self::DEFAULT_QPS);
330        let burst = burst_raw
331            .as_deref()
332            .map(|s| s.trim().parse::<u32>().unwrap_or(0))
333            .unwrap_or(Self::DEFAULT_BURST);
334        let cfg = Self {
335            qps,
336            burst,
337            per_tenant_default,
338        };
339        if cfg.is_token_layer_disabled() {
340            tracing::warn!(
341                target: "tensor_wasm_api::rate_limit",
342                qps,
343                burst,
344                "{} / {} parsed but yields a disabled token-layer limiter (qps==0 or burst==0); per-tenant layer still active",
345                Self::ENV_QPS,
346                Self::ENV_BURST,
347            );
348            return Self {
349                qps: 0,
350                burst: 0,
351                per_tenant_default,
352            };
353        }
354        tracing::info!(
355            target: "tensor_wasm_api::rate_limit",
356            qps,
357            burst,
358            per_tenant_burst = per_tenant_default.burst,
359            per_tenant_qps = per_tenant_default.qps,
360            "rate limiter enabled (per-token backstop + per-tenant primary)",
361        );
362        cfg
363    }
364}
365
366impl Default for RateLimitConfig {
367    /// Default is *disabled*. Operators opt in by setting both env vars (or
368    /// by constructing a config explicitly).
369    fn default() -> Self {
370        Self::disabled()
371    }
372}
373
374/// Abstract monotonic clock. Implemented by [`RealClock`] (production) and
375/// [`ManualClock`] (tests).
376pub trait Clock: Send + Sync + 'static {
377    /// Return the current monotonic [`Instant`].
378    fn now(&self) -> Instant;
379}
380
381/// Production clock: delegates to [`Instant::now`].
382#[derive(Debug, Clone, Copy, Default)]
383pub struct RealClock;
384
385impl Clock for RealClock {
386    fn now(&self) -> Instant {
387        Instant::now()
388    }
389}
390
391/// Test clock: holds an explicit [`Instant`] that the test advances.
392#[derive(Debug, Clone)]
393pub struct ManualClock {
394    inner: Arc<Mutex<Instant>>,
395}
396
397impl ManualClock {
398    /// Construct a [`ManualClock`] seeded at `Instant::now()`.
399    pub fn new() -> Self {
400        Self {
401            inner: Arc::new(Mutex::new(Instant::now())),
402        }
403    }
404
405    /// Advance the clock by `d`.
406    pub fn advance(&self, d: Duration) {
407        let mut g = self.inner.lock().expect("ManualClock mutex poisoned");
408        *g += d;
409    }
410}
411
412impl Default for ManualClock {
413    fn default() -> Self {
414        Self::new()
415    }
416}
417
418impl Clock for ManualClock {
419    fn now(&self) -> Instant {
420        *self.inner.lock().expect("ManualClock mutex poisoned")
421    }
422}
423
424/// Per-token bucket state. Protected by a `std::sync::Mutex`.
425#[derive(Debug)]
426struct BucketState {
427    /// Current permit balance. Stored as `f64` so refill arithmetic does
428    /// not lose sub-permit progress between requests at QPS values that
429    /// don't divide evenly into a millisecond.
430    tokens: f64,
431    /// Monotonic instant of the most recent refill calculation.
432    last_refill: Instant,
433    /// Monotonic instant of the most recent admit attempt against this
434    /// bucket. Drives LRU eviction of the per-`(token, tenant)` map (L10):
435    /// `last_refill` is updated on every refill regardless of layer, but we
436    /// keep a separate field so the eviction policy reads intent ("last
437    /// touched by a request") rather than refill bookkeeping. In practice the
438    /// two move together; the distinct name keeps the eviction call site
439    /// self-documenting.
440    last_access: Instant,
441}
442
443/// Outcome of an attempt to claim a permit from the bucket.
444#[derive(Debug, Clone, Copy, PartialEq)]
445pub enum AdmitResult {
446    /// Request admitted; one permit was deducted.
447    Admit,
448    /// Request rejected; carries the suggested `Retry-After` value (in
449    /// whole seconds, rounded up — HTTP `Retry-After` is integer seconds
450    /// when not a date).
451    Reject {
452        /// Seconds the client should wait before retrying.
453        retry_after_secs: u64,
454    },
455}
456
457/// In-process two-layer rate limiter.
458///
459/// Layer 1 (primary): `(TokenId, TenantId)` bucket — keeps a shared token
460/// from being drained by a single tenant.
461///
462/// Layer 2 (backstop): `TokenId` bucket — caps aggregate usage by a single
463/// token across all tenants. Inherited from the v0.4 design; kept active so
464/// pre-multi-tenant operators see no behavioural regression.
465///
466/// Cheaply cloneable: every clone shares the same underlying [`DashMap`]s
467/// and [`Clock`] via [`Arc`].
468#[derive(Clone)]
469pub struct RateLimiter {
470    cfg: RateLimitConfig,
471    clock: Arc<dyn Clock>,
472    /// Per-token (backstop) buckets.
473    buckets: Arc<DashMap<TokenId, Mutex<BucketState>>>,
474    /// Per-(token, tenant) (primary) buckets. We use a composite key so a
475    /// single shared token still gets per-tenant isolation. With the dev
476    /// sentinel token, this also separates internal-cron tenants from
477    /// external traffic that lands on `TokenId::DEV`.
478    ///
479    /// **L10 — bounded growth.** A wildcard-scope token can spray arbitrarily
480    /// many distinct `X-TensorWasm-Tenant` header values, each minting a fresh
481    /// `(token, tenant)` key. Left unchecked this map grows without bound (a
482    /// memory-exhaustion DoS). We cap the number of distinct tenants tracked
483    /// per token at [`MAX_TENANTS_PER_TOKEN`](RateLimiter::MAX_TENANTS_PER_TOKEN)
484    /// and evict the least-recently-used `(token, tenant)` bucket for that
485    /// token when a brand-new tenant would push it over the cap. Eviction runs
486    /// opportunistically — only on the insert of a previously-unseen
487    /// `(token, tenant)` pair — so the steady-state hot path (an existing
488    /// bucket) pays nothing.
489    per_tenant_buckets: Arc<DashMap<(TokenId, TenantId), Mutex<BucketState>>>,
490}
491
492impl std::fmt::Debug for RateLimiter {
493    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
494        f.debug_struct("RateLimiter")
495            .field("cfg", &self.cfg)
496            .field("buckets", &self.buckets.len())
497            .field("per_tenant_buckets", &self.per_tenant_buckets.len())
498            .finish()
499    }
500}
501
502impl RateLimiter {
503    /// Maximum number of distinct tenants tracked per token in the
504    /// per-`(token, tenant)` bucket map before LRU eviction kicks in (L10).
505    ///
506    /// Sized generously relative to any realistic legitimate fan-out: a
507    /// single token addressing more than a few thousand tenants is either a
508    /// misconfiguration or an attack, and in both cases bounding the memory
509    /// is the right call. The eviction only ever discards rate-limiter
510    /// bookkeeping (a `f64` balance + two `Instant`s); a subsequently-evicted
511    /// tenant simply starts from a full burst on its next request, which is
512    /// strictly more permissive — never a correctness or security regression.
513    pub const MAX_TENANTS_PER_TOKEN: usize = 4096;
514
515    /// Construct a limiter with the production [`RealClock`].
516    pub fn new(cfg: RateLimitConfig) -> Self {
517        Self::with_clock(cfg, Arc::new(RealClock))
518    }
519
520    /// Construct a limiter with an injected clock (for tests).
521    pub fn with_clock(cfg: RateLimitConfig, clock: Arc<dyn Clock>) -> Self {
522        Self {
523            cfg,
524            clock,
525            buckets: Arc::new(DashMap::new()),
526            per_tenant_buckets: Arc::new(DashMap::new()),
527        }
528    }
529
530    /// `true` if every configured layer admits unconditionally.
531    pub fn is_disabled(&self) -> bool {
532        self.cfg.is_disabled()
533    }
534
535    /// Effective configuration this limiter was built with.
536    pub fn config(&self) -> RateLimitConfig {
537        self.cfg
538    }
539
540    /// Attempt to claim one permit for the `(token, tenant)` pair.
541    ///
542    /// Both the per-tenant (primary) and per-token (backstop) buckets must
543    /// admit. If either rejects we return [`AdmitResult::Reject`] carrying
544    /// the **smaller** of the two retry [`Duration`]s — per the
545    /// per-tenant-bucket design note, the smaller backoff is the earliest
546    /// the client could plausibly retry, even though it may still face the
547    /// other bucket on the next attempt.
548    ///
549    /// To avoid leaking a permit on one layer when the other rejects, we
550    /// hold both layers' inner mutexes across the decision and only deduct
551    /// when *both* would admit. The lock-order is `(token, tenant)` then
552    /// `token`; since these live in two distinct [`DashMap`]s and every
553    /// caller takes them in the same order, no cycle is possible.
554    pub fn try_admit(&self, token: TokenId, tenant: TenantId) -> AdmitResult {
555        if self.is_disabled() {
556            return AdmitResult::Admit;
557        }
558        let now = self.clock.now();
559
560        // Acquire entries for whichever layers are active. We hold the
561        // DashMap entries (RefMut) for the full critical section so the
562        // inner Mutex guards stay valid; the underlying shards stay locked
563        // only for the brief Mutex lock/unlock, not the whole decision.
564        let per_tenant_burst = self.cfg.per_tenant_default.burst as f64;
565        let per_tenant_qps = self.cfg.per_tenant_default.qps;
566        let token_burst = self.cfg.burst as f64;
567        let token_qps = self.cfg.qps as f64;
568
569        let per_tenant_entry = if self.cfg.per_tenant_default.is_disabled() {
570            None
571        } else {
572            // L10: before minting a bucket for a previously-unseen
573            // (token, tenant) pair, make room by evicting this token's
574            // least-recently-used tenant if it is already at the cap. We
575            // gate the (relatively expensive) eviction scan behind a cheap
576            // `contains_key` so the steady-state path — an existing bucket —
577            // never touches it. The check/evict/insert sequence is not atomic
578            // across shards, but a benign race only briefly overshoots the
579            // cap by the number of concurrent first-touches and self-corrects
580            // on the next new-tenant insert.
581            if !self.per_tenant_buckets.contains_key(&(token, tenant)) {
582                self.evict_lru_tenant_if_at_cap(token);
583            }
584            Some(
585                self.per_tenant_buckets
586                    .entry((token, tenant))
587                    .or_insert_with(|| {
588                        Mutex::new(BucketState {
589                            tokens: per_tenant_burst,
590                            last_refill: now,
591                            last_access: now,
592                        })
593                    }),
594            )
595        };
596        let token_entry = if self.cfg.is_token_layer_disabled() {
597            None
598        } else {
599            Some(self.buckets.entry(token).or_insert_with(|| {
600                Mutex::new(BucketState {
601                    tokens: token_burst,
602                    last_refill: now,
603                    last_access: now,
604                })
605            }))
606        };
607
608        // Lock both buckets, in a fixed order, for the whole decision.
609        let mut per_tenant_guard = per_tenant_entry.as_ref().map(|e| {
610            e.value()
611                .lock()
612                .expect("RateLimiter per-tenant bucket mutex poisoned")
613        });
614        let mut token_guard = token_entry.as_ref().map(|e| {
615            e.value()
616                .lock()
617                .expect("RateLimiter token bucket mutex poisoned")
618        });
619
620        let per_tenant_decision = per_tenant_guard
621            .as_deref_mut()
622            .map(|s| refill_and_decide(s, per_tenant_burst, per_tenant_qps, now));
623        let token_decision = token_guard
624            .as_deref_mut()
625            .map(|s| refill_and_decide(s, token_burst, token_qps, now));
626
627        // `is_none_or` is MSRV 1.82; workspace MSRV is 1.78, so keep the
628        // map_or formulation here.
629        #[allow(clippy::unnecessary_map_or)]
630        let per_tenant_admit = per_tenant_decision.as_ref().map_or(true, |d| d.admittable);
631        #[allow(clippy::unnecessary_map_or)]
632        let token_admit = token_decision.as_ref().map_or(true, |d| d.admittable);
633
634        if per_tenant_admit && token_admit {
635            if let Some(state) = per_tenant_guard.as_deref_mut() {
636                state.tokens -= 1.0;
637            }
638            if let Some(state) = token_guard.as_deref_mut() {
639                state.tokens -= 1.0;
640            }
641            return AdmitResult::Admit;
642        }
643
644        // At least one layer rejected. Per spec: signal with the SMALLER of
645        // the two retry durations. (An admitting layer contributes nothing
646        // — its implied duration is zero; we only consider durations from
647        // layers that themselves rejected.)
648        let mut chosen: Option<Duration> = None;
649        for d in [per_tenant_decision.as_ref(), token_decision.as_ref()]
650            .into_iter()
651            .flatten()
652        {
653            if !d.admittable {
654                chosen = Some(match chosen {
655                    None => d.retry_after,
656                    Some(prev) => prev.min(d.retry_after),
657                });
658            }
659        }
660        let retry = chosen.unwrap_or(Duration::from_secs(1));
661        let secs = retry.as_secs_f64().ceil() as u64;
662        AdmitResult::Reject {
663            // Always suggest at least 1s so misbehaving clients back off a
664            // measurable amount even when qps is very high.
665            retry_after_secs: secs.max(1),
666        }
667    }
668
669    /// L10 eviction: if `token` already owns
670    /// [`MAX_TENANTS_PER_TOKEN`](Self::MAX_TENANTS_PER_TOKEN) (or more)
671    /// distinct per-tenant buckets, drop the single least-recently-accessed
672    /// one to make room for the caller's pending insert. Bounds the
673    /// `per_tenant_buckets` map at `MAX_TENANTS_PER_TOKEN` entries per token,
674    /// so a wildcard token spraying distinct `X-TensorWasm-Tenant` values
675    /// cannot grow it without limit.
676    ///
677    /// Called only on the cold path (first request for a `(token, tenant)`
678    /// pair), so the per-token scan over the map does not touch the
679    /// steady-state hot path. Evicting a bucket merely resets that tenant's
680    /// rate-limit bookkeeping; the next request for it rebuilds the bucket at
681    /// full burst, which is strictly more permissive and never a security
682    /// regression (the per-token backstop layer still caps aggregate usage).
683    fn evict_lru_tenant_if_at_cap(&self, token: TokenId) {
684        // Single pass: count this token's buckets and remember the coldest.
685        let mut count = 0usize;
686        let mut lru_key: Option<(TokenId, TenantId)> = None;
687        let mut lru_access: Option<Instant> = None;
688        for entry in self.per_tenant_buckets.iter() {
689            let key = *entry.key();
690            if key.0 != token {
691                continue;
692            }
693            count += 1;
694            let access = entry
695                .value()
696                .lock()
697                .map(|s| s.last_access)
698                .unwrap_or_else(|p| p.into_inner().last_access);
699            // `is_none_or` is MSRV 1.82; workspace MSRV is 1.78, so keep the
700            // explicit match formulation (mirrors the `map_or` note in
701            // `try_admit`).
702            let colder = match lru_access {
703                None => true,
704                Some(cur) => access < cur,
705            };
706            if colder {
707                lru_access = Some(access);
708                lru_key = Some(key);
709            }
710        }
711        if count < Self::MAX_TENANTS_PER_TOKEN {
712            return;
713        }
714        if let Some(key) = lru_key {
715            // `remove` is a no-op if a concurrent caller already evicted it.
716            self.per_tenant_buckets.remove(&key);
717        }
718    }
719}
720
721/// Per-layer decision returned by `refill_and_decide`.
722struct BucketDecision {
723    /// `true` if this layer alone would admit the request.
724    admittable: bool,
725    /// Retry hint for this layer if `!admittable`. Zero when `admittable`.
726    retry_after: Duration,
727}
728
729/// Refill the bucket in place (updating `last_refill`) and report whether
730/// it currently has at least one full permit. Does *not* deduct — the
731/// caller subtracts one only after both layers agree to admit.
732fn refill_and_decide(
733    state: &mut BucketState,
734    burst: f64,
735    qps: f64,
736    now: Instant,
737) -> BucketDecision {
738    let elapsed = now.saturating_duration_since(state.last_refill);
739    if elapsed > Duration::ZERO && qps > 0.0 {
740        state.tokens = (state.tokens + elapsed.as_secs_f64() * qps).min(burst);
741    } else {
742        state.tokens = state.tokens.min(burst);
743    }
744    state.last_refill = now;
745    // L10: record the access so LRU eviction (see
746    // `RateLimiter::evict_lru_tenant_if_at_cap`) can identify the coldest
747    // per-tenant bucket for a token under fan-out pressure.
748    state.last_access = now;
749    if state.tokens >= 1.0 {
750        BucketDecision {
751            admittable: true,
752            retry_after: Duration::ZERO,
753        }
754    } else {
755        let deficit = 1.0 - state.tokens;
756        let retry = if qps > 0.0 {
757            Duration::from_secs_f64((deficit / qps).max(0.0))
758        } else {
759            // No refill ever — surface a large but finite hint. We pick 1h
760            // so it is visibly "go away" without being u64::MAX. Tests for
761            // the no-refill case only assert reject, never the magnitude.
762            Duration::from_secs(3600)
763        };
764        BucketDecision {
765            admittable: false,
766            retry_after: retry,
767        }
768    }
769}
770
771/// Render the standard `{ "error": { "kind": ..., "message": ... } }`
772/// envelope at `status`, attaching a `Retry-After` header.
773fn rate_limited_response(retry_after_secs: u64) -> Response {
774    let body = Json(json!({
775        "error": {
776            "kind": "rate_limited",
777            "message": format!(
778                "per-token rate limit exceeded; retry after {retry_after_secs}s",
779            ),
780        }
781    }));
782    let mut resp = (StatusCode::TOO_MANY_REQUESTS, body).into_response();
783    // `Retry-After` per RFC 9110 §10.2.3 may be either an HTTP-date or a
784    // non-negative decimal integer of seconds. We emit the latter.
785    if let Ok(hv) = HeaderValue::from_str(&retry_after_secs.to_string()) {
786        resp.headers_mut()
787            .insert(axum::http::header::RETRY_AFTER, hv);
788    }
789    resp
790}
791
792/// Axum middleware that enforces the per-token rate limit.
793///
794/// Reads [`AuthContext`] from request extensions (inserted by
795/// [`crate::middleware::bearer_auth`]) and consults the [`RateLimiter`]
796/// supplied via an `axum::Extension`. On bucket-empty, returns
797/// `429 Too Many Requests` with a `Retry-After` header.
798///
799/// When no [`RateLimiter`] is in the extensions the middleware is a
800/// pass-through (the operator did not configure rate limiting).
801pub async fn rate_limit(req: Request, next: Next) -> Response {
802    let limiter = match req.extensions().get::<RateLimiter>().cloned() {
803        Some(l) => l,
804        None => return next.run(req).await,
805    };
806    if limiter.is_disabled() {
807        return next.run(req).await;
808    }
809    let token = req
810        .extensions()
811        .get::<AuthContext>()
812        .map(|c| c.token_id)
813        // Defensive: if the auth middleware was somehow bypassed, fold all
814        // un-authed requests into the dev bucket so they still face the
815        // configured cap. This should not happen in the production stack.
816        .unwrap_or(TokenId::DEV);
817    // Per-tenant rate-limit (api S-25): the tenant_scope middleware sets
818    // a tenant extension on the request. Fall back to TenantId(0) for the
819    // unauthenticated / probe paths so they share a single bucket.
820    let tenant = req
821        .extensions()
822        .get::<tensor_wasm_core::types::TenantId>()
823        .copied()
824        .unwrap_or(tensor_wasm_core::types::TenantId(0));
825    match limiter.try_admit(token, tenant) {
826        AdmitResult::Admit => next.run(req).await,
827        AdmitResult::Reject { retry_after_secs } => rate_limited_response(retry_after_secs),
828    }
829}
830
831// ---------------------------------------------------------------------------
832// Tests
833// ---------------------------------------------------------------------------
834
835#[cfg(test)]
836mod tests {
837    use super::*;
838
839    use axum::body::Body;
840    use axum::http::{Method, Request};
841    use axum::routing::get;
842    use axum::Router;
843    use tower::ServiceExt;
844
845    fn cfg(qps: u32, burst: u32) -> RateLimitConfig {
846        // Disable the per-tenant layer so these tests exercise the token
847        // (backstop) layer in isolation; the per-tenant primary layer has
848        // its own dedicated tests elsewhere.
849        RateLimitConfig {
850            qps,
851            burst,
852            per_tenant_default: PerTenantRateLimitConfig::disabled(),
853        }
854    }
855
856    /// Convenience: the tenant every inline unit test in this module pins
857    /// to. Pre-multi-tenant tests only need a single stable value here.
858    const TENANT: TenantId = TenantId(1);
859
860    #[test]
861    fn config_disabled_when_either_zero() {
862        assert!(cfg(0, 10).is_disabled());
863        assert!(cfg(10, 0).is_disabled());
864        assert!(cfg(0, 0).is_disabled());
865        assert!(!cfg(1, 1).is_disabled());
866    }
867
868    #[test]
869    fn token_id_dev_is_distinct_from_real_tokens() {
870        assert_ne!(TokenId::from_bearer("anything").0, TokenId::DEV.0);
871        // Stable within a process for the same input.
872        assert_eq!(
873            TokenId::from_bearer("alpha").0,
874            TokenId::from_bearer("alpha").0
875        );
876        assert_ne!(
877            TokenId::from_bearer("alpha").0,
878            TokenId::from_bearer("beta").0
879        );
880    }
881
882    #[test]
883    fn bucket_allows_up_to_burst_immediately() {
884        let clock = Arc::new(ManualClock::new());
885        let limiter = RateLimiter::with_clock(cfg(10, 5), clock.clone());
886        let tok = TokenId::from_bearer("alpha");
887        for i in 0..5 {
888            assert!(
889                matches!(limiter.try_admit(tok, TENANT), AdmitResult::Admit),
890                "burst slot {i} should admit",
891            );
892        }
893        // 6th request in the same instant: rejected.
894        assert!(matches!(
895            limiter.try_admit(tok, TENANT),
896            AdmitResult::Reject { .. },
897        ));
898    }
899
900    #[test]
901    fn bucket_refills_at_qps_rate_with_manual_clock() {
902        let clock = Arc::new(ManualClock::new());
903        let limiter = RateLimiter::with_clock(cfg(10, 5), clock.clone());
904        let tok = TokenId::from_bearer("alpha");
905        // Drain the bucket.
906        for _ in 0..5 {
907            assert!(matches!(limiter.try_admit(tok, TENANT), AdmitResult::Admit));
908        }
909        assert!(matches!(
910            limiter.try_admit(tok, TENANT),
911            AdmitResult::Reject { .. },
912        ));
913        // Advance enough wall-time to refill exactly one permit at 10 qps.
914        clock.advance(Duration::from_millis(100));
915        assert!(matches!(limiter.try_admit(tok, TENANT), AdmitResult::Admit));
916        // Immediately after: empty again.
917        assert!(matches!(
918            limiter.try_admit(tok, TENANT),
919            AdmitResult::Reject { .. },
920        ));
921        // Advance enough to refill the entire burst.
922        clock.advance(Duration::from_secs(1));
923        for _ in 0..5 {
924            assert!(matches!(limiter.try_admit(tok, TENANT), AdmitResult::Admit));
925        }
926        assert!(matches!(
927            limiter.try_admit(tok, TENANT),
928            AdmitResult::Reject { .. },
929        ));
930    }
931
932    #[test]
933    fn separate_tokens_have_separate_buckets() {
934        let clock = Arc::new(ManualClock::new());
935        let limiter = RateLimiter::with_clock(cfg(1, 2), clock.clone());
936        let a = TokenId::from_bearer("alpha");
937        let b = TokenId::from_bearer("beta");
938        for _ in 0..2 {
939            assert!(matches!(limiter.try_admit(a, TENANT), AdmitResult::Admit));
940        }
941        // A is drained.
942        assert!(matches!(
943            limiter.try_admit(a, TENANT),
944            AdmitResult::Reject { .. }
945        ));
946        // B is untouched.
947        for _ in 0..2 {
948            assert!(matches!(limiter.try_admit(b, TENANT), AdmitResult::Admit));
949        }
950        assert!(matches!(
951            limiter.try_admit(b, TENANT),
952            AdmitResult::Reject { .. }
953        ));
954    }
955
956    #[test]
957    fn disabled_limiter_admits_unconditionally() {
958        let clock = Arc::new(ManualClock::new());
959        let limiter = RateLimiter::with_clock(RateLimitConfig::disabled(), clock);
960        let tok = TokenId::from_bearer("alpha");
961        for _ in 0..1000 {
962            assert!(matches!(limiter.try_admit(tok, TENANT), AdmitResult::Admit));
963        }
964    }
965
966    #[test]
967    fn reject_carries_retry_after_at_least_one_second() {
968        let clock = Arc::new(ManualClock::new());
969        // qps=1000, burst=1 → very fast refill, but we still want >=1s back-off.
970        let limiter = RateLimiter::with_clock(cfg(1000, 1), clock.clone());
971        let tok = TokenId::from_bearer("alpha");
972        assert!(matches!(limiter.try_admit(tok, TENANT), AdmitResult::Admit));
973        match limiter.try_admit(tok, TENANT) {
974            AdmitResult::Reject { retry_after_secs } => {
975                assert!(retry_after_secs >= 1, "got {retry_after_secs}");
976            }
977            other => panic!("expected reject, got {other:?}"),
978        }
979    }
980
981    /// Drives a minimal router that exercises just the rate-limit middleware
982    /// in front of a 200-handler, so we can assert the integration layer
983    /// emits 429 + `Retry-After` exactly as specified.
984    async fn drive_tower(
985        limiter: RateLimiter,
986        auth: AuthContext,
987        n: usize,
988    ) -> Vec<(StatusCode, Option<String>)> {
989        // Per-test inline handler: returns 204 if it ran, so any non-204
990        // status had to come from the middleware short-circuit.
991        async fn ok() -> StatusCode {
992            StatusCode::NO_CONTENT
993        }
994        let router = Router::new()
995            .route("/probe", get(ok))
996            .layer(axum::middleware::from_fn(rate_limit))
997            .layer(axum::Extension(limiter))
998            .layer(axum::Extension(auth));
999
1000        let mut out = Vec::with_capacity(n);
1001        for _ in 0..n {
1002            let req = Request::builder()
1003                .method(Method::GET)
1004                .uri("/probe")
1005                .body(Body::empty())
1006                .unwrap();
1007            let resp = router.clone().oneshot(req).await.unwrap();
1008            let status = resp.status();
1009            let retry = resp
1010                .headers()
1011                .get(axum::http::header::RETRY_AFTER)
1012                .and_then(|v| v.to_str().ok())
1013                .map(str::to_owned);
1014            out.push((status, retry));
1015        }
1016        out
1017    }
1018
1019    #[tokio::test]
1020    async fn middleware_admits_burst_then_rejects_with_retry_after() {
1021        let clock = Arc::new(ManualClock::new());
1022        // burst=3 → exactly 3 requests get through before a 429.
1023        let limiter = RateLimiter::with_clock(cfg(1, 3), clock.clone());
1024        let auth = AuthContext::for_token("alpha");
1025        let results = drive_tower(limiter, auth, 5).await;
1026
1027        assert_eq!(results[0].0, StatusCode::NO_CONTENT);
1028        assert_eq!(results[1].0, StatusCode::NO_CONTENT);
1029        assert_eq!(results[2].0, StatusCode::NO_CONTENT);
1030        assert_eq!(results[3].0, StatusCode::TOO_MANY_REQUESTS);
1031        assert!(results[3].1.is_some(), "Retry-After header missing");
1032        assert_eq!(results[4].0, StatusCode::TOO_MANY_REQUESTS);
1033    }
1034
1035    /// L10 regression guard: a single token addressing far more distinct
1036    /// tenants than the per-token cap must NOT grow `per_tenant_buckets`
1037    /// without bound. The map size for that token is held at
1038    /// [`RateLimiter::MAX_TENANTS_PER_TOKEN`] via LRU eviction.
1039    #[test]
1040    fn per_tenant_buckets_are_bounded_under_distinct_tenant_fan_out() {
1041        let clock = Arc::new(ManualClock::new());
1042        // Per-tenant layer ACTIVE (non-zero burst) so the per-tenant map is
1043        // actually populated; token layer disabled so we isolate the L10
1044        // path. `cfg(..)` disables per-tenant, so build the config directly.
1045        let limiter = RateLimiter::with_clock(
1046            RateLimitConfig {
1047                qps: 0,
1048                burst: 0,
1049                per_tenant_default: PerTenantRateLimitConfig { burst: 5, qps: 1.0 },
1050            },
1051            clock.clone(),
1052        );
1053        let tok = TokenId::from_bearer("wildcard-sprayer");
1054
1055        // Spray far more distinct tenants than the cap. Advance the clock a
1056        // touch between requests so `last_access` strictly orders the
1057        // buckets, making the LRU victim deterministic.
1058        let cap = RateLimiter::MAX_TENANTS_PER_TOKEN;
1059        let total = cap + 500;
1060        for t in 0..total {
1061            clock.advance(Duration::from_micros(1));
1062            // Cast is safe: TenantId wraps a u64 and `total` fits easily.
1063            let _ = limiter.try_admit(tok, TenantId(t as u64));
1064        }
1065
1066        // The map must be bounded by the per-token cap, NOT by `total`.
1067        assert!(
1068            limiter.per_tenant_buckets.len() <= cap,
1069            "per_tenant_buckets grew to {} (cap {cap}); eviction did not bound it",
1070            limiter.per_tenant_buckets.len(),
1071        );
1072        // And it should be at the cap (we inserted well past it), proving we
1073        // evict rather than refuse to track.
1074        assert_eq!(
1075            limiter.per_tenant_buckets.len(),
1076            cap,
1077            "expected exactly the cap to remain after fan-out",
1078        );
1079    }
1080
1081    /// A second token's buckets are unaffected by another token hitting the
1082    /// cap: eviction is scoped per token, not global.
1083    #[test]
1084    fn per_token_eviction_does_not_disturb_other_tokens() {
1085        let clock = Arc::new(ManualClock::new());
1086        let limiter = RateLimiter::with_clock(
1087            RateLimitConfig {
1088                qps: 0,
1089                burst: 0,
1090                per_tenant_default: PerTenantRateLimitConfig { burst: 5, qps: 1.0 },
1091            },
1092            clock.clone(),
1093        );
1094        let noisy = TokenId::from_bearer("noisy");
1095        let quiet = TokenId::from_bearer("quiet");
1096
1097        // `quiet` registers a single tenant up front.
1098        let _ = limiter.try_admit(quiet, TenantId(1));
1099
1100        // `noisy` blows past the cap.
1101        let cap = RateLimiter::MAX_TENANTS_PER_TOKEN;
1102        for t in 0..(cap + 100) {
1103            clock.advance(Duration::from_micros(1));
1104            let _ = limiter.try_admit(noisy, TenantId(t as u64));
1105        }
1106
1107        // `quiet`'s lone bucket survived; only `noisy` was capped.
1108        assert!(
1109            limiter
1110                .per_tenant_buckets
1111                .contains_key(&(quiet, TenantId(1))),
1112            "quiet token's bucket must not be evicted by noisy token's fan-out",
1113        );
1114        let noisy_count = limiter
1115            .per_tenant_buckets
1116            .iter()
1117            .filter(|e| e.key().0 == noisy)
1118            .count();
1119        assert_eq!(noisy_count, cap, "noisy token must be capped");
1120    }
1121
1122    #[tokio::test]
1123    async fn middleware_passthrough_when_no_limiter_in_extensions() {
1124        // No `Extension(RateLimiter)` layer — middleware should pass through.
1125        async fn ok() -> StatusCode {
1126            StatusCode::NO_CONTENT
1127        }
1128        let router = Router::new()
1129            .route("/probe", get(ok))
1130            .layer(axum::middleware::from_fn(rate_limit));
1131        for _ in 0..50 {
1132            let req = Request::builder()
1133                .method(Method::GET)
1134                .uri("/probe")
1135                .body(Body::empty())
1136                .unwrap();
1137            let resp = router.clone().oneshot(req).await.unwrap();
1138            assert_eq!(resp.status(), StatusCode::NO_CONTENT);
1139        }
1140    }
1141
1142    #[tokio::test]
1143    async fn middleware_passthrough_when_limiter_disabled() {
1144        let limiter = RateLimiter::new(RateLimitConfig::disabled());
1145        let auth = AuthContext::for_token("alpha");
1146        let results = drive_tower(limiter, auth, 20).await;
1147        for (i, (status, _)) in results.iter().enumerate() {
1148            assert_eq!(*status, StatusCode::NO_CONTENT, "request {i}");
1149        }
1150    }
1151}