use crate::config::RateLimitDefaults;
use crate::ratelimit::TokenBucket;
use dashmap::DashMap;
use parking_lot::Mutex;
use sha2::{Digest, Sha256};
use std::net::{IpAddr, Ipv6Addr};
use std::sync::Arc;
use std::time::{Duration, Instant};
use tokio_util::sync::CancellationToken;
use tracing::{debug, info, warn};
use uuid::Uuid;
#[derive(Clone, Debug, Hash, PartialEq, Eq)]
pub enum RateLimitKey {
Ip(IpAddr),
Header(String),
Route(uuid::Uuid),
Global,
}
impl RateLimitKey {
pub fn client_ip(ip: IpAddr) -> Self {
match ip.to_canonical() {
IpAddr::V4(v4) => RateLimitKey::Ip(IpAddr::V4(v4)),
IpAddr::V6(v6) => {
let s = v6.segments();
RateLimitKey::Ip(IpAddr::V6(Ipv6Addr::new(
s[0], s[1], s[2], s[3], 0, 0, 0, 0,
)))
}
}
}
pub fn header_value(value: &[u8]) -> Self {
RateLimitKey::Header(hex::encode(Sha256::digest(value)))
}
}
pub fn per_second(requests: u32, window_secs: u32) -> f64 {
f64::from(requests) / f64::from(window_secs.max(1))
}
type BucketId = (Uuid, RateLimitKey);
pub struct RateLimiter {
buckets: DashMap<BucketId, TokenBucket>,
defaults: RateLimitDefaults,
max_keys: usize,
last_full_sweep: Mutex<Option<Instant>>,
}
const DEFAULT_SCOPE: Uuid = Uuid::nil();
impl RateLimiter {
pub fn new(defaults: RateLimitDefaults) -> Self {
Self {
buckets: DashMap::new(),
max_keys: defaults.max_tracked_keys.max(1),
defaults,
last_full_sweep: Mutex::new(None),
}
}
pub fn check(&self, key: RateLimitKey) -> bool {
let rate = per_second(self.defaults.requests, self.defaults.window_secs);
self.check_with_config(DEFAULT_SCOPE, key, rate, self.defaults.burst)
}
pub fn check_with_config(
&self,
scope: Uuid,
key: RateLimitKey,
rate_per_sec: f64,
burst: u32,
) -> bool {
let id = (scope, key);
if let Some(bucket) = self.buckets.get(&id) {
return bucket.try_acquire();
}
if self.buckets.len() >= self.max_keys && !self.make_room() {
warn!(
max_keys = self.max_keys,
"Rate limiter is tracking as many clients as it may; refusing a new one"
);
return false;
}
self.buckets
.entry(id)
.or_insert_with(|| TokenBucket::new(u64::from(burst), rate_per_sec))
.try_acquire()
}
pub fn remaining(&self, key: &RateLimitKey) -> Option<u64> {
self.buckets
.get(&(DEFAULT_SCOPE, key.clone()))
.map(|bucket| bucket.available())
}
pub fn tracked_keys(&self) -> usize {
self.buckets.len()
}
pub async fn start_cleanup(self: Arc<Self>, cancel_token: CancellationToken) {
let cleanup_interval = Duration::from_secs(60);
info!("Rate limiter cleanup task started");
loop {
tokio::select! {
_ = cancel_token.cancelled() => {
info!("Rate limiter cleanup task stopped");
break;
}
_ = tokio::time::sleep(cleanup_interval) => {
self.cleanup_expired();
}
}
}
}
fn make_room(&self) -> bool {
{
let mut last = self.last_full_sweep.lock();
let now = Instant::now();
if last.is_some_and(|at| now.duration_since(at) < Duration::from_secs(1)) {
return self.buckets.len() < self.max_keys;
}
*last = Some(now);
}
self.cleanup_expired();
self.buckets.len() < self.max_keys
}
fn cleanup_expired(&self) {
let before = self.buckets.len();
self.buckets.retain(|_, bucket| !bucket.is_full());
let removed = before.saturating_sub(self.buckets.len());
if removed > 0 {
debug!("Rate limiter cleanup: removed {} refilled entries", removed);
}
}
}
impl Default for RateLimiter {
fn default() -> Self {
Self::new(RateLimitDefaults::default())
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::net::Ipv4Addr;
fn defaults(requests: u32, window_secs: u32, burst: u32) -> RateLimitDefaults {
RateLimitDefaults {
requests,
window_secs,
burst,
..RateLimitDefaults::default()
}
}
#[test]
fn test_rate_limiter_by_ip() {
let limiter = RateLimiter::new(defaults(600, 60, 5));
let ip1 = RateLimitKey::Ip(IpAddr::V4(Ipv4Addr::new(192, 168, 1, 1)));
let ip2 = RateLimitKey::Ip(IpAddr::V4(Ipv4Addr::new(192, 168, 1, 2)));
for _ in 0..5 {
assert!(limiter.check(ip1.clone()));
assert!(limiter.check(ip2.clone()));
}
assert!(!limiter.check(ip1.clone()));
assert!(!limiter.check(ip2.clone()));
}
#[test]
fn test_rate_limiter_remaining() {
let limiter = RateLimiter::new(defaults(600, 60, 10));
let key = RateLimitKey::Global;
assert!(limiter.check(key.clone()));
let remaining = limiter.remaining(&key).unwrap();
assert!(remaining < 10);
}
#[test]
fn the_rate_is_a_fraction() {
assert_eq!(per_second(3, 60), 0.05);
assert_eq!(per_second(600, 60), 10.0);
assert_eq!(per_second(5, 0), 5.0);
}
#[test]
fn scopes_do_not_share_a_bucket() {
let limiter = RateLimiter::default();
let ip = RateLimitKey::client_ip("10.0.0.1".parse().unwrap());
let strict = Uuid::new_v4();
let generous = Uuid::new_v4();
assert!(limiter.check_with_config(strict, ip.clone(), 0.01, 1));
assert!(!limiter.check_with_config(strict, ip.clone(), 0.01, 1));
for _ in 0..100 {
assert!(limiter.check_with_config(generous, ip.clone(), 0.01, 100));
}
assert!(!limiter.check_with_config(generous, ip.clone(), 0.01, 100));
}
#[test]
fn an_ipv6_client_is_keyed_by_its_slash_64() {
let a = RateLimitKey::client_ip("2001:db8:1:2:aaaa::1".parse().unwrap());
let b = RateLimitKey::client_ip("2001:db8:1:2:ffff:1:2:3".parse().unwrap());
let other = RateLimitKey::client_ip("2001:db8:1:3::1".parse().unwrap());
assert_eq!(a, b);
assert_ne!(a, other);
}
#[test]
fn a_mapped_ipv4_client_is_keyed_as_ipv4() {
let mapped = RateLimitKey::client_ip("::ffff:192.0.2.1".parse().unwrap());
let plain = RateLimitKey::client_ip("192.0.2.1".parse().unwrap());
let other = RateLimitKey::client_ip("::ffff:192.0.2.2".parse().unwrap());
assert_eq!(mapped, plain);
assert_ne!(mapped, other);
}
#[test]
fn a_header_value_is_stored_as_a_digest() {
let key = RateLimitKey::header_value(&[b'k'; 8192]);
match key {
RateLimitKey::Header(digest) => assert_eq!(digest.len(), 64),
other => panic!("expected a header key, got {other:?}"),
}
assert_eq!(
RateLimitKey::header_value(b"a"),
RateLimitKey::header_value(b"a")
);
assert_ne!(
RateLimitKey::header_value(b"a"),
RateLimitKey::header_value(b"b")
);
}
#[test]
fn the_table_is_capped() {
let limiter = RateLimiter::new(RateLimitDefaults {
max_tracked_keys: 3,
..RateLimitDefaults::default()
});
let scope = Uuid::new_v4();
let key = |n: u8| RateLimitKey::Ip(IpAddr::V4(Ipv4Addr::new(10, 0, 0, n)));
for n in 0..3 {
assert!(limiter.check_with_config(scope, key(n), 0.0001, 1));
}
assert_eq!(limiter.tracked_keys(), 3);
assert!(!limiter.check_with_config(scope, key(3), 0.0001, 1));
assert_eq!(limiter.tracked_keys(), 3);
assert!(!limiter.check_with_config(scope, key(0), 0.0001, 1));
}
#[test]
fn a_full_table_makes_room_by_dropping_refilled_buckets() {
let limiter = RateLimiter::new(RateLimitDefaults {
max_tracked_keys: 2,
..RateLimitDefaults::default()
});
let scope = Uuid::new_v4();
let key = |n: u8| RateLimitKey::Ip(IpAddr::V4(Ipv4Addr::new(10, 0, 0, n)));
assert!(limiter.check_with_config(scope, key(0), 1e9, 2));
assert!(limiter.check_with_config(scope, key(1), 1e9, 2));
std::thread::sleep(Duration::from_millis(5));
assert!(limiter.check_with_config(scope, key(2), 1e9, 2));
assert!(limiter.tracked_keys() <= 2);
}
#[test]
fn cleanup_forgets_only_refilled_buckets() {
let limiter = RateLimiter::default();
let scope = Uuid::new_v4();
let spent = RateLimitKey::Ip(IpAddr::V4(Ipv4Addr::new(10, 0, 0, 1)));
let refilled = RateLimitKey::Ip(IpAddr::V4(Ipv4Addr::new(10, 0, 0, 2)));
assert!(limiter.check_with_config(scope, spent.clone(), 0.0001, 1));
assert!(limiter.check_with_config(scope, refilled, 1e9, 1));
std::thread::sleep(Duration::from_millis(5));
limiter.cleanup_expired();
assert_eq!(limiter.tracked_keys(), 1);
assert!(!limiter.check_with_config(scope, spent, 0.0001, 1));
}
}