1use std::collections::HashMap;
11use std::sync::Arc;
12use std::time::{Duration, Instant};
13
14use dashmap::mapref::entry::Entry;
15use serde::Deserialize;
16use serde_json::Value;
17
18use super::gcra::{Gcra, Profile};
19use super::matcher::CompiledProfile;
20use crate::config::{JwtLimitConfig, LimitServiceConfig};
21
22const PER_MINUTE: Duration = Duration::from_secs(60);
24
25pub(super) fn claim_str(claims: &Value, path: &str) -> Option<String> {
27 match claim_at(claims, path)? {
28 Value::String(s) => Some(s.clone()),
29 Value::Number(n) => Some(n.to_string()),
30 Value::Bool(b) => Some(b.to_string()),
31 _ => None,
32 }
33}
34
35fn claim_u64(claims: &Value, path: &str) -> Option<u64> {
38 match claim_at(claims, path)? {
39 Value::Number(n) => n.as_u64(),
40 Value::String(s) => s.trim().parse().ok(),
41 _ => None,
42 }
43}
44
45fn claim_at<'a>(claims: &'a Value, path: &str) -> Option<&'a Value> {
46 let mut cur = claims;
47 for seg in path.split('.') {
48 cur = cur.get(seg)?;
49 }
50 Some(cur)
51}
52
53fn profile_from_numbers(rpm: u64, burst: u64) -> Option<CompiledProfile> {
59 if rpm == 0 {
60 return None;
61 }
62 let gcra = Gcra::from_profile(Profile {
63 rate: rpm,
64 window: PER_MINUTE,
65 burst: burst.max(1),
66 });
67 Some(CompiledProfile {
68 gcra,
69 limit: rpm,
70 window: PER_MINUTE,
71 })
72}
73
74#[derive(Debug, Clone)]
76pub struct JwtLimits {
77 tier_claim: String,
78 rpm_claim: String,
79 burst_claim: String,
80}
81
82impl JwtLimits {
83 pub fn from_config(cfg: &JwtLimitConfig) -> Self {
85 Self {
86 tier_claim: cfg.tier_claim.clone(),
87 rpm_claim: cfg.rpm_claim.clone(),
88 burst_claim: cfg.burst_claim.clone(),
89 }
90 }
91
92 pub fn resolve(
96 &self,
97 claims: &Value,
98 profiles: &HashMap<String, CompiledProfile>,
99 ) -> Option<CompiledProfile> {
100 if let Some(tier) = claim_str(claims, &self.tier_claim) {
101 if let Some(profile) = profiles.get(&tier) {
102 return Some(*profile);
103 }
104 }
105 if let Some(rpm) = claim_u64(claims, &self.rpm_claim) {
106 let burst = claim_u64(claims, &self.burst_claim).unwrap_or(rpm);
107 return profile_from_numbers(rpm, burst);
108 }
109 None
110 }
111}
112
113#[derive(Debug, Deserialize)]
115struct LimitResponse {
116 #[serde(default)]
117 tier: Option<String>,
118 #[serde(default)]
119 rate_per_min: Option<u64>,
120 #[serde(default)]
121 burst: Option<u64>,
122}
123
124#[derive(Clone, Copy)]
127struct Cached {
128 profile: Option<CompiledProfile>,
129 at: Instant,
131 last_access: Instant,
135}
136
137const SWEEP_INTERVAL: Duration = Duration::from_secs(60);
139
140const MAX_CACHE_ENTRIES: usize = 100_000;
145
146const MAX_CONCURRENT_FETCHES: usize = 32;
149
150pub struct LimitService {
152 endpoint: String,
153 ttl: Duration,
154 evict_after: Duration,
158 client: reqwest::Client,
159 profiles: HashMap<String, CompiledProfile>,
161 cache: dashmap::DashMap<String, Cached>,
162 inflight: dashmap::DashMap<String, ()>,
164 fetch_slots: Arc<tokio::sync::Semaphore>,
166 base: Instant,
167 last_sweep_ms: std::sync::atomic::AtomicU64,
168}
169
170impl LimitService {
171 pub fn build(
176 cfg: &LimitServiceConfig,
177 profiles: HashMap<String, CompiledProfile>,
178 ) -> Result<Arc<Self>, String> {
179 let url = reqwest::Url::parse(&cfg.endpoint)
183 .map_err(|e| format!("invalid limit_service.endpoint {:?}: {e}", cfg.endpoint))?;
184 if !matches!(url.scheme(), "http" | "https") {
185 return Err(format!(
186 "limit_service.endpoint must be http/https, got scheme {:?}",
187 url.scheme()
188 ));
189 }
190 let client = reqwest::Client::builder()
191 .timeout(Duration::from_millis(cfg.timeout_ms.max(1)))
192 .tls_backend_preconfigured(crate::auth::jwks::build_tls_config())
193 .build()
194 .map_err(|e| format!("invalid limit_service client: {e}"))?;
195 let ttl = Duration::from_secs(cfg.ttl_secs.max(1));
196 Ok(Arc::new(Self {
197 endpoint: cfg.endpoint.clone(),
198 ttl,
199 evict_after: (ttl * 4).max(Duration::from_secs(300)),
201 client,
202 profiles,
203 cache: dashmap::DashMap::new(),
204 inflight: dashmap::DashMap::new(),
205 fetch_slots: Arc::new(tokio::sync::Semaphore::new(MAX_CONCURRENT_FETCHES)),
206 base: Instant::now(),
207 last_sweep_ms: std::sync::atomic::AtomicU64::new(0),
208 }))
209 }
210
211 fn maybe_sweep(&self) {
214 use std::sync::atomic::Ordering;
215 let now_ms = u64::try_from(self.base.elapsed().as_millis()).unwrap_or(u64::MAX);
216 let last = self.last_sweep_ms.load(Ordering::Relaxed);
217 if now_ms.saturating_sub(last) < SWEEP_INTERVAL.as_millis() as u64 {
218 return;
219 }
220 if self
221 .last_sweep_ms
222 .compare_exchange(last, now_ms, Ordering::Relaxed, Ordering::Relaxed)
223 .is_ok()
224 {
225 self.sweep();
226 }
227 }
228
229 fn sweep(&self) {
236 let evict_after = self.evict_after;
237 self.cache
238 .retain(|_, c| c.last_access.elapsed() < evict_after);
239 if self.cache.len() > MAX_CACHE_ENTRIES {
240 let mut ages: Vec<(String, Instant)> = self
241 .cache
242 .iter()
243 .map(|e| (e.key().clone(), e.value().last_access))
244 .collect();
245 ages.sort_by_key(|(_, t)| *t);
247 for (key, _) in ages.into_iter().take(self.cache.len() - MAX_CACHE_ENTRIES) {
248 self.cache.remove(&key);
249 }
250 }
251 }
252
253 pub fn resolve(self: &Arc<Self>, key: &str) -> Option<CompiledProfile> {
257 self.maybe_sweep();
258 let cached = self.cache.get_mut(key).map(|mut c| {
261 c.last_access = Instant::now();
262 *c
263 });
264 match cached {
265 Some(c) if c.at.elapsed() < self.ttl => c.profile,
266 Some(c) => {
267 self.trigger_refresh(key.to_string());
269 c.profile
270 }
271 None => {
272 self.trigger_refresh(key.to_string());
273 None
274 }
275 }
276 }
277
278 fn insert_capped(&self, key: String, cached: Cached) {
289 let over_cap = self.cache.len() >= MAX_CACHE_ENTRIES;
293 match self.cache.entry(key) {
294 Entry::Occupied(mut o) => {
295 o.insert(cached);
296 }
297 Entry::Vacant(_) if over_cap => {} Entry::Vacant(v) => {
299 v.insert(cached);
300 }
301 }
302 }
303
304 fn trigger_refresh(self: &Arc<Self>, key: String) {
305 match self.inflight.entry(key.clone()) {
306 Entry::Occupied(_) => return,
307 Entry::Vacant(v) => {
308 v.insert(());
309 }
310 }
311 let permit = match self.fetch_slots.clone().try_acquire_owned() {
312 Ok(p) => p,
313 Err(_) => {
314 self.inflight.remove(&key);
315 return;
316 }
317 };
318 let this = self.clone();
319 tokio::spawn(async move {
320 let _permit = permit; match this.fetch(&key).await {
322 Ok(resolved) => {
323 let now = Instant::now();
324 this.insert_capped(
325 key.clone(),
326 Cached {
327 profile: resolved,
328 at: now,
329 last_access: now,
330 },
331 );
332 }
333 Err(()) => {
334 let now = Instant::now();
342 match this.cache.get_mut(&key) {
343 Some(mut c) => c.at = now,
344 None => {
345 this.insert_capped(
346 key.clone(),
347 Cached {
348 profile: None,
349 at: now,
350 last_access: now,
351 },
352 );
353 }
354 }
355 }
356 }
357 this.inflight.remove(&key);
358 });
359 }
360
361 async fn fetch(&self, key: &str) -> Result<Option<CompiledProfile>, ()> {
365 let url = reqwest::Url::parse_with_params(&self.endpoint, &[("key", key)])
366 .map_err(|e| tracing::warn!("invalid limit-service endpoint: {e}"))?;
367 let resp = self
368 .client
369 .get(url)
370 .send()
371 .await
372 .map_err(|e| tracing::warn!("limit-service fetch failed: {e}"))?;
373 if !resp.status().is_success() {
374 if resp.status() == reqwest::StatusCode::NOT_FOUND {
376 return Ok(None);
377 }
378 tracing::warn!("limit-service returned {}", resp.status());
379 return Err(());
380 }
381 let body: LimitResponse = resp
382 .json()
383 .await
384 .map_err(|e| tracing::warn!("limit-service response parse failed: {e}"))?;
385 Ok(self.map_response(body))
386 }
387
388 fn map_response(&self, body: LimitResponse) -> Option<CompiledProfile> {
391 if let Some(tier) = &body.tier {
392 if let Some(profile) = self.profiles.get(tier) {
393 return Some(*profile);
394 }
395 }
398 body.rate_per_min
399 .and_then(|rpm| profile_from_numbers(rpm, body.burst.unwrap_or(rpm)))
400 }
401}
402
403#[cfg(test)]
404mod tests {
405 use super::*;
406
407 fn profiles() -> HashMap<String, CompiledProfile> {
408 let mut m = HashMap::new();
409 m.insert(
410 "premium".to_string(),
411 profile_from_numbers(1000, 100).unwrap(), );
413 m
414 }
415
416 fn jwt_limits() -> JwtLimits {
417 JwtLimits::from_config(&JwtLimitConfig {
418 tier_claim: "ratelimit_tier".to_string(),
419 rpm_claim: "ratelimit_rpm".to_string(),
420 burst_claim: "ratelimit_burst".to_string(),
421 })
422 }
423
424 #[test]
425 fn jwt_tier_name_maps_to_profile() {
426 let claims = serde_json::json!({ "ratelimit_tier": "premium" });
427 let p = jwt_limits().resolve(&claims, &profiles()).unwrap();
428 assert_eq!(p.limit, 1000);
429 }
430
431 #[test]
432 fn jwt_direct_numbers_build_a_profile() {
433 let claims = serde_json::json!({ "ratelimit_rpm": 300, "ratelimit_burst": 30 });
434 let p = jwt_limits().resolve(&claims, &profiles()).unwrap();
435 assert_eq!(p.limit, 300);
436 }
437
438 #[test]
439 fn jwt_tier_takes_precedence_over_numbers() {
440 let claims = serde_json::json!({ "ratelimit_tier": "premium", "ratelimit_rpm": 5 });
441 let p = jwt_limits().resolve(&claims, &profiles()).unwrap();
442 assert_eq!(p.limit, 1000);
443 }
444
445 #[test]
446 fn jwt_unknown_tier_falls_through_to_numbers_then_none() {
447 let claims = serde_json::json!({ "ratelimit_tier": "gold" });
449 assert!(jwt_limits().resolve(&claims, &profiles()).is_none());
450 let claims = serde_json::json!({ "ratelimit_tier": "gold", "ratelimit_rpm": 42 });
452 assert_eq!(
453 jwt_limits().resolve(&claims, &profiles()).unwrap().limit,
454 42
455 );
456 }
457
458 #[tokio::test]
459 async fn actively_used_stale_entry_survives_eviction() {
460 use crate::config::LimitServiceConfig;
461 let svc = LimitService::build(
462 &LimitServiceConfig {
463 endpoint: "http://127.0.0.1:0/".to_string(),
464 ttl_secs: 1,
465 timeout_ms: 50,
466 },
467 profiles(),
468 )
469 .unwrap();
470 let old = Instant::now()
474 .checked_sub(Duration::from_secs(600))
475 .expect("clock supports the offset");
476 svc.cache.insert(
477 "k".to_string(),
478 Cached {
479 profile: Some(profile_from_numbers(10, 10).unwrap()),
480 at: old,
481 last_access: old,
482 },
483 );
484 let _ = svc.resolve("k");
485 svc.sweep();
486 assert!(
487 svc.cache.contains_key("k"),
488 "an actively-used stale entry must not be evicted during an outage"
489 );
490 }
491
492 fn service(endpoint: &str) -> Arc<LimitService> {
493 LimitService::build(
494 &LimitServiceConfig {
495 endpoint: endpoint.to_string(),
496 ttl_secs: 60,
497 timeout_ms: 50,
498 },
499 profiles(),
500 )
501 .unwrap()
502 }
503
504 #[test]
505 fn service_unknown_tier_falls_through_to_numbers() {
506 let svc = service("http://127.0.0.1:9/");
507 let p = svc
509 .map_response(LimitResponse {
510 tier: Some("gold".to_string()),
511 rate_per_min: Some(50),
512 burst: None,
513 })
514 .unwrap();
515 assert_eq!(p.limit, 50);
516 }
517
518 #[test]
519 fn build_rejects_non_http_endpoint() {
520 for ep in ["redis://127.0.0.1/", "file:///etc/passwd", "ftp://h/x"] {
522 let r = LimitService::build(
523 &LimitServiceConfig {
524 endpoint: ep.to_string(),
525 ttl_secs: 60,
526 timeout_ms: 50,
527 },
528 profiles(),
529 );
530 assert!(r.is_err(), "expected {ep} to be rejected");
531 }
532 }
533
534 #[test]
535 fn build_rejects_malformed_endpoint() {
536 let bad = LimitService::build(
537 &LimitServiceConfig {
538 endpoint: "not a url".to_string(),
539 ttl_secs: 60,
540 timeout_ms: 50,
541 },
542 profiles(),
543 );
544 assert!(bad.is_err());
545 }
546
547 #[test]
548 fn jwt_zero_rate_is_not_a_usable_limit() {
549 let claims = serde_json::json!({ "ratelimit_rpm": 0 });
552 assert!(jwt_limits().resolve(&claims, &profiles()).is_none());
553 }
554
555 #[test]
556 fn numeric_string_claims_are_accepted() {
557 let claims = serde_json::json!({ "ratelimit_rpm": "250" });
558 assert_eq!(
559 jwt_limits().resolve(&claims, &profiles()).unwrap().limit,
560 250
561 );
562 }
563}