use std::collections::HashMap;
use std::sync::Arc;
use std::time::{Duration, Instant};
use dashmap::mapref::entry::Entry;
use serde::Deserialize;
use serde_json::Value;
use super::gcra::{Gcra, Profile};
use super::matcher::CompiledProfile;
use crate::config::{JwtLimitConfig, LimitServiceConfig};
const PER_MINUTE: Duration = Duration::from_secs(60);
pub(super) fn claim_str(claims: &Value, path: &str) -> Option<String> {
match claim_at(claims, path)? {
Value::String(s) => Some(s.clone()),
Value::Number(n) => Some(n.to_string()),
Value::Bool(b) => Some(b.to_string()),
_ => None,
}
}
fn claim_u64(claims: &Value, path: &str) -> Option<u64> {
match claim_at(claims, path)? {
Value::Number(n) => n.as_u64(),
Value::String(s) => s.trim().parse().ok(),
_ => None,
}
}
fn claim_at<'a>(claims: &'a Value, path: &str) -> Option<&'a Value> {
let mut cur = claims;
for seg in path.split('.') {
cur = cur.get(seg)?;
}
Some(cur)
}
fn profile_from_numbers(rpm: u64, burst: u64) -> Option<CompiledProfile> {
if rpm == 0 {
return None;
}
let gcra = Gcra::from_profile(Profile {
rate: rpm,
window: PER_MINUTE,
burst: burst.max(1),
});
Some(CompiledProfile {
gcra,
limit: rpm,
window: PER_MINUTE,
})
}
#[derive(Debug, Clone)]
pub struct JwtLimits {
tier_claim: String,
rpm_claim: String,
burst_claim: String,
}
impl JwtLimits {
pub fn from_config(cfg: &JwtLimitConfig) -> Self {
Self {
tier_claim: cfg.tier_claim.clone(),
rpm_claim: cfg.rpm_claim.clone(),
burst_claim: cfg.burst_claim.clone(),
}
}
pub fn resolve(
&self,
claims: &Value,
profiles: &HashMap<String, CompiledProfile>,
) -> Option<CompiledProfile> {
if let Some(tier) = claim_str(claims, &self.tier_claim) {
if let Some(profile) = profiles.get(&tier) {
return Some(*profile);
}
}
if let Some(rpm) = claim_u64(claims, &self.rpm_claim) {
let burst = claim_u64(claims, &self.burst_claim).unwrap_or(rpm);
return profile_from_numbers(rpm, burst);
}
None
}
}
#[derive(Debug, Deserialize)]
struct LimitResponse {
#[serde(default)]
tier: Option<String>,
#[serde(default)]
rate_per_min: Option<u64>,
#[serde(default)]
burst: Option<u64>,
}
#[derive(Clone, Copy)]
struct Cached {
profile: Option<CompiledProfile>,
at: Instant,
last_access: Instant,
}
const SWEEP_INTERVAL: Duration = Duration::from_secs(60);
const MAX_CACHE_ENTRIES: usize = 100_000;
const MAX_CONCURRENT_FETCHES: usize = 32;
pub struct LimitService {
endpoint: String,
ttl: Duration,
evict_after: Duration,
client: reqwest::Client,
profiles: HashMap<String, CompiledProfile>,
cache: dashmap::DashMap<String, Cached>,
inflight: dashmap::DashMap<String, ()>,
fetch_slots: Arc<tokio::sync::Semaphore>,
base: Instant,
last_sweep_ms: std::sync::atomic::AtomicU64,
}
impl LimitService {
pub fn build(
cfg: &LimitServiceConfig,
profiles: HashMap<String, CompiledProfile>,
) -> Result<Arc<Self>, String> {
let url = reqwest::Url::parse(&cfg.endpoint)
.map_err(|e| format!("invalid limit_service.endpoint {:?}: {e}", cfg.endpoint))?;
if !matches!(url.scheme(), "http" | "https") {
return Err(format!(
"limit_service.endpoint must be http/https, got scheme {:?}",
url.scheme()
));
}
let client = reqwest::Client::builder()
.timeout(Duration::from_millis(cfg.timeout_ms.max(1)))
.tls_backend_preconfigured(crate::auth::jwks::build_tls_config())
.build()
.map_err(|e| format!("invalid limit_service client: {e}"))?;
let ttl = Duration::from_secs(cfg.ttl_secs.max(1));
Ok(Arc::new(Self {
endpoint: cfg.endpoint.clone(),
ttl,
evict_after: (ttl * 4).max(Duration::from_secs(300)),
client,
profiles,
cache: dashmap::DashMap::new(),
inflight: dashmap::DashMap::new(),
fetch_slots: Arc::new(tokio::sync::Semaphore::new(MAX_CONCURRENT_FETCHES)),
base: Instant::now(),
last_sweep_ms: std::sync::atomic::AtomicU64::new(0),
}))
}
fn maybe_sweep(&self) {
use std::sync::atomic::Ordering;
let now_ms = u64::try_from(self.base.elapsed().as_millis()).unwrap_or(u64::MAX);
let last = self.last_sweep_ms.load(Ordering::Relaxed);
if now_ms.saturating_sub(last) < SWEEP_INTERVAL.as_millis() as u64 {
return;
}
if self
.last_sweep_ms
.compare_exchange(last, now_ms, Ordering::Relaxed, Ordering::Relaxed)
.is_ok()
{
self.sweep();
}
}
fn sweep(&self) {
let evict_after = self.evict_after;
self.cache
.retain(|_, c| c.last_access.elapsed() < evict_after);
if self.cache.len() > MAX_CACHE_ENTRIES {
let mut ages: Vec<(String, Instant)> = self
.cache
.iter()
.map(|e| (e.key().clone(), e.value().last_access))
.collect();
ages.sort_by_key(|(_, t)| *t);
for (key, _) in ages.into_iter().take(self.cache.len() - MAX_CACHE_ENTRIES) {
self.cache.remove(&key);
}
}
}
pub fn resolve(self: &Arc<Self>, key: &str) -> Option<CompiledProfile> {
self.maybe_sweep();
let cached = self.cache.get_mut(key).map(|mut c| {
c.last_access = Instant::now();
*c
});
match cached {
Some(c) if c.at.elapsed() < self.ttl => c.profile,
Some(c) => {
self.trigger_refresh(key.to_string());
c.profile
}
None => {
self.trigger_refresh(key.to_string());
None
}
}
}
fn insert_capped(&self, key: String, cached: Cached) {
let over_cap = self.cache.len() >= MAX_CACHE_ENTRIES;
match self.cache.entry(key) {
Entry::Occupied(mut o) => {
o.insert(cached);
}
Entry::Vacant(_) if over_cap => {} Entry::Vacant(v) => {
v.insert(cached);
}
}
}
fn trigger_refresh(self: &Arc<Self>, key: String) {
match self.inflight.entry(key.clone()) {
Entry::Occupied(_) => return,
Entry::Vacant(v) => {
v.insert(());
}
}
let permit = match self.fetch_slots.clone().try_acquire_owned() {
Ok(p) => p,
Err(_) => {
self.inflight.remove(&key);
return;
}
};
let this = self.clone();
tokio::spawn(async move {
let _permit = permit; match this.fetch(&key).await {
Ok(resolved) => {
let now = Instant::now();
this.insert_capped(
key.clone(),
Cached {
profile: resolved,
at: now,
last_access: now,
},
);
}
Err(()) => {
let now = Instant::now();
match this.cache.get_mut(&key) {
Some(mut c) => c.at = now,
None => {
this.insert_capped(
key.clone(),
Cached {
profile: None,
at: now,
last_access: now,
},
);
}
}
}
}
this.inflight.remove(&key);
});
}
async fn fetch(&self, key: &str) -> Result<Option<CompiledProfile>, ()> {
let url = reqwest::Url::parse_with_params(&self.endpoint, &[("key", key)])
.map_err(|e| tracing::warn!("invalid limit-service endpoint: {e}"))?;
let resp = self
.client
.get(url)
.send()
.await
.map_err(|e| tracing::warn!("limit-service fetch failed: {e}"))?;
if !resp.status().is_success() {
if resp.status() == reqwest::StatusCode::NOT_FOUND {
return Ok(None);
}
tracing::warn!("limit-service returned {}", resp.status());
return Err(());
}
let body: LimitResponse = resp
.json()
.await
.map_err(|e| tracing::warn!("limit-service response parse failed: {e}"))?;
Ok(self.map_response(body))
}
fn map_response(&self, body: LimitResponse) -> Option<CompiledProfile> {
if let Some(tier) = &body.tier {
if let Some(profile) = self.profiles.get(tier) {
return Some(*profile);
}
}
body.rate_per_min
.and_then(|rpm| profile_from_numbers(rpm, body.burst.unwrap_or(rpm)))
}
}
#[cfg(test)]
mod tests {
use super::*;
fn profiles() -> HashMap<String, CompiledProfile> {
let mut m = HashMap::new();
m.insert(
"premium".to_string(),
profile_from_numbers(1000, 100).unwrap(), );
m
}
fn jwt_limits() -> JwtLimits {
JwtLimits::from_config(&JwtLimitConfig {
tier_claim: "ratelimit_tier".to_string(),
rpm_claim: "ratelimit_rpm".to_string(),
burst_claim: "ratelimit_burst".to_string(),
})
}
#[test]
fn jwt_tier_name_maps_to_profile() {
let claims = serde_json::json!({ "ratelimit_tier": "premium" });
let p = jwt_limits().resolve(&claims, &profiles()).unwrap();
assert_eq!(p.limit, 1000);
}
#[test]
fn jwt_direct_numbers_build_a_profile() {
let claims = serde_json::json!({ "ratelimit_rpm": 300, "ratelimit_burst": 30 });
let p = jwt_limits().resolve(&claims, &profiles()).unwrap();
assert_eq!(p.limit, 300);
}
#[test]
fn jwt_tier_takes_precedence_over_numbers() {
let claims = serde_json::json!({ "ratelimit_tier": "premium", "ratelimit_rpm": 5 });
let p = jwt_limits().resolve(&claims, &profiles()).unwrap();
assert_eq!(p.limit, 1000);
}
#[test]
fn jwt_unknown_tier_falls_through_to_numbers_then_none() {
let claims = serde_json::json!({ "ratelimit_tier": "gold" });
assert!(jwt_limits().resolve(&claims, &profiles()).is_none());
let claims = serde_json::json!({ "ratelimit_tier": "gold", "ratelimit_rpm": 42 });
assert_eq!(
jwt_limits().resolve(&claims, &profiles()).unwrap().limit,
42
);
}
#[tokio::test]
async fn actively_used_stale_entry_survives_eviction() {
use crate::config::LimitServiceConfig;
let svc = LimitService::build(
&LimitServiceConfig {
endpoint: "http://127.0.0.1:0/".to_string(),
ttl_secs: 1,
timeout_ms: 50,
},
profiles(),
)
.unwrap();
let old = Instant::now()
.checked_sub(Duration::from_secs(600))
.expect("clock supports the offset");
svc.cache.insert(
"k".to_string(),
Cached {
profile: Some(profile_from_numbers(10, 10).unwrap()),
at: old,
last_access: old,
},
);
let _ = svc.resolve("k");
svc.sweep();
assert!(
svc.cache.contains_key("k"),
"an actively-used stale entry must not be evicted during an outage"
);
}
fn service(endpoint: &str) -> Arc<LimitService> {
LimitService::build(
&LimitServiceConfig {
endpoint: endpoint.to_string(),
ttl_secs: 60,
timeout_ms: 50,
},
profiles(),
)
.unwrap()
}
#[test]
fn service_unknown_tier_falls_through_to_numbers() {
let svc = service("http://127.0.0.1:9/");
let p = svc
.map_response(LimitResponse {
tier: Some("gold".to_string()),
rate_per_min: Some(50),
burst: None,
})
.unwrap();
assert_eq!(p.limit, 50);
}
#[test]
fn build_rejects_non_http_endpoint() {
for ep in ["redis://127.0.0.1/", "file:///etc/passwd", "ftp://h/x"] {
let r = LimitService::build(
&LimitServiceConfig {
endpoint: ep.to_string(),
ttl_secs: 60,
timeout_ms: 50,
},
profiles(),
);
assert!(r.is_err(), "expected {ep} to be rejected");
}
}
#[test]
fn build_rejects_malformed_endpoint() {
let bad = LimitService::build(
&LimitServiceConfig {
endpoint: "not a url".to_string(),
ttl_secs: 60,
timeout_ms: 50,
},
profiles(),
);
assert!(bad.is_err());
}
#[test]
fn jwt_zero_rate_is_not_a_usable_limit() {
let claims = serde_json::json!({ "ratelimit_rpm": 0 });
assert!(jwt_limits().resolve(&claims, &profiles()).is_none());
}
#[test]
fn numeric_string_claims_are_accepted() {
let claims = serde_json::json!({ "ratelimit_rpm": "250" });
assert_eq!(
jwt_limits().resolve(&claims, &profiles()).unwrap().limit,
250
);
}
}