use std::collections::HashMap;
use std::future::Future;
use std::pin::Pin;
use std::sync::atomic::{AtomicU64, Ordering};
use std::time::{SystemTime, UNIX_EPOCH};
use a2a_protocol_types::error::{A2aError, A2aResult};
use tokio::sync::RwLock;
use crate::call_context::CallContext;
use crate::error::{ServerError, ServerResult};
use crate::interceptor::ServerInterceptor;
#[derive(Debug, Clone)]
pub struct RateLimitConfig {
pub requests_per_window: u64,
pub window_secs: u64,
pub trusted_proxy_hops: usize,
pub max_buckets: usize,
}
pub const DEFAULT_MAX_BUCKETS: usize = 10_000;
impl Default for RateLimitConfig {
fn default() -> Self {
Self {
requests_per_window: 100,
window_secs: 60,
trusted_proxy_hops: 0,
max_buckets: DEFAULT_MAX_BUCKETS,
}
}
}
struct CallerBucket {
window_start: AtomicU64,
count: AtomicU64,
}
pub struct RateLimitInterceptor {
config: RateLimitConfig,
buckets: RwLock<HashMap<String, CallerBucket>>,
check_count: AtomicU64,
}
const CLEANUP_INTERVAL: u64 = 256;
impl std::fmt::Debug for RateLimitInterceptor {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("RateLimitInterceptor")
.field("config", &self.config)
.finish_non_exhaustive()
}
}
impl RateLimitInterceptor {
pub fn new(config: RateLimitConfig) -> ServerResult<Self> {
if config.requests_per_window == 0 {
return Err(ServerError::InvalidParams(
"rate limit requests_per_window must be greater than zero".into(),
));
}
if config.window_secs == 0 {
return Err(ServerError::InvalidParams(
"rate limit window_secs must be greater than zero".into(),
));
}
if config.max_buckets == 0 {
return Err(ServerError::InvalidParams(
"rate limit max_buckets must be greater than zero".into(),
));
}
Ok(Self {
config,
buckets: RwLock::new(HashMap::new()),
check_count: AtomicU64::new(0),
})
}
fn caller_key(&self, ctx: &CallContext) -> String {
if let Some(identity) = ctx.caller_identity() {
return identity.to_owned();
}
let hops = self.config.trusted_proxy_hops;
if hops > 0 {
if let Some(xff) = ctx.http_headers().get("x-forwarded-for") {
let entries: Vec<&str> = xff
.split(',')
.map(str::trim)
.filter(|e| !e.is_empty())
.collect();
if entries.len() >= hops {
return canonicalize_caller_ip(entries[entries.len() - hops]);
}
}
}
"anonymous".to_string()
}
const fn window_number(&self, now_secs: u64) -> u64 {
now_secs / self.config.window_secs
}
fn evict_stale(buckets: &mut HashMap<String, CallerBucket>, current_window: u64) {
buckets.retain(|_, bucket| {
bucket.window_start.load(Ordering::Relaxed) >= current_window.saturating_sub(1)
});
}
async fn cleanup_stale_buckets(&self) {
let now_secs = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap_or_default()
.as_secs();
let current_window = self.window_number(now_secs);
let mut buckets = self.buckets.write().await;
Self::evict_stale(&mut buckets, current_window);
}
#[allow(clippy::too_many_lines)]
fn admit_within_window(&self, bucket: &CallerBucket) -> A2aResult<()> {
let count = bucket.count.fetch_add(1, Ordering::Relaxed) + 1;
if count > self.config.requests_per_window {
return Err(A2aError::internal(format!(
"rate limit exceeded: {} requests per {} seconds",
self.config.requests_per_window, self.config.window_secs
)));
}
Ok(())
}
fn admit_or_roll_window(&self, bucket: &CallerBucket, current_window: u64) -> A2aResult<()> {
if bucket.window_start.load(Ordering::Acquire) == current_window {
return self.admit_within_window(bucket);
}
bucket.window_start.store(current_window, Ordering::Release);
bucket.count.store(1, Ordering::Release);
Ok(())
}
async fn create_or_join_bucket(&self, key: &str, current_window: u64) -> A2aResult<()> {
let mut buckets = self.buckets.write().await;
if let Some(bucket) = buckets.get(key) {
return self.admit_or_roll_window(bucket, current_window);
}
if buckets.len() >= self.config.max_buckets {
Self::evict_stale(&mut buckets, current_window);
if buckets.len() >= self.config.max_buckets {
return Err(A2aError::internal(format!(
"rate limiter caller capacity exhausted ({} buckets); request rejected",
self.config.max_buckets
)));
}
}
buckets.insert(
key.to_string(),
CallerBucket {
window_start: AtomicU64::new(current_window),
count: AtomicU64::new(1),
},
);
drop(buckets);
Ok(())
}
async fn check(&self, key: &str) -> A2aResult<()> {
let now_secs = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap_or_default()
.as_secs();
let current_window = self.window_number(now_secs);
let count = self.check_count.fetch_add(1, Ordering::Relaxed);
if count > 0 && count.is_multiple_of(CLEANUP_INTERVAL) {
self.cleanup_stale_buckets().await;
}
{
let buckets = self.buckets.read().await;
if let Some(bucket) = buckets.get(key) {
loop {
let bucket_window = bucket.window_start.load(Ordering::Acquire);
if bucket_window == current_window {
return self.admit_within_window(bucket);
}
if bucket
.window_start
.compare_exchange(
bucket_window,
current_window,
Ordering::AcqRel,
Ordering::Acquire,
)
.is_ok()
{
bucket.count.store(1, Ordering::Release);
return Ok(());
}
}
}
}
self.create_or_join_bucket(key, current_window).await
}
}
impl ServerInterceptor for RateLimitInterceptor {
fn before<'a>(
&'a self,
ctx: &'a CallContext,
) -> Pin<Box<dyn Future<Output = A2aResult<()>> + Send + 'a>> {
Box::pin(async move {
let key = self.caller_key(ctx);
self.check(&key).await
})
}
fn after<'a>(
&'a self,
_ctx: &'a CallContext,
) -> Pin<Box<dyn Future<Output = A2aResult<()>> + Send + 'a>> {
Box::pin(async { Ok(()) })
}
}
fn canonicalize_caller_ip(entry: &str) -> String {
use std::net::IpAddr;
let trimmed = entry.trim().trim_start_matches('[').trim_end_matches(']');
match trimmed.parse::<IpAddr>() {
Ok(IpAddr::V6(v6)) => v6
.to_ipv4_mapped()
.map_or_else(|| IpAddr::V6(v6).to_string(), |v4| v4.to_string()),
Ok(ip) => ip.to_string(),
Err(_) => trimmed.to_string(),
}
}
#[cfg(test)]
mod double_check_tests {
use super::{CallerBucket, RateLimitConfig, RateLimitInterceptor};
use std::sync::atomic::{AtomicU64, Ordering};
fn limiter(limit: u64) -> RateLimitInterceptor {
RateLimitInterceptor::new(RateLimitConfig {
requests_per_window: limit,
window_secs: 60,
..RateLimitConfig::default()
})
.expect("valid config")
}
fn bucket(window: u64, count: u64) -> CallerBucket {
CallerBucket {
window_start: AtomicU64::new(window),
count: AtomicU64::new(count),
}
}
#[test]
fn same_window_counts_the_request_rather_than_resetting() {
let rl = limiter(3);
let b = bucket(100, 2);
assert!(
rl.admit_or_roll_window(&b, 100).is_ok(),
"the third request of three is still within the limit"
);
assert_eq!(
b.count.load(Ordering::Acquire),
3,
"an in-window request must increment the counter, not reset it"
);
assert_eq!(
b.window_start.load(Ordering::Acquire),
100,
"the window must not roll while it is still current"
);
assert!(
rl.admit_or_roll_window(&b, 100).is_err(),
"the fourth request of three must be rejected"
);
}
#[test]
fn advanced_window_rolls_and_restarts_the_count() {
let rl = limiter(3);
let b = bucket(100, 99);
assert!(
rl.admit_or_roll_window(&b, 101).is_ok(),
"a request in a fresh window is admitted regardless of the old count"
);
assert_eq!(b.count.load(Ordering::Acquire), 1, "the count restarts");
assert_eq!(
b.window_start.load(Ordering::Acquire),
101,
"the window rolls forward"
);
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::collections::HashMap;
#[test]
fn caller_ip_canonicalization_collapses_equivalent_forms() {
assert_eq!(canonicalize_caller_ip("::ffff:203.0.113.7"), "203.0.113.7");
assert_eq!(canonicalize_caller_ip("203.0.113.7"), "203.0.113.7");
assert_eq!(
canonicalize_caller_ip("[2001:db8::1]"),
canonicalize_caller_ip("2001:0db8:0000:0000:0000:0000:0000:0001")
);
assert_eq!(canonicalize_caller_ip(" not-an-ip "), "not-an-ip");
}
fn make_ctx(identity: Option<&str>) -> CallContext {
let mut ctx = CallContext::new("message/send");
if let Some(id) = identity {
ctx = ctx.with_caller_identity(id.to_owned());
}
ctx
}
#[tokio::test]
async fn allows_requests_within_limit() {
let limiter = RateLimitInterceptor::new(RateLimitConfig {
requests_per_window: 5,
window_secs: 60,
..RateLimitConfig::default()
})
.expect("valid config");
let ctx = make_ctx(Some("user-1"));
for _ in 0..5 {
assert!(limiter.before(&ctx).await.is_ok());
}
}
#[tokio::test]
async fn rejects_requests_over_limit() {
let limiter = RateLimitInterceptor::new(RateLimitConfig {
requests_per_window: 3,
window_secs: 60,
..RateLimitConfig::default()
})
.expect("valid config");
let ctx = make_ctx(Some("user-2"));
for _ in 0..3 {
assert!(limiter.before(&ctx).await.is_ok());
}
let result = limiter.before(&ctx).await;
assert!(result.is_err());
}
#[tokio::test]
async fn different_callers_have_separate_limits() {
let limiter = RateLimitInterceptor::new(RateLimitConfig {
requests_per_window: 2,
window_secs: 60,
..RateLimitConfig::default()
})
.expect("valid config");
let ctx_a = make_ctx(Some("alice"));
let ctx_b = make_ctx(Some("bob"));
assert!(limiter.before(&ctx_a).await.is_ok());
assert!(limiter.before(&ctx_a).await.is_ok());
assert!(limiter.before(&ctx_a).await.is_err());
assert!(limiter.before(&ctx_b).await.is_ok());
assert!(limiter.before(&ctx_b).await.is_ok());
}
#[tokio::test]
async fn anonymous_fallback_when_no_identity() {
let limiter = RateLimitInterceptor::new(RateLimitConfig {
requests_per_window: 1,
window_secs: 60,
..RateLimitConfig::default()
})
.expect("valid config");
let ctx = make_ctx(None);
assert!(limiter.before(&ctx).await.is_ok());
assert!(limiter.before(&ctx).await.is_err());
}
#[tokio::test]
async fn default_config_ignores_forged_x_forwarded_for() {
let limiter = RateLimitInterceptor::new(RateLimitConfig {
requests_per_window: 1,
window_secs: 60,
..RateLimitConfig::default()
})
.expect("valid config");
let ctx1 = CallContext::new("message/send").with_http_header("x-forwarded-for", "10.0.0.1");
let ctx2 = CallContext::new("message/send").with_http_header("x-forwarded-for", "10.0.0.2");
assert!(limiter.before(&ctx1).await.is_ok());
assert!(
limiter.before(&ctx2).await.is_err(),
"forged x-forwarded-for must not evade the limit"
);
assert_eq!(limiter.buckets.read().await.len(), 1);
}
#[tokio::test]
async fn trusted_hop_uses_rightmost_entry_and_resists_spoofing() {
let limiter = RateLimitInterceptor::new(RateLimitConfig {
requests_per_window: 1,
window_secs: 60,
trusted_proxy_hops: 1,
..RateLimitConfig::default()
})
.expect("valid config");
let ctx1 = CallContext::new("message/send")
.with_http_header("x-forwarded-for", "6.6.6.1, 203.0.113.7");
let ctx2 = CallContext::new("message/send")
.with_http_header("x-forwarded-for", "6.6.6.2, 203.0.113.7");
assert!(limiter.before(&ctx1).await.is_ok());
assert!(
limiter.before(&ctx2).await.is_err(),
"spoofed left-hand entries must map to the same real client"
);
let ctx3 =
CallContext::new("message/send").with_http_header("x-forwarded-for", "203.0.113.8");
assert!(limiter.before(&ctx3).await.is_ok());
}
#[tokio::test]
async fn trusted_hops_two_takes_second_from_right() {
let limiter = RateLimitInterceptor::new(RateLimitConfig {
requests_per_window: 1,
window_secs: 60,
trusted_proxy_hops: 2,
..RateLimitConfig::default()
})
.expect("valid config");
let ctx1 = CallContext::new("message/send")
.with_http_header("x-forwarded-for", "6.6.6.1, 198.51.100.9, 10.0.0.5");
let ctx2 = CallContext::new("message/send")
.with_http_header("x-forwarded-for", "6.6.6.2, 198.51.100.9, 10.0.0.5");
assert!(limiter.before(&ctx1).await.is_ok());
assert!(
limiter.before(&ctx2).await.is_err(),
"same client, same bucket"
);
}
#[tokio::test]
async fn short_xff_chain_falls_back_to_anonymous() {
let limiter = RateLimitInterceptor::new(RateLimitConfig {
requests_per_window: 1,
window_secs: 60,
trusted_proxy_hops: 3,
..RateLimitConfig::default()
})
.expect("valid config");
let ctx1 = CallContext::new("message/send").with_http_header("x-forwarded-for", "1.2.3.4");
let ctx2 = CallContext::new("message/send").with_http_header("x-forwarded-for", "5.6.7.8");
assert!(limiter.before(&ctx1).await.is_ok());
assert!(
limiter.before(&ctx2).await.is_err(),
"short chains must share the anonymous bucket, not be trusted"
);
}
#[test]
fn new_rejects_zero_window_secs() {
let err = RateLimitInterceptor::new(RateLimitConfig {
window_secs: 0,
..RateLimitConfig::default()
})
.expect_err("zero window_secs must be rejected");
assert!(err.to_string().contains("window_secs"), "got: {err}");
}
#[test]
fn new_rejects_zero_requests_per_window() {
let err = RateLimitInterceptor::new(RateLimitConfig {
requests_per_window: 0,
..RateLimitConfig::default()
})
.expect_err("zero requests_per_window must be rejected");
assert!(
err.to_string().contains("requests_per_window"),
"got: {err}"
);
}
#[test]
fn new_rejects_zero_max_buckets() {
let err = RateLimitInterceptor::new(RateLimitConfig {
max_buckets: 0,
..RateLimitConfig::default()
})
.expect_err("zero max_buckets must be rejected");
assert!(err.to_string().contains("max_buckets"), "got: {err}");
}
#[tokio::test]
async fn bucket_map_is_bounded() {
let limiter = RateLimitInterceptor::new(RateLimitConfig {
requests_per_window: 10,
window_secs: 60,
max_buckets: 2,
..RateLimitConfig::default()
})
.expect("valid config");
assert!(limiter.before(&make_ctx(Some("a"))).await.is_ok());
assert!(limiter.before(&make_ctx(Some("b"))).await.is_ok());
let err = limiter
.before(&make_ctx(Some("c")))
.await
.expect_err("third caller must be rejected at capacity");
assert!(err.to_string().contains("capacity"), "got: {err}");
assert_eq!(limiter.buckets.read().await.len(), 2);
assert!(limiter.before(&make_ctx(Some("a"))).await.is_ok());
}
#[tokio::test]
async fn full_map_evicts_stale_buckets_before_rejecting() {
let limiter = RateLimitInterceptor::new(RateLimitConfig {
requests_per_window: 10,
window_secs: 60,
max_buckets: 2,
..RateLimitConfig::default()
})
.expect("valid config");
assert!(limiter.before(&make_ctx(Some("live"))).await.is_ok());
{
let mut buckets = limiter.buckets.write().await;
buckets.insert(
"ancient".to_string(),
CallerBucket {
window_start: AtomicU64::new(0),
count: AtomicU64::new(1),
},
);
}
assert!(
limiter.before(&make_ctx(Some("newcomer"))).await.is_ok(),
"stale bucket should be evicted to admit the new caller"
);
let buckets = limiter.buckets.read().await;
assert!(!buckets.contains_key("ancient"));
assert!(buckets.contains_key("live"));
assert!(buckets.contains_key("newcomer"));
drop(buckets);
}
#[tokio::test]
async fn concurrent_distinct_callers_respect_bucket_cap() {
use std::sync::Arc;
let limiter = RateLimitInterceptor::new(RateLimitConfig {
requests_per_window: 10,
window_secs: 60,
max_buckets: 10,
..RateLimitConfig::default()
})
.expect("valid config");
let limiter = Arc::new(limiter);
let mut handles = Vec::new();
for i in 0..50 {
let lim = Arc::clone(&limiter);
handles.push(tokio::spawn(async move {
let ctx =
CallContext::new("message/send").with_caller_identity(format!("user-{i}"));
lim.before(&ctx).await
}));
}
let mut ok_count = 0;
let mut err_count = 0;
for handle in handles {
match handle.await.unwrap() {
Ok(()) => ok_count += 1,
Err(_) => err_count += 1,
}
}
assert_eq!(ok_count, 10, "exactly max_buckets callers admitted");
assert_eq!(err_count, 40);
assert_eq!(limiter.buckets.read().await.len(), 10);
}
#[tokio::test]
async fn concurrent_rate_limit_checks() {
use std::sync::Arc;
let limiter = Arc::new(
RateLimitInterceptor::new(RateLimitConfig {
requests_per_window: 100,
window_secs: 60,
..RateLimitConfig::default()
})
.expect("valid config"),
);
let mut handles = Vec::new();
for _ in 0..200 {
let lim = Arc::clone(&limiter);
handles.push(tokio::spawn(async move {
let ctx =
CallContext::new("message/send").with_caller_identity("concurrent-user".into());
lim.before(&ctx).await
}));
}
let mut ok_count = 0;
let mut err_count = 0;
for handle in handles {
match handle.await.unwrap() {
Ok(()) => ok_count += 1,
Err(_) => err_count += 1,
}
}
assert_eq!(ok_count, 100, "expected 100 allowed, got {ok_count}");
assert_eq!(err_count, 100, "expected 100 rejected, got {err_count}");
}
#[tokio::test]
async fn stale_bucket_cleanup() {
let limiter = RateLimitInterceptor::new(RateLimitConfig {
requests_per_window: 10,
window_secs: 60,
..RateLimitConfig::default()
})
.expect("valid config");
let ctx_a = make_ctx(Some("stale-a"));
let ctx_b = make_ctx(Some("stale-b"));
assert!(limiter.before(&ctx_a).await.is_ok());
assert!(limiter.before(&ctx_b).await.is_ok());
assert_eq!(limiter.buckets.read().await.len(), 2);
limiter.cleanup_stale_buckets().await;
assert_eq!(
limiter.buckets.read().await.len(),
2,
"current-window buckets should not be evicted"
);
}
#[test]
fn debug_format_includes_config() {
let limiter = RateLimitInterceptor::new(RateLimitConfig {
requests_per_window: 42,
window_secs: 10,
..RateLimitConfig::default()
})
.expect("valid config");
let debug = format!("{limiter:?}");
assert!(
debug.contains("RateLimitInterceptor"),
"Debug output should contain struct name"
);
assert!(
debug.contains("config"),
"Debug output should contain config field"
);
}
#[test]
fn default_config_values() {
let config = RateLimitConfig::default();
assert_eq!(config.requests_per_window, 100);
assert_eq!(config.window_secs, 60);
}
#[tokio::test]
async fn after_hook_is_noop() {
let limiter = RateLimitInterceptor::new(RateLimitConfig::default()).expect("valid config");
let ctx = make_ctx(Some("user"));
let result = limiter.after(&ctx).await;
assert_eq!(result.unwrap(), (), "after hook should return Ok(())");
}
#[test]
fn window_number_correctness() {
let limiter = RateLimitInterceptor::new(RateLimitConfig {
requests_per_window: 10,
window_secs: 60,
..RateLimitConfig::default()
})
.expect("valid config");
assert_eq!(limiter.window_number(0), 0);
assert_eq!(limiter.window_number(59), 0);
assert_eq!(limiter.window_number(60), 1);
assert_eq!(limiter.window_number(120), 2);
assert_eq!(limiter.window_number(61), 1);
}
#[tokio::test]
async fn cleanup_stale_buckets_removes_old_entries() {
let limiter = RateLimitInterceptor::new(RateLimitConfig {
requests_per_window: 100,
window_secs: 60,
..RateLimitConfig::default()
})
.expect("valid config");
{
let mut buckets = limiter.buckets.write().await;
buckets.insert(
"ancient-user".to_string(),
CallerBucket {
window_start: AtomicU64::new(0), count: AtomicU64::new(5),
},
);
}
assert_eq!(limiter.buckets.read().await.len(), 1);
limiter.cleanup_stale_buckets().await;
assert_eq!(
limiter.buckets.read().await.len(),
0,
"ancient bucket should be evicted"
);
}
#[tokio::test]
async fn check_triggers_cleanup_at_interval() {
let limiter = RateLimitInterceptor::new(RateLimitConfig {
requests_per_window: 10000,
window_secs: 60,
..RateLimitConfig::default()
})
.expect("valid config");
{
let mut buckets = limiter.buckets.write().await;
buckets.insert(
"stale-for-cleanup".to_string(),
CallerBucket {
window_start: AtomicU64::new(0),
count: AtomicU64::new(1),
},
);
}
limiter
.check_count
.store(CLEANUP_INTERVAL, Ordering::Relaxed);
let ctx = make_ctx(Some("cleanup-trigger-user"));
assert!(limiter.before(&ctx).await.is_ok());
let buckets = limiter.buckets.read().await;
let has_stale = buckets.contains_key("stale-for-cleanup");
drop(buckets);
assert!(
!has_stale,
"stale bucket should be cleaned up after CLEANUP_INTERVAL checks"
);
}
#[tokio::test]
async fn slow_path_double_check_same_window() {
let limiter = RateLimitInterceptor::new(RateLimitConfig {
requests_per_window: 2,
window_secs: 60,
..RateLimitConfig::default()
})
.expect("valid config");
let ctx = make_ctx(Some("race-user"));
assert!(limiter.before(&ctx).await.is_ok());
assert!(limiter.before(&ctx).await.is_ok());
assert!(limiter.before(&ctx).await.is_err());
}
#[tokio::test]
async fn slow_path_double_check_stale_window() {
let limiter = RateLimitInterceptor::new(RateLimitConfig {
requests_per_window: 10,
window_secs: 60,
..RateLimitConfig::default()
})
.expect("valid config");
let key = "slow-path-stale";
{
let mut buckets = limiter.buckets.write().await;
buckets.insert(
key.to_string(),
CallerBucket {
window_start: AtomicU64::new(1), count: AtomicU64::new(5),
},
);
}
let result = limiter.check(key).await;
assert!(
result.is_ok(),
"slow-path stale-window reset should succeed"
);
assert_eq!(
limiter
.buckets
.read()
.await
.get(key)
.expect("bucket should exist")
.count
.load(Ordering::Relaxed),
1,
"count should be reset to 1 after window advance"
);
}
#[tokio::test]
async fn slow_path_rate_limit_exceeded() {
let limiter = RateLimitInterceptor::new(RateLimitConfig {
requests_per_window: 1,
window_secs: 60,
..RateLimitConfig::default()
})
.expect("valid config");
let now_secs = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap()
.as_secs();
let current_window = limiter.window_number(now_secs);
let key = "slow-path-exceeded";
{
let mut buckets = limiter.buckets.write().await;
buckets.insert(
key.to_string(),
CallerBucket {
window_start: AtomicU64::new(current_window),
count: AtomicU64::new(1), },
);
}
let result = limiter.check(key).await;
assert!(
result.is_err(),
"slow-path should reject when count exceeds limit"
);
}
#[tokio::test]
async fn fast_path_rate_limit_exceeded() {
let limiter = RateLimitInterceptor::new(RateLimitConfig {
requests_per_window: 2,
window_secs: 60,
..RateLimitConfig::default()
})
.expect("valid config");
let ctx = make_ctx(Some("fast-path-user"));
assert!(limiter.before(&ctx).await.is_ok());
assert!(limiter.before(&ctx).await.is_ok());
let result = limiter.before(&ctx).await;
assert!(
result.is_err(),
"fast-path should reject when count exceeds limit"
);
let err = result.unwrap_err();
assert!(
err.to_string().contains("rate limit exceeded"),
"error message should mention rate limit exceeded, got: {err}"
);
}
#[tokio::test]
async fn fast_path_window_advancement_resets_count() {
let limiter = RateLimitInterceptor::new(RateLimitConfig {
requests_per_window: 1,
window_secs: 60,
..RateLimitConfig::default()
})
.expect("valid config");
let key = "fast-path-window-advance";
{
let mut buckets = limiter.buckets.write().await;
buckets.insert(
key.to_string(),
CallerBucket {
window_start: AtomicU64::new(1), count: AtomicU64::new(999),
},
);
}
let result = limiter.check(key).await;
assert_eq!(
result.unwrap(),
(),
"fast-path window advance should return Ok(())"
);
assert_eq!(
limiter
.buckets
.read()
.await
.get(key)
.expect("bucket should exist")
.count
.load(Ordering::Relaxed),
1,
"count should be reset to 1 after window advance"
);
}
#[tokio::test]
async fn cleanup_does_not_run_on_first_call() {
let limiter = RateLimitInterceptor::new(RateLimitConfig {
requests_per_window: 10000,
window_secs: 60,
..RateLimitConfig::default()
})
.expect("valid config");
{
let mut buckets = limiter.buckets.write().await;
buckets.insert(
"stale-first-call".to_string(),
CallerBucket {
window_start: AtomicU64::new(0),
count: AtomicU64::new(1),
},
);
}
let ctx = make_ctx(Some("first-caller"));
assert!(limiter.before(&ctx).await.is_ok());
assert!(
limiter
.buckets
.read()
.await
.contains_key("stale-first-call"),
"stale bucket should not be cleaned up on the very first call"
);
}
#[tokio::test]
async fn x_forwarded_for_single_ip_with_trusted_hop() {
let limiter = RateLimitInterceptor::new(RateLimitConfig {
requests_per_window: 1,
window_secs: 60,
trusted_proxy_hops: 1,
..RateLimitConfig::default()
})
.expect("valid config");
let mut headers = HashMap::new();
headers.insert("x-forwarded-for".to_string(), "192.168.1.1".to_string());
let ctx = CallContext::new("message/send").with_http_headers(headers);
assert!(limiter.before(&ctx).await.is_ok());
assert!(limiter.before(&ctx).await.is_err());
}
}