use super::{CallerBucket, RateLimitConfig, RateLimitInterceptor};
use std::sync::atomic::{AtomicU64, Ordering};
use std::time::{SystemTime, UNIX_EPOCH};
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, rl.config.requests_per_window)
.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, rl.config.requests_per_window)
.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, rl.config.requests_per_window)
.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"
);
}
use super::identity::canonicalize_caller_ip;
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, limiter.config.requests_per_window).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, limiter.config.requests_per_window).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, limiter.config.requests_per_window).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());
}
mod tenant {
use super::{make_ctx, RateLimitConfig, RateLimitInterceptor};
use crate::store::tenant::TenantContext;
use crate::tenant_config::{PerTenantConfig, TenantLimits};
use crate::ServerInterceptor as _;
fn limiter_with(rps: Option<u32>, window_secs: u64) -> RateLimitInterceptor {
let limits = rps.map_or_else(TenantLimits::default, |r| {
TenantLimits::builder().rate_limit_rps(r).build()
});
RateLimitInterceptor::new(RateLimitConfig {
requests_per_window: 1_000_000,
window_secs,
..RateLimitConfig::default()
})
.expect("valid config")
.with_tenant_config(
PerTenantConfig::builder()
.with_override("acme", limits)
.build(),
)
}
#[tokio::test]
async fn the_tenant_limit_applies_across_distinct_callers() {
let rl = limiter_with(Some(1), 1);
TenantContext::scope("acme", async {
assert!(rl.before(&make_ctx(Some("alice"))).await.is_ok());
assert!(
rl.before(&make_ctx(Some("bob"))).await.is_err(),
"a tenant allowance must not be multiplied by its caller count"
);
})
.await;
}
#[tokio::test]
async fn the_rps_unit_is_multiplied_by_the_window() {
let rl = limiter_with(Some(2), 60);
TenantContext::scope("acme", async {
for i in 0..120 {
assert!(
rl.before(&make_ctx(Some("alice"))).await.is_ok(),
"request {i} of the tenant's 2 rps x 60s allowance was refused"
);
}
assert!(
rl.before(&make_ctx(Some("alice"))).await.is_err(),
"the 121st request exceeds 2 rps x 60s"
);
})
.await;
}
#[tokio::test]
async fn a_tenant_without_an_rps_is_not_counted_against_any_tenant_bucket() {
let rl = limiter_with(None, 1);
TenantContext::scope("acme", async {
for i in 0..50 {
assert!(
rl.before(&make_ctx(Some("alice"))).await.is_ok(),
"request {i} was refused although the tenant declares no rps"
);
}
})
.await;
}
#[tokio::test]
async fn an_unconfigured_limiter_is_unchanged() {
let rl = RateLimitInterceptor::new(RateLimitConfig {
requests_per_window: 2,
window_secs: 60,
..RateLimitConfig::default()
})
.expect("valid config");
TenantContext::scope("acme", async {
assert!(rl.before(&make_ctx(Some("alice"))).await.is_ok());
assert!(rl.before(&make_ctx(Some("alice"))).await.is_ok());
assert!(
rl.before(&make_ctx(Some("alice"))).await.is_err(),
"the caller limit of 2 must still apply on its own"
);
})
.await;
}
#[tokio::test]
async fn the_caller_limit_still_applies_under_a_generous_tenant_limit() {
let rl = RateLimitInterceptor::new(RateLimitConfig {
requests_per_window: 1,
window_secs: 60,
..RateLimitConfig::default()
})
.expect("valid config")
.with_tenant_config(
PerTenantConfig::builder()
.with_override("acme", TenantLimits::builder().rate_limit_rps(1000).build())
.build(),
);
TenantContext::scope("acme", async {
assert!(rl.before(&make_ctx(Some("alice"))).await.is_ok());
assert!(
rl.before(&make_ctx(Some("alice"))).await.is_err(),
"a generous tenant allowance must not lift the caller limit"
);
})
.await;
}
}