use super::*;
use std::net::Ipv4Addr;
fn metrics() -> Arc<Metrics> {
Arc::new(Metrics::new())
}
fn peer(byte: u8) -> NodeId {
NodeId::from_bytes([byte; 32])
}
fn ip(a: u8, b: u8, c: u8, d: u8) -> IpAddr {
IpAddr::V4(Ipv4Addr::new(a, b, c, d))
}
fn strict_cfg() -> DhtRateLimitConfig {
DhtRateLimitConfig {
per_peer_rate_per_sec: 1.0,
per_peer_burst: 1,
per_ip_rate_per_sec: 1.0,
per_ip_burst: 1,
global_rate_per_sec: 1.0,
global_burst: 1,
max_tracked_per_ip: 4096,
max_tracked_per_peer: 4096,
}
}
#[test]
fn admits_first_request_under_default_caps() {
let lim = DhtRateLimiter::new(&DhtRateLimitConfig::default(), metrics());
assert_eq!(lim.check(&peer(1), Some(ip(10, 0, 0, 1))), Ok(()));
}
#[test]
fn per_peer_layer_rejects_after_burst() {
let mut cfg = strict_cfg();
cfg.global_burst = u32::MAX;
cfg.global_rate_per_sec = 1e9;
cfg.per_ip_burst = u32::MAX;
cfg.per_ip_rate_per_sec = 1e9;
let lim = DhtRateLimiter::new(&cfg, metrics());
let p = peer(1);
let i = Some(ip(10, 0, 0, 1));
assert_eq!(lim.check(&p, i), Ok(()));
assert_eq!(lim.check(&p, i), Err(DhtRejectLayer::PerPeer));
}
#[test]
fn per_ip_layer_rejects_after_burst() {
let mut cfg = strict_cfg();
cfg.global_burst = u32::MAX;
cfg.global_rate_per_sec = 1e9;
cfg.per_peer_burst = u32::MAX;
cfg.per_peer_rate_per_sec = 1e9;
let lim = DhtRateLimiter::new(&cfg, metrics());
let i = Some(ip(10, 0, 0, 1));
assert_eq!(lim.check(&peer(1), i), Ok(()));
assert_eq!(lim.check(&peer(2), i), Err(DhtRejectLayer::PerIp));
}
#[test]
fn global_layer_rejects_after_burst() {
let mut cfg = strict_cfg();
cfg.per_peer_burst = u32::MAX;
cfg.per_peer_rate_per_sec = 1e9;
cfg.per_ip_burst = u32::MAX;
cfg.per_ip_rate_per_sec = 1e9;
let lim = DhtRateLimiter::new(&cfg, metrics());
assert_eq!(lim.check(&peer(1), Some(ip(10, 0, 0, 1))), Ok(()));
assert_eq!(
lim.check(&peer(2), Some(ip(10, 0, 0, 2))),
Err(DhtRejectLayer::Global)
);
}
#[test]
fn cheapest_first_global_short_circuits_per_peer() {
let lim = DhtRateLimiter::new(&strict_cfg(), metrics());
assert_eq!(lim.check(&peer(1), Some(ip(10, 0, 0, 1))), Ok(()));
assert_eq!(
lim.check(&peer(1), Some(ip(10, 0, 0, 1))),
Err(DhtRejectLayer::Global)
);
}
#[test]
fn relay_only_connection_skips_per_ip_layer() {
let mut cfg = strict_cfg();
cfg.global_burst = u32::MAX;
cfg.global_rate_per_sec = 1e9;
cfg.per_peer_burst = u32::MAX;
cfg.per_peer_rate_per_sec = 1e9;
let lim = DhtRateLimiter::new(&cfg, metrics());
for _ in 0..16 {
assert_eq!(lim.check(&peer(1), None), Ok(()));
}
}
#[test]
fn cheapest_first_global_fires_before_per_ip() {
let mut cfg = strict_cfg();
cfg.global_rate_per_sec = 1e-9;
cfg.per_ip_rate_per_sec = 1e-9;
cfg.per_peer_burst = u32::MAX;
cfg.per_peer_rate_per_sec = 1e9;
let lim = DhtRateLimiter::new(&cfg, metrics());
let i = Some(ip(10, 0, 0, 1));
assert_eq!(lim.check(&peer(1), i), Ok(()));
assert_eq!(lim.check(&peer(2), i), Err(DhtRejectLayer::Global));
}
#[test]
fn global_before_per_ip_attributes_rejection_to_global_counter() {
let mut cfg = strict_cfg();
cfg.global_rate_per_sec = 1e-9;
cfg.per_ip_rate_per_sec = 1e-9;
cfg.per_peer_burst = u32::MAX;
cfg.per_peer_rate_per_sec = 1e9;
let metrics = metrics();
let lim = DhtRateLimiter::new(&cfg, Arc::clone(&metrics));
let i = Some(ip(10, 0, 0, 1));
assert_eq!(lim.check(&peer(1), i), Ok(()));
assert_eq!(lim.check(&peer(2), i), Err(DhtRejectLayer::Global));
let text = metrics.encode().unwrap();
assert!(
text.contains("decdn_dht_rate_limit_rejected_global_total 1"),
"global must be the counted layer when both global+per-IP would \
reject; got:\n{text}"
);
assert!(
text.contains("decdn_dht_rate_limit_rejected_per_ip_total 0"),
"per-IP counter must NOT move — global short-circuits first; \
got:\n{text}"
);
}
fn ip6(segments: [u16; 8]) -> IpAddr {
IpAddr::V6(std::net::Ipv6Addr::new(
segments[0],
segments[1],
segments[2],
segments[3],
segments[4],
segments[5],
segments[6],
segments[7],
))
}
#[test]
fn per_ip_layer_masks_ipv6_to_slash_64() {
let mut cfg = strict_cfg();
cfg.global_burst = u32::MAX;
cfg.global_rate_per_sec = 1e9;
cfg.per_peer_burst = u32::MAX;
cfg.per_peer_rate_per_sec = 1e9;
let lim = DhtRateLimiter::new(&cfg, metrics());
let a = Some(ip6([0x2001, 0xdb8, 0, 0, 0, 0, 0, 1]));
let b = Some(ip6([0x2001, 0xdb8, 0, 0, 0xffff, 0xffff, 0xffff, 0xfffe]));
assert_eq!(lim.check(&peer(1), a), Ok(()));
assert_eq!(lim.check(&peer(2), b), Err(DhtRejectLayer::PerIp));
let c = Some(ip6([0x2001, 0xdb8, 0, 1, 0, 0, 0, 1]));
assert_eq!(lim.check(&peer(3), c), Ok(()));
}
#[test]
fn rejection_increments_layer_metric_in_scrape() {
let metrics = metrics();
let lim = DhtRateLimiter::new(&strict_cfg(), Arc::clone(&metrics));
lim.check(&peer(1), Some(ip(10, 0, 0, 1)))
.expect("first ok");
let _ = lim.check(&peer(1), Some(ip(10, 0, 0, 1)));
let text = metrics.encode().unwrap();
assert!(
text.contains("decdn_dht_rate_limit_rejected_global_total 1"),
"global rejection must appear in /metrics; got:\n{text}"
);
}
#[test]
fn disabled_layer_admits_all() {
let cfg = DhtRateLimitConfig {
per_peer_rate_per_sec: 0.0,
per_peer_burst: 0,
per_ip_rate_per_sec: 0.0,
per_ip_burst: 0,
global_rate_per_sec: 0.0,
global_burst: 0,
max_tracked_per_ip: 0,
max_tracked_per_peer: 0,
};
let lim = DhtRateLimiter::new(&cfg, metrics());
for _ in 0..1000 {
assert_eq!(lim.check(&peer(1), Some(ip(10, 0, 0, 1))), Ok(()));
}
}
#[test]
fn admit_batch_extra_grants_full_budget_when_permissive() {
let lim = DhtRateLimiter::new(&DhtRateLimitConfig::default(), metrics());
let p = peer(1);
let i = Some(ip(10, 0, 0, 1));
assert_eq!(lim.check(&p, i), Ok(()));
assert_eq!(lim.admit_batch_extra(&p, i, 9), 9);
}
#[test]
fn admit_batch_extra_returns_zero_for_zero_extra() {
let lim = DhtRateLimiter::new(&DhtRateLimitConfig::default(), metrics());
assert_eq!(lim.admit_batch_extra(&peer(1), Some(ip(10, 0, 0, 1)), 0), 0);
}
#[test]
fn admit_batch_extra_bounded_by_per_peer_remaining() {
let mut cfg = strict_cfg();
cfg.per_peer_rate_per_sec = 1.0;
cfg.per_peer_burst = 5;
cfg.per_ip_burst = u32::MAX;
cfg.per_ip_rate_per_sec = 1e9;
cfg.global_burst = u32::MAX;
cfg.global_rate_per_sec = 1e9;
let lim = DhtRateLimiter::new(&cfg, metrics());
let p = peer(1);
let i = Some(ip(10, 0, 0, 1));
assert_eq!(lim.check(&p, i), Ok(()));
assert_eq!(lim.admit_batch_extra(&p, i, 10), 4);
assert_eq!(lim.admit_batch_extra(&p, i, 10), 0);
}
#[test]
fn admit_batch_extra_bounded_by_min_across_layers() {
let mut cfg = strict_cfg();
cfg.per_peer_rate_per_sec = 1e9;
cfg.per_peer_burst = u32::MAX;
cfg.per_ip_rate_per_sec = 1.0;
cfg.per_ip_burst = 3;
cfg.global_rate_per_sec = 1e9;
cfg.global_burst = u32::MAX;
let lim = DhtRateLimiter::new(&cfg, metrics());
let p = peer(1);
let i = Some(ip(10, 0, 0, 1));
assert_eq!(lim.check(&p, i), Ok(()));
assert_eq!(lim.admit_batch_extra(&p, i, 8), 2);
}
#[test]
fn admit_batch_extra_does_not_bump_rejection_counters() {
let mut cfg = strict_cfg();
cfg.per_peer_rate_per_sec = 1.0;
cfg.per_peer_burst = 2;
cfg.per_ip_burst = u32::MAX;
cfg.per_ip_rate_per_sec = 1e9;
cfg.global_burst = u32::MAX;
cfg.global_rate_per_sec = 1e9;
let metrics = metrics();
let lim = DhtRateLimiter::new(&cfg, Arc::clone(&metrics));
let p = peer(1);
let i = Some(ip(10, 0, 0, 1));
assert_eq!(lim.check(&p, i), Ok(()));
assert_eq!(lim.admit_batch_extra(&p, i, 5), 1);
let text = metrics.encode().unwrap();
for layer in ["per_peer", "per_ip", "global"] {
assert!(
text.contains(&format!("decdn_dht_rate_limit_rejected_{layer}_total 0")),
"stage-2 partial admit must not bump the {layer} rejection counter:\n{text}"
);
}
}
#[test]
fn layer_label_strings_are_stable() {
assert_eq!(DhtRejectLayer::PerPeer.as_str(), "per_peer");
assert_eq!(DhtRejectLayer::PerIp.as_str(), "per_ip");
assert_eq!(DhtRejectLayer::Global.as_str(), "global");
}
#[test]
fn gc_per_ip_is_noop_when_layer_disabled() {
let mut cfg = strict_cfg();
cfg.per_ip_rate_per_sec = 0.0;
cfg.per_ip_burst = 0;
let lim = DhtRateLimiter::new(&cfg, metrics());
assert!(lim.gc_per_ip().is_none());
assert_eq!(lim.per_ip_tracked(), 0);
}
#[test]
fn gc_per_peer_is_noop_when_layer_disabled() {
let mut cfg = strict_cfg();
cfg.per_peer_rate_per_sec = 0.0;
cfg.per_peer_burst = 0;
let lim = DhtRateLimiter::new(&cfg, metrics());
assert!(lim.gc_per_peer().is_none());
assert_eq!(lim.per_peer_tracked(), 0);
}
#[tokio::test]
async fn gc_per_ip_drops_refilled_buckets() {
let cfg = DhtRateLimitConfig {
per_peer_rate_per_sec: 1e6,
per_peer_burst: u32::MAX,
per_ip_rate_per_sec: 1000.0,
per_ip_burst: 1,
global_rate_per_sec: 1e6,
global_burst: u32::MAX,
max_tracked_per_ip: 0,
max_tracked_per_peer: 0,
};
let lim = DhtRateLimiter::new(&cfg, metrics());
for i in 0..8 {
let _ = lim.check(&peer(1), Some(ip(10, 0, 0, i)));
}
assert_eq!(lim.per_ip_tracked(), 8, "8 distinct IPs populated");
tokio::time::sleep(Duration::from_millis(100)).await;
let (before, after) = lim
.gc_per_ip()
.expect("per-IP layer enabled and CAS uncontended");
assert_eq!(before, 8);
assert_eq!(after, 0, "all buckets refilled and were dropped");
assert_eq!(lim.per_ip_tracked(), 0);
}
#[tokio::test]
async fn gc_per_peer_drops_refilled_buckets() {
let cfg = DhtRateLimitConfig {
per_peer_rate_per_sec: 1000.0,
per_peer_burst: 1,
per_ip_rate_per_sec: 1e6,
per_ip_burst: u32::MAX,
global_rate_per_sec: 1e6,
global_burst: u32::MAX,
max_tracked_per_ip: 0,
max_tracked_per_peer: 0,
};
let lim = DhtRateLimiter::new(&cfg, metrics());
for byte in 0..8u8 {
let _ = lim.check(&peer(byte), Some(ip(10, 0, 0, 1)));
}
assert_eq!(lim.per_peer_tracked(), 8);
tokio::time::sleep(Duration::from_millis(100)).await;
let (before, after) = lim
.gc_per_peer()
.expect("per-peer layer enabled and CAS uncontended");
assert_eq!(before, 8);
assert_eq!(after, 0);
assert_eq!(lim.per_peer_tracked(), 0);
}
#[tokio::test]
async fn lazy_prune_bounds_per_ip_under_flood() {
let cap: usize = 16;
let cfg = DhtRateLimitConfig {
per_peer_rate_per_sec: 1e6,
per_peer_burst: u32::MAX,
per_ip_rate_per_sec: 1000.0,
per_ip_burst: 1,
global_rate_per_sec: 1e6,
global_burst: u32::MAX,
max_tracked_per_ip: cap,
max_tracked_per_peer: 0,
};
let metrics_handle = metrics();
let lim = DhtRateLimiter::new(&cfg, Arc::clone(&metrics_handle));
for i in 0..200u16 {
let a = u8::try_from((i >> 8) & 0xff).unwrap_or(0);
let b = u8::try_from(i & 0xff).unwrap_or(0);
let _ = lim.check(&peer(1), Some(ip(10, 0, a, b)));
tokio::time::sleep(Duration::from_millis(2)).await;
}
let final_size = lim.per_ip_tracked();
let bound = cap.saturating_add(cap / 10).saturating_add(1);
assert!(
final_size <= bound,
"expected per_ip_tracked <= {bound}, got {final_size}"
);
let text = metrics_handle.encode().unwrap();
assert!(
text.contains("decdn_dht_rate_limit_prune_sweeps_per_ip_total"),
"per-IP prune-sweep counter missing from scrape:\n{text}"
);
assert!(
!text.contains("decdn_dht_rate_limit_prune_sweeps_per_ip_total 0"),
"per-IP prune-sweep counter must have fired at least once during the flood:\n{text}"
);
}
#[test]
fn unbounded_when_cap_is_zero_per_ip() {
let cfg = DhtRateLimitConfig {
per_peer_rate_per_sec: 1e6,
per_peer_burst: u32::MAX,
per_ip_rate_per_sec: 1e6,
per_ip_burst: u32::MAX,
global_rate_per_sec: 1e6,
global_burst: u32::MAX,
max_tracked_per_ip: 0,
max_tracked_per_peer: 0,
};
let lim = DhtRateLimiter::new(&cfg, metrics());
for i in 0..1000u16 {
let a = u8::try_from((i >> 8) & 0xff).unwrap_or(0);
let b = u8::try_from(i & 0xff).unwrap_or(0);
assert!(lim.check(&peer(1), Some(ip(10, 0, a, b))).is_ok());
}
assert_eq!(lim.per_ip_tracked(), 1000);
}
#[test]
fn unbounded_when_cap_is_zero_per_peer() {
let cfg = DhtRateLimitConfig {
per_peer_rate_per_sec: 1e6,
per_peer_burst: u32::MAX,
per_ip_rate_per_sec: 1e6,
per_ip_burst: u32::MAX,
global_rate_per_sec: 1e6,
global_burst: u32::MAX,
max_tracked_per_ip: 0,
max_tracked_per_peer: 0,
};
let lim = DhtRateLimiter::new(&cfg, metrics());
for i in 0..1000u16 {
let mut id = [0u8; 32];
id[0] = u8::try_from((i >> 8) & 0xff).unwrap_or(0);
id[1] = u8::try_from(i & 0xff).unwrap_or(0);
assert!(
lim.check(&NodeId::from_bytes(id), Some(ip(10, 0, 0, 1)))
.is_ok()
);
}
assert_eq!(lim.per_peer_tracked(), 1000);
}
#[tokio::test]
async fn lazy_prune_bounds_per_peer_under_flood() {
let cap: usize = 16;
let cfg = DhtRateLimitConfig {
per_peer_rate_per_sec: 1000.0,
per_peer_burst: 1,
per_ip_rate_per_sec: 1e6,
per_ip_burst: u32::MAX,
global_rate_per_sec: 1e6,
global_burst: u32::MAX,
max_tracked_per_ip: 0,
max_tracked_per_peer: cap,
};
let metrics_handle = metrics();
let lim = DhtRateLimiter::new(&cfg, Arc::clone(&metrics_handle));
for i in 0..200u16 {
let mut id = [0u8; 32];
id[0] = u8::try_from((i >> 8) & 0xff).unwrap_or(0);
id[1] = u8::try_from(i & 0xff).unwrap_or(0);
let _ = lim.check(&NodeId::from_bytes(id), Some(ip(10, 0, 0, 1)));
tokio::time::sleep(Duration::from_millis(2)).await;
}
let final_size = lim.per_peer_tracked();
let bound = cap.saturating_add(cap / 10).saturating_add(1);
assert!(
final_size <= bound,
"expected per_peer_tracked <= {bound}, got {final_size}"
);
let text = metrics_handle.encode().unwrap();
assert!(
text.contains("decdn_dht_rate_limit_prune_sweeps_per_peer_total"),
"per-peer prune-sweep counter missing from scrape:\n{text}"
);
assert!(
!text.contains("decdn_dht_rate_limit_prune_sweeps_per_peer_total 0"),
"per-peer prune-sweep counter must have fired at least once during the flood:\n{text}"
);
}
#[tokio::test]
async fn gauge_and_sweep_counter_emit_in_scrape() {
let metrics_handle = metrics();
let cfg = DhtRateLimitConfig {
per_peer_rate_per_sec: 1000.0,
per_peer_burst: 1,
per_ip_rate_per_sec: 1000.0,
per_ip_burst: 1,
global_rate_per_sec: 1e6,
global_burst: u32::MAX,
max_tracked_per_ip: 0,
max_tracked_per_peer: 0,
};
let lim = DhtRateLimiter::new(&cfg, Arc::clone(&metrics_handle));
for i in 0..4u8 {
let mut id = [0u8; 32];
id[0] = i;
let _ = lim.check(&NodeId::from_bytes(id), Some(ip(10, 0, 0, i)));
}
tokio::time::sleep(Duration::from_millis(100)).await;
assert!(lim.gc_per_ip().is_some());
assert!(lim.gc_per_peer().is_some());
let text = metrics_handle.encode().unwrap();
assert!(
text.contains("decdn_dht_rate_limit_prune_sweeps_per_ip_total 1"),
"per-IP sweep counter missing from scrape:\n{text}"
);
assert!(
text.contains("decdn_dht_rate_limit_prune_sweeps_per_peer_total 1"),
"per-peer sweep counter missing from scrape:\n{text}"
);
assert!(
text.contains("decdn_dht_rate_limit_tracked_per_ip 0"),
"per-IP tracked gauge missing from scrape:\n{text}"
);
assert!(
text.contains("decdn_dht_rate_limit_tracked_per_peer 0"),
"per-peer tracked gauge missing from scrape:\n{text}"
);
}