use std::sync::Arc;
use std::time::{Duration, SystemTime, UNIX_EPOCH};
use axum::body::Body;
use axum::extract::Request;
use axum::http::{HeaderValue, Response};
use axum::middleware::Next;
use axum::Router;
use crate::cache::BoxedCache;
use crate::rate_limit::KeyBy;
fn hash_discriminator(value: &str) -> String {
use sha2::{Digest, Sha256};
let digest = Sha256::digest(value.as_bytes());
digest[..8]
.iter()
.fold(String::with_capacity(16), |mut s, b| {
use std::fmt::Write as _;
let _ = write!(s, "{b:02x}");
s
})
}
fn warn_missing_discriminator(what: &str) {
use std::sync::atomic::{AtomicBool, Ordering};
static WARNED: AtomicBool = AtomicBool::new(false);
if !WARNED.swap(true, Ordering::Relaxed) {
tracing::warn!(
target: "rustango::rate_limit",
discriminator = %what,
"rate limiter could not derive a per-client key; ALL such requests \
share ONE bucket, so one client throttles everyone. For IP keying, \
serve with `into_make_service_with_connect_info::<SocketAddr>()`; \
for header keying, ensure the header is present.",
);
}
}
#[derive(Clone)]
pub struct CacheRateLimitLayer {
cache: BoxedCache,
capacity: u32,
window: Duration,
key_by: KeyBy,
key_prefix: Arc<String>,
}
impl CacheRateLimitLayer {
#[must_use]
pub fn new(cache: BoxedCache, capacity: u32, window: Duration) -> Self {
Self {
cache,
capacity,
window,
key_by: KeyBy::Ip,
key_prefix: Arc::new("rl".to_owned()),
}
}
#[must_use]
pub fn key_by(mut self, key_by: KeyBy) -> Self {
self.key_by = key_by;
self
}
#[must_use]
pub fn key_prefix(mut self, prefix: impl Into<String>) -> Self {
self.key_prefix = Arc::new(prefix.into());
self
}
fn extract_key(&self, req: &Request<Body>) -> String {
match &self.key_by {
KeyBy::Ip => crate::rate_limit::client_ip_key(req),
KeyBy::Header(name) => req
.headers()
.get(*name)
.and_then(|v| v.to_str().ok())
.map(hash_discriminator)
.unwrap_or_else(|| {
warn_missing_discriminator(name);
"<no-header>".to_owned()
}),
KeyBy::Global => "<global>".to_owned(),
}
}
fn window_secs(&self) -> u64 {
self.window.as_secs().max(1)
}
fn window_now(&self) -> (u64, u64) {
let now = SystemTime::now()
.duration_since(UNIX_EPOCH)
.map_or(0, |d| d.as_secs());
(now, (now / self.window_secs()) * self.window_secs())
}
fn cache_key(&self, key: &str, window_start: u64) -> String {
format!("{}:{}:{window_start}", self.key_prefix.as_str(), key)
}
fn retry_after(&self, now: u64, window_start: u64) -> u64 {
(window_start + self.window_secs())
.saturating_sub(now)
.max(1)
}
pub async fn take(&self, key: &str) -> Result<(u32, u64), u64> {
match self.try_take(key).await {
Ok(r) => r
.map(|(count, window_start)| (count, window_start + self.window_secs()))
.map_err(|(retry_after, _)| retry_after),
Err(e) => {
tracing::warn!(error = %e, "rate-limit cache incr failed; allowing request");
Ok((0, 0))
}
}
}
pub(crate) async fn try_take(
&self,
key: &str,
) -> Result<Result<(u32, u64), (u64, u64)>, crate::cache::CacheError> {
let (now, window_start) = self.window_now();
let count = self
.cache
.incr(
&self.cache_key(key, window_start),
1,
Some(Duration::from_secs(self.window_secs())),
)
.await?;
Ok(if count > i64::from(self.capacity) {
Err((self.retry_after(now, window_start), window_start))
} else {
Ok((u32::try_from(count).unwrap_or(u32::MAX), window_start))
})
}
#[cfg(feature = "admin")] pub(crate) async fn try_peek(
&self,
key: &str,
) -> Result<Result<(), u64>, crate::cache::CacheError> {
let (now, window_start) = self.window_now();
let count = self
.cache
.get(&self.cache_key(key, window_start))
.await?
.and_then(|v| v.parse::<i64>().ok())
.unwrap_or(0);
Ok(if count >= i64::from(self.capacity) {
Err(self.retry_after(now, window_start))
} else {
Ok(())
})
}
pub(crate) async fn give_back_at(&self, key: &str, window_start: u64) {
if self.window_now().1 != window_start {
return;
}
let ttl = Some(Duration::from_secs(self.window_secs()));
let cache_key = self.cache_key(key, window_start);
let current = self.cache.get(&cache_key).await;
if !matches!(¤t, Ok(Some(v)) if v.parse::<i64>().is_ok_and(|n| n > 0)) {
return;
}
match self.cache.decr(&cache_key, 1, ttl).await {
Ok(n) if n < 0 => {
let _ = self.cache.incr(&cache_key, 1, ttl).await;
}
Ok(_) => {}
Err(e) => tracing::warn!(cache_key, error = %e, "rate-limit cache refund failed"),
}
}
pub(crate) fn cache(&self) -> &BoxedCache {
&self.cache
}
}
pub trait CacheRateLimitRouterExt {
#[must_use]
fn cache_rate_limit(self, layer: CacheRateLimitLayer) -> Self;
}
impl<S: Clone + Send + Sync + 'static> CacheRateLimitRouterExt for Router<S> {
fn cache_rate_limit(self, layer: CacheRateLimitLayer) -> Self {
let cfg = Arc::new(layer);
self.layer(axum::middleware::from_fn(
move |req: Request<Body>, next: Next| {
let cfg = cfg.clone();
async move { handle(cfg, req, next).await }
},
))
}
}
async fn handle(cfg: Arc<CacheRateLimitLayer>, req: Request<Body>, next: Next) -> Response<Body> {
let key = cfg.extract_key(&req);
match cfg.take(&key).await {
Ok((count, reset_at)) => {
let mut response = next.run(req).await;
let remaining = i64::from(cfg.capacity).saturating_sub(i64::from(count));
let _ = response.headers_mut().insert(
"x-ratelimit-limit",
HeaderValue::from_str(&cfg.capacity.to_string())
.unwrap_or(HeaderValue::from_static("0")),
);
let _ = response.headers_mut().insert(
"x-ratelimit-remaining",
HeaderValue::from_str(&remaining.max(0).to_string())
.unwrap_or(HeaderValue::from_static("0")),
);
if reset_at > 0 {
let _ = response.headers_mut().insert(
"x-ratelimit-reset",
HeaderValue::from_str(&reset_at.to_string())
.unwrap_or(HeaderValue::from_static("0")),
);
}
response
}
Err(retry_secs) => {
let mut resp = crate::api_errors::ApiError::rate_limited_response(
"rate limit exceeded",
retry_secs,
);
let h = resp.headers_mut();
h.insert("x-ratelimit-limit", cfg.capacity.into());
h.insert("x-ratelimit-remaining", HeaderValue::from_static("0"));
resp
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::cache::InMemoryCache;
fn layer(capacity: u32, window_secs: u64) -> CacheRateLimitLayer {
let cache: BoxedCache = Arc::new(InMemoryCache::new());
CacheRateLimitLayer::new(cache, capacity, Duration::from_secs(window_secs))
.key_prefix("test")
}
#[test]
fn header_value_is_hashed_not_stored_raw() {
let secret = "super-secret-api-key-abc123";
let h = hash_discriminator(secret);
assert_ne!(h, secret, "value must be transformed");
assert!(
!h.contains("secret"),
"raw secret must not leak into the key"
);
assert_eq!(h.len(), 16, "expected a 16-hex-char digest prefix");
assert!(h.chars().all(|c| c.is_ascii_hexdigit()));
assert_eq!(h, hash_discriminator(secret));
assert_ne!(h, hash_discriminator("a-different-key"));
}
#[tokio::test]
async fn first_n_under_capacity_succeed() {
let l = layer(3, 60);
for _ in 0..3 {
assert!(l.take("alice").await.is_ok());
}
}
#[tokio::test]
async fn n_plus_one_returns_retry_after() {
let l = layer(2, 60);
assert!(l.take("alice").await.is_ok());
assert!(l.take("alice").await.is_ok());
let err = l.take("alice").await.unwrap_err();
assert!(err >= 1, "retry_after must be at least 1 sec, got {err}");
}
#[tokio::test]
async fn separate_keys_have_independent_counters() {
let l = layer(1, 60);
assert!(l.take("alice").await.is_ok());
assert!(l.take("alice").await.is_err());
assert!(l.take("bob").await.is_ok());
}
#[tokio::test]
async fn separate_prefixes_have_independent_counters() {
let cache: BoxedCache = Arc::new(InMemoryCache::new());
let a =
CacheRateLimitLayer::new(cache.clone(), 1, Duration::from_secs(60)).key_prefix("login");
let b = CacheRateLimitLayer::new(cache, 1, Duration::from_secs(60)).key_prefix("signup");
assert!(a.take("alice").await.is_ok());
assert!(a.take("alice").await.is_err());
assert!(b.take("alice").await.is_ok());
}
#[tokio::test]
async fn count_returned_increases_per_call() {
let l = layer(5, 60);
let (c1, _) = l.take("k").await.unwrap();
let (c2, _) = l.take("k").await.unwrap();
let (c3, _) = l.take("k").await.unwrap();
assert_eq!((c1, c2, c3), (1, 2, 3));
}
#[tokio::test]
async fn reset_at_advances_with_window() {
let l = layer(1, 60);
let (_, reset_at) = l.take("k").await.unwrap();
let now = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap()
.as_secs();
assert!(reset_at > now);
assert!(reset_at <= now + 60);
}
#[tokio::test]
async fn refund_never_goes_below_zero() {
let l = layer(1, 3600);
let (_, window) = l.try_take("k").await.unwrap().unwrap();
let key = l.cache_key("k", window);
l.cache.delete(&key).await.unwrap();
l.give_back_at("k", window).await;
let v = l.cache.get(&key).await.unwrap();
assert!(
v.as_deref().is_none_or(|v| v == "0"),
"counter went to {v:?}"
);
assert!(l.try_take("k").await.unwrap().is_ok());
assert!(l.try_take("k").await.unwrap().is_err(), "an extra slot");
}
#[tokio::test]
async fn refund_after_the_window_changed_is_dropped() {
let l = layer(1, 3600);
let (_, window) = l.try_take("k").await.unwrap().unwrap();
let past = window - 3600;
l.give_back_at("k", past).await;
assert_eq!(l.cache.get(&l.cache_key("k", past)).await.unwrap(), None);
assert!(
l.try_take("k").await.unwrap().is_err(),
"this window refunded"
);
}
#[tokio::test]
async fn fail_open_on_cache_error_is_documented_via_take_succeeding() {
let l = layer(2, 60);
assert!(l.take("k").await.is_ok());
}
}