use axum::http::StatusCode;
use std::collections::hash_map::DefaultHasher;
use std::collections::HashMap;
use std::hash::{Hash, Hasher};
use std::sync::atomic::Ordering;
use std::sync::Arc;
use std::time::Instant;
use tokio::sync::Mutex;
use super::Metrics;
#[derive(Clone)]
pub struct RateLimiter {
buckets: Arc<Mutex<HashMap<String, TokenBucket>>>,
capacity: u32,
refill_rate: f64,
}
struct TokenBucket {
tokens: f64,
last_refill: Instant,
}
impl RateLimiter {
pub fn new(capacity: u32, refill_rate: f64) -> Self {
Self {
buckets: Arc::new(Mutex::new(HashMap::new())),
capacity,
refill_rate,
}
}
async fn check(&self, key: &str) -> bool {
let mut buckets = self.buckets.lock().await;
let now = Instant::now();
let bucket = buckets.entry(key.to_string()).or_insert_with(|| {
let tokens = self.capacity as f64;
TokenBucket {
tokens,
last_refill: now,
}
});
let elapsed = now.duration_since(bucket.last_refill).as_secs_f64();
let refill = elapsed * self.refill_rate;
bucket.tokens = (bucket.tokens + refill).min(self.capacity as f64);
bucket.last_refill = now;
if bucket.tokens >= 1.0 {
bucket.tokens -= 1.0;
true
} else {
false
}
}
pub(super) async fn prune(&self) {
let mut buckets = self.buckets.lock().await;
buckets.retain(|_, b| b.tokens < self.capacity as f64);
}
#[cfg(test)]
pub(super) async fn bucket_count(&self) -> usize {
self.buckets.lock().await.len()
}
}
pub fn rate_limiter_from_env() -> RateLimiter {
let rpm = std::env::var("RECURSIVE_RATE_LIMIT_RPM")
.ok()
.and_then(|v| v.parse::<f64>().ok())
.unwrap_or(60.0);
let burst = std::env::var("RECURSIVE_RATE_LIMIT_BURST")
.ok()
.and_then(|v| v.parse::<u32>().ok())
.unwrap_or(10);
if rpm <= 0.0 {
tracing::warn!("RECURSIVE_RATE_LIMIT_RPM=0: token bucket never refills, clients will be permanently blocked after burst");
}
if burst == 0 {
tracing::warn!("RECURSIVE_RATE_LIMIT_BURST=0: all requests will be rejected immediately");
}
let refill_rate = rpm / 60.0;
RateLimiter::new(burst, refill_rate)
}
fn hash_key(value: &str) -> String {
let mut h = DefaultHasher::new();
value.hash(&mut h);
format!("{:016x}", h.finish())
}
pub(super) fn extract_client_key(req: &axum::extract::Request) -> String {
if let Some(api_key) = req.headers().get("x-api-key") {
if let Ok(key) = api_key.to_str() {
return format!("apikey:{}", hash_key(key));
}
}
if let Some(xff) = req.headers().get("x-forwarded-for") {
if let Ok(s) = xff.to_str() {
if let Some(first) = s.split(',').next() {
let first = first.trim();
if !first.is_empty() {
return format!("xff:{first}");
}
}
}
}
req.extensions()
.get::<axum::extract::ConnectInfo<std::net::SocketAddr>>()
.map(|info| format!("ip:{}", info.ip()))
.unwrap_or_else(|| "ip:unknown".to_string())
}
pub(super) async fn metrics_middleware(
axum::extract::State(metrics): axum::extract::State<Arc<Metrics>>,
req: axum::extract::Request,
next: axum::middleware::Next,
) -> axum::response::Response {
metrics.requests_total.fetch_add(1, Ordering::Relaxed);
metrics.requests_active.fetch_add(1, Ordering::Relaxed);
let response = next.run(req).await;
metrics.requests_active.fetch_sub(1, Ordering::Relaxed);
response
}
pub(super) async fn rate_limit_middleware(
axum::extract::State((limiter, metrics)): axum::extract::State<(RateLimiter, Arc<Metrics>)>,
req: axum::extract::Request,
next: axum::middleware::Next,
) -> axum::response::Response {
let key = extract_client_key(&req);
if !limiter.check(&key).await {
metrics.rate_limits_rejected.fetch_add(1, Ordering::Relaxed);
let mut resp = axum::response::Response::new(axum::body::Body::from("rate limit exceeded"));
*resp.status_mut() = StatusCode::TOO_MANY_REQUESTS;
return resp;
}
next.run(req).await
}
#[cfg(test)]
mod tests {
use super::*;
use axum::routing::get;
use std::time::Duration;
use tower::ServiceExt;
fn test_limiter(capacity: u32, rpm: f64) -> RateLimiter {
RateLimiter::new(capacity, rpm / 60.0)
}
#[tokio::test]
async fn test_requests_within_limit_succeed() {
let limiter = test_limiter(5, 60.0); for _ in 0..5 {
assert!(limiter.check("client-a").await, "request should be allowed");
}
}
#[tokio::test]
async fn test_requests_exceeding_limit_get_429() {
let limiter = test_limiter(3, 60.0); for _ in 0..3 {
assert!(limiter.check("client-b").await, "request should be allowed");
}
assert!(
!limiter.check("client-b").await,
"request should be rate limited"
);
}
#[tokio::test]
async fn test_tokens_refill_after_waiting() {
let limiter = test_limiter(2, 60.0); assert!(limiter.check("client-c").await);
assert!(limiter.check("client-c").await);
assert!(!limiter.check("client-c").await, "should be denied");
tokio::time::sleep(Duration::from_millis(1100)).await;
assert!(
limiter.check("client-c").await,
"should be allowed after refill"
);
}
#[tokio::test]
async fn test_different_clients_have_independent_buckets() {
let limiter = test_limiter(2, 60.0);
assert!(limiter.check("client-d").await);
assert!(limiter.check("client-d").await);
assert!(
!limiter.check("client-d").await,
"client-d should be denied"
);
assert!(
limiter.check("client-e").await,
"client-e should be allowed"
);
assert!(
limiter.check("client-e").await,
"client-e should be allowed"
);
}
#[tokio::test]
async fn test_extract_client_key_with_api_key() {
let req = axum::http::Request::builder()
.header("x-api-key", "test-key-123")
.body(axum::body::Body::empty())
.unwrap();
let key = extract_client_key(&req);
assert!(
key.starts_with("apikey:"),
"expected apikey: prefix, got: {key}"
);
assert!(
!key.contains("test-key-123"),
"raw API key must not appear in bucket key"
);
let req2 = axum::http::Request::builder()
.header("x-api-key", "test-key-123")
.body(axum::body::Body::empty())
.unwrap();
assert_eq!(
key,
extract_client_key(&req2),
"bucket key must be deterministic"
);
let req3 = axum::http::Request::builder()
.header("x-api-key", "other-key-456")
.body(axum::body::Body::empty())
.unwrap();
assert_ne!(
key,
extract_client_key(&req3),
"different keys must have different buckets"
);
}
#[tokio::test]
async fn test_extract_client_key_without_api_key() {
let req = axum::http::Request::builder()
.body(axum::body::Body::empty())
.unwrap();
let key = extract_client_key(&req);
assert_eq!(key, "ip:unknown");
}
#[tokio::test]
async fn test_rate_limiter_from_env_defaults() {
std::env::remove_var("RECURSIVE_RATE_LIMIT_RPM");
std::env::remove_var("RECURSIVE_RATE_LIMIT_BURST");
let limiter = rate_limiter_from_env();
for _ in 0..10 {
assert!(limiter.check("default-client").await);
}
assert!(!limiter.check("default-client").await, "burst exceeded");
}
#[tokio::test]
async fn prune_removes_full_buckets() {
let limiter = RateLimiter::new(2, 1.0);
limiter.check("client-a").await;
limiter.check("client-b").await;
limiter.check("client-new-full").await;
limiter.prune().await;
let count_after = limiter.bucket_count().await;
assert_eq!(
count_after, 3,
"all partially drained, none should be removed"
);
{
let mut b = limiter.buckets.lock().await;
b.insert(
"idle-client".to_string(),
TokenBucket {
tokens: 2.0, last_refill: std::time::Instant::now(),
},
);
}
limiter.prune().await;
assert_eq!(
limiter.bucket_count().await,
3,
"idle-client should be evicted"
);
{
let mut b = limiter.buckets.lock().await;
let now = std::time::Instant::now();
for bucket in b.values_mut() {
bucket.tokens = 2.0;
bucket.last_refill = now;
}
}
limiter.prune().await;
assert_eq!(
limiter.bucket_count().await,
0,
"all buckets should be idle after manual refill"
);
}
#[tokio::test]
async fn rate_limits_rejected_counter_increments() {
let metrics = Arc::new(Metrics::default());
let limiter = RateLimiter::new(0, 0.0);
let app = axum::Router::new()
.route("/", get(|| async { "ok" }))
.layer(axum::middleware::from_fn_with_state(
(limiter, metrics.clone()),
rate_limit_middleware,
));
let resp = app
.clone()
.oneshot(
axum::extract::Request::builder()
.uri("/")
.body(axum::body::Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::TOO_MANY_REQUESTS);
assert_eq!(
metrics.rate_limits_rejected.load(Ordering::Relaxed),
1,
"rate_limits_rejected should increment to 1"
);
let resp = app
.oneshot(
axum::extract::Request::builder()
.uri("/")
.body(axum::body::Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::TOO_MANY_REQUESTS);
assert_eq!(
metrics.rate_limits_rejected.load(Ordering::Relaxed),
2,
"rate_limits_rejected should increment to 2"
);
}
}
#[cfg(test)]
mod goal_h3_xff {
use super::*;
use axum::extract::Request;
fn build_request_with_xff(xff: Option<&str>) -> Request {
let mut req = Request::builder()
.uri("/")
.body(axum::body::Body::empty())
.unwrap();
if let Some(xff) = xff {
req.headers_mut()
.insert("x-forwarded-for", xff.parse().expect("valid header value"));
}
req
}
#[test]
fn extract_client_key_uses_xff_when_no_apikey() {
let req = build_request_with_xff(Some("203.0.113.42, 10.0.0.1"));
let key = extract_client_key(&req);
assert_eq!(key, "xff:203.0.113.42");
}
#[test]
fn extract_client_key_trims_whitespace_in_xff() {
let req = build_request_with_xff(Some(" 203.0.113.42 , 10.0.0.1 "));
let key = extract_client_key(&req);
assert_eq!(key, "xff:203.0.113.42");
}
#[test]
fn extract_client_key_falls_back_when_xff_empty() {
let req = build_request_with_xff(Some(""));
let key = extract_client_key(&req);
assert!(
key.starts_with("ip:"),
"empty XFF should fall through to socket IP, got {key}"
);
}
}