use std::collections::HashMap;
use std::time::Instant;
use dig_rpc_protocol::Tier;
use parking_lot::Mutex;
pub type PeerKey = Vec<u8>;
#[derive(Debug, Clone, Copy)]
pub struct BucketSpec {
pub fill_per_sec: f64,
pub capacity: f64,
}
#[derive(Debug, Clone)]
pub struct RateLimitConfig {
pub buckets: HashMap<Tier, BucketSpec>,
}
impl RateLimitConfig {
pub fn defaults() -> Self {
let mut buckets = HashMap::new();
buckets.insert(
Tier::PublicRead,
BucketSpec {
fill_per_sec: 50.0,
capacity: 100.0,
},
);
buckets.insert(
Tier::Peer,
BucketSpec {
fill_per_sec: 20.0,
capacity: 40.0,
},
);
buckets.insert(
Tier::Control,
BucketSpec {
fill_per_sec: 5.0,
capacity: 10.0,
},
);
Self { buckets }
}
}
impl Default for RateLimitConfig {
fn default() -> Self {
Self::defaults()
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum RateLimitOutcome {
Allow,
Deny {
retry_after_secs: u64,
},
}
#[derive(Debug, Clone)]
pub struct RateLimitState {
inner: std::sync::Arc<Mutex<HashMap<(PeerKey, Tier), Bucket>>>,
config: std::sync::Arc<RateLimitConfig>,
}
#[derive(Debug)]
struct Bucket {
tokens: f64,
last_refill: Instant,
}
impl RateLimitState {
pub fn new(config: RateLimitConfig) -> Self {
Self {
inner: std::sync::Arc::new(Mutex::new(HashMap::new())),
config: std::sync::Arc::new(config),
}
}
pub fn check(&self, peer: &PeerKey, tier: Tier) -> RateLimitOutcome {
let Some(spec) = self.config.buckets.get(&tier).copied() else {
tracing::warn!(?tier, "rate tier not configured; allowing");
return RateLimitOutcome::Allow;
};
let mut g = self.inner.lock();
let now = Instant::now();
let b = g.entry((peer.clone(), tier)).or_insert(Bucket {
tokens: spec.capacity,
last_refill: now,
});
let elapsed = now.duration_since(b.last_refill).as_secs_f64();
b.tokens = (b.tokens + spec.fill_per_sec * elapsed).min(spec.capacity);
b.last_refill = now;
if b.tokens >= 1.0 {
b.tokens -= 1.0;
RateLimitOutcome::Allow
} else {
let deficit = 1.0 - b.tokens;
let wait_s = (deficit / spec.fill_per_sec).ceil() as u64;
RateLimitOutcome::Deny {
retry_after_secs: wait_s.max(1),
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn first_request_allowed() {
let s = RateLimitState::new(RateLimitConfig::defaults());
assert_eq!(
s.check(&vec![0; 32], Tier::PublicRead),
RateLimitOutcome::Allow
);
}
#[test]
fn exhaust_bucket_denies() {
let mut buckets = HashMap::new();
buckets.insert(
Tier::Control,
BucketSpec {
fill_per_sec: 1.0,
capacity: 3.0,
},
);
let s = RateLimitState::new(RateLimitConfig { buckets });
for _ in 0..3 {
assert_eq!(
s.check(&vec![0; 32], Tier::Control),
RateLimitOutcome::Allow
);
}
match s.check(&vec![0; 32], Tier::Control) {
RateLimitOutcome::Deny { retry_after_secs } => assert!(retry_after_secs >= 1),
_ => panic!("expected Deny"),
}
}
#[test]
fn buckets_are_per_peer() {
let mut buckets = HashMap::new();
buckets.insert(
Tier::Peer,
BucketSpec {
fill_per_sec: 1.0,
capacity: 2.0,
},
);
let s = RateLimitState::new(RateLimitConfig { buckets });
let a = vec![0xAA; 32];
let b = vec![0xBB; 32];
for _ in 0..2 {
assert_eq!(s.check(&a, Tier::Peer), RateLimitOutcome::Allow);
}
assert!(matches!(
s.check(&a, Tier::Peer),
RateLimitOutcome::Deny { .. }
));
assert_eq!(s.check(&b, Tier::Peer), RateLimitOutcome::Allow);
}
#[test]
fn buckets_are_per_tier() {
let mut buckets = HashMap::new();
buckets.insert(
Tier::Control,
BucketSpec {
fill_per_sec: 1.0,
capacity: 1.0,
},
);
buckets.insert(
Tier::PublicRead,
BucketSpec {
fill_per_sec: 1.0,
capacity: 1.0,
},
);
let s = RateLimitState::new(RateLimitConfig { buckets });
let p = vec![1; 32];
assert_eq!(s.check(&p, Tier::Control), RateLimitOutcome::Allow);
assert!(matches!(
s.check(&p, Tier::Control),
RateLimitOutcome::Deny { .. }
));
assert_eq!(s.check(&p, Tier::PublicRead), RateLimitOutcome::Allow);
}
#[test]
fn unconfigured_tier_allows() {
let s = RateLimitState::new(RateLimitConfig {
buckets: HashMap::new(),
});
assert_eq!(
s.check(&vec![0; 32], Tier::PublicRead),
RateLimitOutcome::Allow
);
}
}