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}