#[cfg(feature = "in-memory")]
use std::sync::OnceLock;
#[cfg(feature = "in-memory")]
use std::time::{Duration, Instant};
use std::future::Future;
use crate::metrics::RateLimitResult;
use crate::quota::Quota;
pub trait RateLimitBackend: Send + Sync + 'static {
fn check(&self, key: &str, quota: &Quota) -> impl Future<Output = RateLimitResult> + Send;
}
pub fn gcra_decide(
now_ms: u64,
tac_ms: u64,
emission_interval_ms: u64,
burst_ms: u64,
) -> (bool, u64, u64) {
let emission = u128::from(emission_interval_ms.max(1));
let burst = u128::from(burst_ms);
let now = u128::from(now_ms);
let new_tat = u128::from(tac_ms).max(now) + emission;
let conforms = new_tat <= now + emission * burst;
if conforms {
let remaining = ((new_tat - now) / emission).min(burst) as u64;
(true, 0, remaining)
} else {
let retry_after = u64::try_from(new_tat - now - emission * burst).unwrap_or(u64::MAX);
(false, retry_after, 0)
}
}
#[cfg(feature = "in-memory")]
#[derive(Clone)]
pub struct InMemoryBackend {
entries: dashmap::DashMap<String, Entry>,
}
#[cfg(feature = "in-memory")]
#[derive(Clone)]
struct Entry {
tac_ms: u64,
}
#[cfg(feature = "in-memory")]
fn gcra_advance(entry: &mut Entry, now_ms: u64, interval_ms: u64, burst: u64) -> (bool, u64, u64) {
let (allowed, retry_after_ms, remaining) =
gcra_decide(now_ms, entry.tac_ms, interval_ms, burst);
if allowed {
entry.tac_ms = entry.tac_ms.max(now_ms).saturating_add(interval_ms);
}
(allowed, retry_after_ms, remaining)
}
#[cfg(feature = "in-memory")]
fn monotonic_anchor() -> Instant {
static ANCHOR: OnceLock<Instant> = OnceLock::new();
*ANCHOR.get_or_init(Instant::now)
}
#[cfg(feature = "in-memory")]
fn monotonic_ms() -> u64 {
monotonic_anchor().elapsed().as_millis() as u64
}
#[cfg(feature = "in-memory")]
fn monotonic_deadline(from_now_ms: u64) -> Instant {
monotonic_anchor()
.checked_add(Duration::from_millis(from_now_ms))
.unwrap_or_else(Instant::now)
}
#[cfg(feature = "in-memory")]
impl InMemoryBackend {
pub fn new() -> Self {
Self {
entries: dashmap::DashMap::new(),
}
}
}
#[cfg(feature = "in-memory")]
impl Default for InMemoryBackend {
fn default() -> Self {
Self::new()
}
}
#[cfg(feature = "in-memory")]
impl RateLimitBackend for InMemoryBackend {
async fn check(&self, key: &str, quota: &Quota) -> RateLimitResult {
let interval_ms = quota.interval().as_millis().max(1) as u64;
let burst = u64::from(quota.burst);
let now_ms = monotonic_ms();
let (allowed, retry_after_ms, remaining) =
if let Some(mut entry) = self.entries.get_mut(key) {
gcra_advance(&mut entry, now_ms, interval_ms, burst)
} else {
let mut entry = self
.entries
.entry(key.to_owned())
.or_insert_with(|| Entry { tac_ms: now_ms });
gcra_advance(&mut entry, now_ms, interval_ms, burst)
};
let reset_at = monotonic_deadline(now_ms.saturating_add(interval_ms));
#[cfg(feature = "metrics")]
{
let utilization = 1.0 - remaining as f64 / burst as f64;
metrics::histogram!("ratelimit_utilization").record(utilization);
if allowed {
metrics::counter!("ratelimit_allowed_total", "key" => key.to_string()).increment(1);
} else {
metrics::counter!("ratelimit_rejected_total", "key" => key.to_string())
.increment(1);
}
}
RateLimitResult {
allowed,
remaining,
reset_at,
limit: burst,
retry_after: if allowed {
None
} else {
Some(Duration::from_millis(retry_after_ms))
},
}
}
}
#[cfg(feature = "test-util")]
#[cfg_attr(docsrs, doc(cfg(feature = "test-util")))]
#[derive(Debug, Clone, Copy, Default)]
pub struct NoopBackend;
#[cfg(feature = "test-util")]
impl RateLimitBackend for NoopBackend {
fn check(&self, _key: &str, quota: &Quota) -> impl Future<Output = RateLimitResult> + Send {
std::future::ready(RateLimitResult {
allowed: true,
remaining: u64::from(quota.burst).saturating_sub(1),
reset_at: std::time::Instant::now() + quota.interval(),
limit: u64::from(quota.burst),
retry_after: None,
})
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn gcra_decide_fresh_key_allows_with_one_interval_budget() {
let (allowed, retry_after, remaining) = gcra_decide(1_000, 1_000, 100, 10);
assert!(allowed);
assert_eq!(retry_after, 0);
assert_eq!(remaining, 1);
}
#[test]
fn gcra_decide_burst_rapid_requests_then_deny() {
let (a1, r1, rem1) = gcra_decide(0, 0, 100, 3);
assert!(a1 && r1 == 0 && rem1 == 1);
let (a2, r2, rem2) = gcra_decide(0, 100, 100, 3);
assert!(a2 && r2 == 0 && rem2 == 2);
let (a3, r3, rem3) = gcra_decide(0, 200, 100, 3);
assert!(a3 && r3 == 0 && rem3 == 3);
let (a4, r4, rem4) = gcra_decide(0, 300, 100, 3);
assert!(!a4 && r4 > 0 && rem4 == 0);
}
#[test]
fn gcra_decide_zero_burst_never_allows() {
let (allowed, retry_after, remaining) = gcra_decide(1_000, 1_000, 100, 0);
assert!(!allowed);
assert!(retry_after >= 1);
assert_eq!(remaining, 0);
}
#[test]
fn gcra_decide_zero_emission_is_clamped() {
let (allowed, retry_after, _remaining) = gcra_decide(5, 5, 0, 1);
assert!(allowed);
assert_eq!(retry_after, 0);
}
#[test]
fn gcra_decide_deny_with_now_greater_than_zero() {
let (allowed, retry_after, remaining) = gcra_decide(1_000, 1_300, 100, 3);
assert!(!allowed);
assert_eq!(retry_after, 100);
assert_eq!(remaining, 0);
}
#[test]
fn gcra_decide_extreme_inputs_no_overflow() {
let (allowed, retry_after, remaining) = gcra_decide(0, u64::MAX, u64::MAX, u64::MAX);
assert!(allowed);
assert_eq!(retry_after, 0);
assert_eq!(remaining, 2);
let (allowed2, retry_after2, remaining2) = gcra_decide(u64::MAX, u64::MAX, 1, 2);
assert!(allowed2);
assert_eq!(retry_after2, 0);
assert_eq!(remaining2, 1);
}
#[cfg(feature = "in-memory")]
#[tokio::test]
async fn in_memory_backend_reallows_after_real_elapsed_time() {
let backend = InMemoryBackend::new();
let quota = Quota::per_second(10);
for i in 1..=10u64 {
let r = backend.check("clock", "a).await;
assert!(r.allowed);
assert_eq!(r.remaining, i);
}
assert!(!backend.check("clock", "a).await.allowed);
tokio::time::sleep(Duration::from_millis(120)).await;
assert!(
backend.check("clock", "a).await.allowed,
"key must re-conform after one full interval of real time"
);
}
#[cfg(feature = "in-memory")]
#[tokio::test]
async fn in_memory_reset_at_is_in_the_future_when_allowed() {
let backend = InMemoryBackend::new();
let r = backend.check("reset", &Quota::per_second(10)).await;
assert!(r.allowed);
assert!(
r.reset_at > std::time::Instant::now(),
"reset_at must be in the future for an allowed request"
);
}
#[cfg(feature = "in-memory")]
#[tokio::test]
async fn warm_key_fast_path_matches_cold_path_semantics() {
let backend = InMemoryBackend::new();
let quota = Quota::per_second(10);
for i in 1..=10u64 {
let r = backend.check("hot", "a).await;
assert!(r.allowed, "call {i} must conform");
assert_eq!(r.remaining, i);
}
let denied = backend.check("hot", "a).await;
assert!(!denied.allowed);
assert!(denied.retry_after.is_some());
assert_eq!(backend.entries.len(), 1);
assert!(backend.entries.contains_key("hot"));
}
#[cfg(feature = "in-memory")]
#[tokio::test]
async fn warm_key_reset_at_derived_from_single_clock_read() {
let backend = InMemoryBackend::new();
let quota = Quota::per_second(10); let before = std::time::Instant::now();
let r = backend.check("clock-domain", "a).await;
let after = std::time::Instant::now();
assert!(r.allowed);
assert!(
r.reset_at + Duration::from_millis(1) >= before + quota.interval(),
"reset_at = clock_read + interval, read no earlier than `before`"
);
assert!(
r.reset_at <= after + quota.interval(),
"reset_at = clock_read + interval, read no later than `after`"
);
}
}