use std::sync::{Arc, Mutex};
use std::time::{Duration, Instant};
use axum::extract::Request;
use axum::http::{HeaderValue, StatusCode};
use axum::middleware::Next;
use axum::response::{IntoResponse, Response};
use axum::Json;
use dashmap::DashMap;
use serde::Serialize;
use serde_json::json;
use tensor_wasm_core::types::TenantId;
use crate::routes::ApiError;
use crate::token_scope::TokenScope;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize)]
#[serde(transparent)]
pub struct TokenId(pub u64);
impl TokenId {
pub const DEV: TokenId = TokenId(0);
pub fn from_bearer(token: &str) -> Self {
use std::collections::hash_map::DefaultHasher;
use std::hash::{Hash, Hasher};
let mut h = DefaultHasher::new();
b"tensor-wasm-api/rate-limit/v1".hash(&mut h);
token.hash(&mut h);
let v = h.finish();
TokenId(if v == Self::DEV.0 { 1 } else { v })
}
}
#[derive(Debug, Clone)]
pub struct AuthContext {
pub token_id: TokenId,
pub scope: TokenScope,
}
impl AuthContext {
pub fn for_token(token: &str) -> Self {
Self {
token_id: TokenId::from_bearer(token),
scope: TokenScope::all(),
}
}
pub fn with_scope(token: &str, scope: TokenScope) -> Self {
Self {
token_id: TokenId::from_bearer(token),
scope,
}
}
pub fn dev() -> Self {
Self {
token_id: TokenId::DEV,
scope: TokenScope::all(),
}
}
pub fn authorize_tenant(&self, tenant: TenantId) -> Result<(), ApiError> {
if self.scope.allows(tenant) {
Ok(())
} else {
Err(ApiError::forbidden(
"tenant_scope_denied",
format!(
"bearer token is not scoped to tenant {}; \
extend the token's tenant= clause in TENSOR_WASM_API_TOKENS",
tenant.0,
),
))
}
}
}
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct PerTenantRateLimitConfig {
pub burst: u32,
pub qps: f64,
}
impl PerTenantRateLimitConfig {
pub const DEFAULT_BURST: u32 = 20;
pub const DEFAULT_QPS: f64 = 10.0;
pub const fn disabled() -> Self {
Self { burst: 0, qps: 0.0 }
}
pub const fn is_disabled(&self) -> bool {
self.burst == 0
}
}
impl Default for PerTenantRateLimitConfig {
fn default() -> Self {
Self {
burst: Self::DEFAULT_BURST,
qps: Self::DEFAULT_QPS,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct RateLimitConfig {
pub qps: u32,
pub burst: u32,
pub per_tenant_default: PerTenantRateLimitConfig,
}
impl RateLimitConfig {
pub const ENV_QPS: &'static str = "TENSOR_WASM_API_RATE_LIMIT_QPS";
pub const ENV_BURST: &'static str = "TENSOR_WASM_API_RATE_LIMIT_BURST";
pub const DEFAULT_QPS: u32 = 100;
pub const DEFAULT_BURST: u32 = 200;
pub const fn disabled() -> Self {
Self {
qps: 0,
burst: 0,
per_tenant_default: PerTenantRateLimitConfig::disabled(),
}
}
pub const fn is_disabled(&self) -> bool {
self.is_token_layer_disabled() && self.per_tenant_default.is_disabled()
}
pub const fn is_token_layer_disabled(&self) -> bool {
self.qps == 0 || self.burst == 0
}
pub fn from_env() -> Self {
let per_tenant_default = PerTenantRateLimitConfig::default();
let qps_raw = std::env::var(Self::ENV_QPS).ok();
let burst_raw = std::env::var(Self::ENV_BURST).ok();
if qps_raw.is_none() && burst_raw.is_none() {
return Self {
qps: 0,
burst: 0,
per_tenant_default,
};
}
let qps = qps_raw
.as_deref()
.map(|s| s.trim().parse::<u32>().unwrap_or(0))
.unwrap_or(Self::DEFAULT_QPS);
let burst = burst_raw
.as_deref()
.map(|s| s.trim().parse::<u32>().unwrap_or(0))
.unwrap_or(Self::DEFAULT_BURST);
let cfg = Self {
qps,
burst,
per_tenant_default,
};
if cfg.is_token_layer_disabled() {
tracing::warn!(
target: "tensor_wasm_api::rate_limit",
qps,
burst,
"{} / {} parsed but yields a disabled token-layer limiter (qps==0 or burst==0); per-tenant layer still active",
Self::ENV_QPS,
Self::ENV_BURST,
);
return Self {
qps: 0,
burst: 0,
per_tenant_default,
};
}
tracing::info!(
target: "tensor_wasm_api::rate_limit",
qps,
burst,
per_tenant_burst = per_tenant_default.burst,
per_tenant_qps = per_tenant_default.qps,
"rate limiter enabled (per-token backstop + per-tenant primary)",
);
cfg
}
}
impl Default for RateLimitConfig {
fn default() -> Self {
Self::disabled()
}
}
pub trait Clock: Send + Sync + 'static {
fn now(&self) -> Instant;
}
#[derive(Debug, Clone, Copy, Default)]
pub struct RealClock;
impl Clock for RealClock {
fn now(&self) -> Instant {
Instant::now()
}
}
#[derive(Debug, Clone)]
pub struct ManualClock {
inner: Arc<Mutex<Instant>>,
}
impl ManualClock {
pub fn new() -> Self {
Self {
inner: Arc::new(Mutex::new(Instant::now())),
}
}
pub fn advance(&self, d: Duration) {
let mut g = self.inner.lock().expect("ManualClock mutex poisoned");
*g += d;
}
}
impl Default for ManualClock {
fn default() -> Self {
Self::new()
}
}
impl Clock for ManualClock {
fn now(&self) -> Instant {
*self.inner.lock().expect("ManualClock mutex poisoned")
}
}
#[derive(Debug)]
struct BucketState {
tokens: f64,
last_refill: Instant,
last_access: Instant,
}
#[derive(Debug, Clone, Copy, PartialEq)]
pub enum AdmitResult {
Admit,
Reject {
retry_after_secs: u64,
},
}
#[derive(Clone)]
pub struct RateLimiter {
cfg: RateLimitConfig,
clock: Arc<dyn Clock>,
buckets: Arc<DashMap<TokenId, Mutex<BucketState>>>,
per_tenant_buckets: Arc<DashMap<(TokenId, TenantId), Mutex<BucketState>>>,
}
impl std::fmt::Debug for RateLimiter {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("RateLimiter")
.field("cfg", &self.cfg)
.field("buckets", &self.buckets.len())
.field("per_tenant_buckets", &self.per_tenant_buckets.len())
.finish()
}
}
impl RateLimiter {
pub const MAX_TENANTS_PER_TOKEN: usize = 4096;
pub fn new(cfg: RateLimitConfig) -> Self {
Self::with_clock(cfg, Arc::new(RealClock))
}
pub fn with_clock(cfg: RateLimitConfig, clock: Arc<dyn Clock>) -> Self {
Self {
cfg,
clock,
buckets: Arc::new(DashMap::new()),
per_tenant_buckets: Arc::new(DashMap::new()),
}
}
pub fn is_disabled(&self) -> bool {
self.cfg.is_disabled()
}
pub fn config(&self) -> RateLimitConfig {
self.cfg
}
pub fn try_admit(&self, token: TokenId, tenant: TenantId) -> AdmitResult {
if self.is_disabled() {
return AdmitResult::Admit;
}
let now = self.clock.now();
let per_tenant_burst = self.cfg.per_tenant_default.burst as f64;
let per_tenant_qps = self.cfg.per_tenant_default.qps;
let token_burst = self.cfg.burst as f64;
let token_qps = self.cfg.qps as f64;
let per_tenant_entry = if self.cfg.per_tenant_default.is_disabled() {
None
} else {
if !self.per_tenant_buckets.contains_key(&(token, tenant)) {
self.evict_lru_tenant_if_at_cap(token);
}
Some(
self.per_tenant_buckets
.entry((token, tenant))
.or_insert_with(|| {
Mutex::new(BucketState {
tokens: per_tenant_burst,
last_refill: now,
last_access: now,
})
}),
)
};
let token_entry = if self.cfg.is_token_layer_disabled() {
None
} else {
Some(self.buckets.entry(token).or_insert_with(|| {
Mutex::new(BucketState {
tokens: token_burst,
last_refill: now,
last_access: now,
})
}))
};
let mut per_tenant_guard = per_tenant_entry.as_ref().map(|e| {
e.value()
.lock()
.expect("RateLimiter per-tenant bucket mutex poisoned")
});
let mut token_guard = token_entry.as_ref().map(|e| {
e.value()
.lock()
.expect("RateLimiter token bucket mutex poisoned")
});
let per_tenant_decision = per_tenant_guard
.as_deref_mut()
.map(|s| refill_and_decide(s, per_tenant_burst, per_tenant_qps, now));
let token_decision = token_guard
.as_deref_mut()
.map(|s| refill_and_decide(s, token_burst, token_qps, now));
#[allow(clippy::unnecessary_map_or)]
let per_tenant_admit = per_tenant_decision.as_ref().map_or(true, |d| d.admittable);
#[allow(clippy::unnecessary_map_or)]
let token_admit = token_decision.as_ref().map_or(true, |d| d.admittable);
if per_tenant_admit && token_admit {
if let Some(state) = per_tenant_guard.as_deref_mut() {
state.tokens -= 1.0;
}
if let Some(state) = token_guard.as_deref_mut() {
state.tokens -= 1.0;
}
return AdmitResult::Admit;
}
let mut chosen: Option<Duration> = None;
for d in [per_tenant_decision.as_ref(), token_decision.as_ref()]
.into_iter()
.flatten()
{
if !d.admittable {
chosen = Some(match chosen {
None => d.retry_after,
Some(prev) => prev.min(d.retry_after),
});
}
}
let retry = chosen.unwrap_or(Duration::from_secs(1));
let secs = retry.as_secs_f64().ceil() as u64;
AdmitResult::Reject {
retry_after_secs: secs.max(1),
}
}
fn evict_lru_tenant_if_at_cap(&self, token: TokenId) {
let mut count = 0usize;
let mut lru_key: Option<(TokenId, TenantId)> = None;
let mut lru_access: Option<Instant> = None;
for entry in self.per_tenant_buckets.iter() {
let key = *entry.key();
if key.0 != token {
continue;
}
count += 1;
let access = entry
.value()
.lock()
.map(|s| s.last_access)
.unwrap_or_else(|p| p.into_inner().last_access);
let colder = match lru_access {
None => true,
Some(cur) => access < cur,
};
if colder {
lru_access = Some(access);
lru_key = Some(key);
}
}
if count < Self::MAX_TENANTS_PER_TOKEN {
return;
}
if let Some(key) = lru_key {
self.per_tenant_buckets.remove(&key);
}
}
}
struct BucketDecision {
admittable: bool,
retry_after: Duration,
}
fn refill_and_decide(
state: &mut BucketState,
burst: f64,
qps: f64,
now: Instant,
) -> BucketDecision {
let elapsed = now.saturating_duration_since(state.last_refill);
if elapsed > Duration::ZERO && qps > 0.0 {
state.tokens = (state.tokens + elapsed.as_secs_f64() * qps).min(burst);
} else {
state.tokens = state.tokens.min(burst);
}
state.last_refill = now;
state.last_access = now;
if state.tokens >= 1.0 {
BucketDecision {
admittable: true,
retry_after: Duration::ZERO,
}
} else {
let deficit = 1.0 - state.tokens;
let retry = if qps > 0.0 {
Duration::from_secs_f64((deficit / qps).max(0.0))
} else {
Duration::from_secs(3600)
};
BucketDecision {
admittable: false,
retry_after: retry,
}
}
}
fn rate_limited_response(retry_after_secs: u64) -> Response {
let body = Json(json!({
"error": {
"kind": "rate_limited",
"message": format!(
"per-token rate limit exceeded; retry after {retry_after_secs}s",
),
}
}));
let mut resp = (StatusCode::TOO_MANY_REQUESTS, body).into_response();
if let Ok(hv) = HeaderValue::from_str(&retry_after_secs.to_string()) {
resp.headers_mut()
.insert(axum::http::header::RETRY_AFTER, hv);
}
resp
}
pub async fn rate_limit(req: Request, next: Next) -> Response {
let limiter = match req.extensions().get::<RateLimiter>().cloned() {
Some(l) => l,
None => return next.run(req).await,
};
if limiter.is_disabled() {
return next.run(req).await;
}
let token = req
.extensions()
.get::<AuthContext>()
.map(|c| c.token_id)
.unwrap_or(TokenId::DEV);
let tenant = req
.extensions()
.get::<tensor_wasm_core::types::TenantId>()
.copied()
.unwrap_or(tensor_wasm_core::types::TenantId(0));
match limiter.try_admit(token, tenant) {
AdmitResult::Admit => next.run(req).await,
AdmitResult::Reject { retry_after_secs } => rate_limited_response(retry_after_secs),
}
}
#[cfg(test)]
mod tests {
use super::*;
use axum::body::Body;
use axum::http::{Method, Request};
use axum::routing::get;
use axum::Router;
use tower::ServiceExt;
fn cfg(qps: u32, burst: u32) -> RateLimitConfig {
RateLimitConfig {
qps,
burst,
per_tenant_default: PerTenantRateLimitConfig::disabled(),
}
}
const TENANT: TenantId = TenantId(1);
#[test]
fn config_disabled_when_either_zero() {
assert!(cfg(0, 10).is_disabled());
assert!(cfg(10, 0).is_disabled());
assert!(cfg(0, 0).is_disabled());
assert!(!cfg(1, 1).is_disabled());
}
#[test]
fn token_id_dev_is_distinct_from_real_tokens() {
assert_ne!(TokenId::from_bearer("anything").0, TokenId::DEV.0);
assert_eq!(
TokenId::from_bearer("alpha").0,
TokenId::from_bearer("alpha").0
);
assert_ne!(
TokenId::from_bearer("alpha").0,
TokenId::from_bearer("beta").0
);
}
#[test]
fn bucket_allows_up_to_burst_immediately() {
let clock = Arc::new(ManualClock::new());
let limiter = RateLimiter::with_clock(cfg(10, 5), clock.clone());
let tok = TokenId::from_bearer("alpha");
for i in 0..5 {
assert!(
matches!(limiter.try_admit(tok, TENANT), AdmitResult::Admit),
"burst slot {i} should admit",
);
}
assert!(matches!(
limiter.try_admit(tok, TENANT),
AdmitResult::Reject { .. },
));
}
#[test]
fn bucket_refills_at_qps_rate_with_manual_clock() {
let clock = Arc::new(ManualClock::new());
let limiter = RateLimiter::with_clock(cfg(10, 5), clock.clone());
let tok = TokenId::from_bearer("alpha");
for _ in 0..5 {
assert!(matches!(limiter.try_admit(tok, TENANT), AdmitResult::Admit));
}
assert!(matches!(
limiter.try_admit(tok, TENANT),
AdmitResult::Reject { .. },
));
clock.advance(Duration::from_millis(100));
assert!(matches!(limiter.try_admit(tok, TENANT), AdmitResult::Admit));
assert!(matches!(
limiter.try_admit(tok, TENANT),
AdmitResult::Reject { .. },
));
clock.advance(Duration::from_secs(1));
for _ in 0..5 {
assert!(matches!(limiter.try_admit(tok, TENANT), AdmitResult::Admit));
}
assert!(matches!(
limiter.try_admit(tok, TENANT),
AdmitResult::Reject { .. },
));
}
#[test]
fn separate_tokens_have_separate_buckets() {
let clock = Arc::new(ManualClock::new());
let limiter = RateLimiter::with_clock(cfg(1, 2), clock.clone());
let a = TokenId::from_bearer("alpha");
let b = TokenId::from_bearer("beta");
for _ in 0..2 {
assert!(matches!(limiter.try_admit(a, TENANT), AdmitResult::Admit));
}
assert!(matches!(
limiter.try_admit(a, TENANT),
AdmitResult::Reject { .. }
));
for _ in 0..2 {
assert!(matches!(limiter.try_admit(b, TENANT), AdmitResult::Admit));
}
assert!(matches!(
limiter.try_admit(b, TENANT),
AdmitResult::Reject { .. }
));
}
#[test]
fn disabled_limiter_admits_unconditionally() {
let clock = Arc::new(ManualClock::new());
let limiter = RateLimiter::with_clock(RateLimitConfig::disabled(), clock);
let tok = TokenId::from_bearer("alpha");
for _ in 0..1000 {
assert!(matches!(limiter.try_admit(tok, TENANT), AdmitResult::Admit));
}
}
#[test]
fn reject_carries_retry_after_at_least_one_second() {
let clock = Arc::new(ManualClock::new());
let limiter = RateLimiter::with_clock(cfg(1000, 1), clock.clone());
let tok = TokenId::from_bearer("alpha");
assert!(matches!(limiter.try_admit(tok, TENANT), AdmitResult::Admit));
match limiter.try_admit(tok, TENANT) {
AdmitResult::Reject { retry_after_secs } => {
assert!(retry_after_secs >= 1, "got {retry_after_secs}");
}
other => panic!("expected reject, got {other:?}"),
}
}
async fn drive_tower(
limiter: RateLimiter,
auth: AuthContext,
n: usize,
) -> Vec<(StatusCode, Option<String>)> {
async fn ok() -> StatusCode {
StatusCode::NO_CONTENT
}
let router = Router::new()
.route("/probe", get(ok))
.layer(axum::middleware::from_fn(rate_limit))
.layer(axum::Extension(limiter))
.layer(axum::Extension(auth));
let mut out = Vec::with_capacity(n);
for _ in 0..n {
let req = Request::builder()
.method(Method::GET)
.uri("/probe")
.body(Body::empty())
.unwrap();
let resp = router.clone().oneshot(req).await.unwrap();
let status = resp.status();
let retry = resp
.headers()
.get(axum::http::header::RETRY_AFTER)
.and_then(|v| v.to_str().ok())
.map(str::to_owned);
out.push((status, retry));
}
out
}
#[tokio::test]
async fn middleware_admits_burst_then_rejects_with_retry_after() {
let clock = Arc::new(ManualClock::new());
let limiter = RateLimiter::with_clock(cfg(1, 3), clock.clone());
let auth = AuthContext::for_token("alpha");
let results = drive_tower(limiter, auth, 5).await;
assert_eq!(results[0].0, StatusCode::NO_CONTENT);
assert_eq!(results[1].0, StatusCode::NO_CONTENT);
assert_eq!(results[2].0, StatusCode::NO_CONTENT);
assert_eq!(results[3].0, StatusCode::TOO_MANY_REQUESTS);
assert!(results[3].1.is_some(), "Retry-After header missing");
assert_eq!(results[4].0, StatusCode::TOO_MANY_REQUESTS);
}
#[test]
fn per_tenant_buckets_are_bounded_under_distinct_tenant_fan_out() {
let clock = Arc::new(ManualClock::new());
let limiter = RateLimiter::with_clock(
RateLimitConfig {
qps: 0,
burst: 0,
per_tenant_default: PerTenantRateLimitConfig { burst: 5, qps: 1.0 },
},
clock.clone(),
);
let tok = TokenId::from_bearer("wildcard-sprayer");
let cap = RateLimiter::MAX_TENANTS_PER_TOKEN;
let total = cap + 500;
for t in 0..total {
clock.advance(Duration::from_micros(1));
let _ = limiter.try_admit(tok, TenantId(t as u64));
}
assert!(
limiter.per_tenant_buckets.len() <= cap,
"per_tenant_buckets grew to {} (cap {cap}); eviction did not bound it",
limiter.per_tenant_buckets.len(),
);
assert_eq!(
limiter.per_tenant_buckets.len(),
cap,
"expected exactly the cap to remain after fan-out",
);
}
#[test]
fn per_token_eviction_does_not_disturb_other_tokens() {
let clock = Arc::new(ManualClock::new());
let limiter = RateLimiter::with_clock(
RateLimitConfig {
qps: 0,
burst: 0,
per_tenant_default: PerTenantRateLimitConfig { burst: 5, qps: 1.0 },
},
clock.clone(),
);
let noisy = TokenId::from_bearer("noisy");
let quiet = TokenId::from_bearer("quiet");
let _ = limiter.try_admit(quiet, TenantId(1));
let cap = RateLimiter::MAX_TENANTS_PER_TOKEN;
for t in 0..(cap + 100) {
clock.advance(Duration::from_micros(1));
let _ = limiter.try_admit(noisy, TenantId(t as u64));
}
assert!(
limiter
.per_tenant_buckets
.contains_key(&(quiet, TenantId(1))),
"quiet token's bucket must not be evicted by noisy token's fan-out",
);
let noisy_count = limiter
.per_tenant_buckets
.iter()
.filter(|e| e.key().0 == noisy)
.count();
assert_eq!(noisy_count, cap, "noisy token must be capped");
}
#[tokio::test]
async fn middleware_passthrough_when_no_limiter_in_extensions() {
async fn ok() -> StatusCode {
StatusCode::NO_CONTENT
}
let router = Router::new()
.route("/probe", get(ok))
.layer(axum::middleware::from_fn(rate_limit));
for _ in 0..50 {
let req = Request::builder()
.method(Method::GET)
.uri("/probe")
.body(Body::empty())
.unwrap();
let resp = router.clone().oneshot(req).await.unwrap();
assert_eq!(resp.status(), StatusCode::NO_CONTENT);
}
}
#[tokio::test]
async fn middleware_passthrough_when_limiter_disabled() {
let limiter = RateLimiter::new(RateLimitConfig::disabled());
let auth = AuthContext::for_token("alpha");
let results = drive_tower(limiter, auth, 20).await;
for (i, (status, _)) in results.iter().enumerate() {
assert_eq!(*status, StatusCode::NO_CONTENT, "request {i}");
}
}
}