use std::collections::HashMap;
use std::net::IpAddr;
use std::sync::RwLock;
use std::time::{Duration, Instant};
use axum::{
extract::{ConnectInfo, Request, State},
http::StatusCode,
middleware::Next,
response::{IntoResponse, Response},
};
#[derive(Debug, Clone)]
pub struct RateLimitConfig {
pub max_requests: u32,
pub window: Duration,
pub enabled: bool,
pub max_tracked_ips: usize,
}
impl Default for RateLimitConfig {
fn default() -> Self {
Self {
max_requests: 100,
window: Duration::from_secs(60),
enabled: true,
max_tracked_ips: 10000,
}
}
}
impl RateLimitConfig {
pub fn disabled() -> Self {
Self {
enabled: false,
..Default::default()
}
}
pub fn strict() -> Self {
Self {
max_requests: 10,
window: Duration::from_secs(60),
..Default::default()
}
}
pub fn relaxed() -> Self {
Self {
max_requests: 1000,
window: Duration::from_secs(60),
..Default::default()
}
}
pub fn custom(max_requests: u32, window_secs: u64) -> Self {
Self {
max_requests,
window: Duration::from_secs(window_secs),
..Default::default()
}
}
}
#[derive(Debug, Clone)]
struct RequestRecord {
timestamps: Vec<Instant>,
}
impl RequestRecord {
fn new() -> Self {
Self {
timestamps: Vec::new(),
}
}
fn clean_and_count(&mut self, window: Duration) -> u32 {
let now = Instant::now();
let cutoff = now - window;
self.timestamps.retain(|&t| t > cutoff);
self.timestamps.len() as u32
}
fn record(&mut self) -> Instant {
let now = Instant::now();
self.timestamps.push(now);
now
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum RateLimitDecision {
Unlimited,
Allowed {
remaining: u32,
charge: RateLimitCharge,
},
Limited { retry_after: Duration },
}
impl RateLimitDecision {
pub fn remaining(&self) -> Option<u32> {
match self {
Self::Allowed { remaining, .. } => Some(*remaining),
_ => None,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct RateLimitCharge(Instant);
#[derive(Debug)]
pub struct RateLimiter {
records: RwLock<HashMap<IpAddr, RequestRecord>>,
config: RateLimitConfig,
last_cleanup: RwLock<Instant>,
}
impl RateLimiter {
pub fn new(config: RateLimitConfig) -> Self {
Self {
records: RwLock::new(HashMap::new()),
config,
last_cleanup: RwLock::new(Instant::now()),
}
}
pub fn disabled() -> Self {
Self::new(RateLimitConfig::disabled())
}
pub fn is_enabled(&self) -> bool {
self.config.enabled
}
pub fn check(&self, ip: IpAddr) -> RateLimitDecision {
if !self.config.enabled {
return RateLimitDecision::Unlimited;
}
self.maybe_cleanup();
let mut records = match self.records.write() {
Ok(r) => r,
Err(_) => return RateLimitDecision::Unlimited,
};
let record = records.entry(ip).or_insert_with(RequestRecord::new);
let current_count = record.clean_and_count(self.config.window);
if current_count >= self.config.max_requests {
let oldest = record.timestamps.first().copied();
let retry_after = oldest
.map(|t| self.config.window.saturating_sub(t.elapsed()))
.unwrap_or(self.config.window);
return RateLimitDecision::Limited { retry_after };
}
let charge = RateLimitCharge(record.record());
let remaining = self.config.max_requests - current_count - 1;
RateLimitDecision::Allowed { remaining, charge }
}
pub fn refund(&self, ip: IpAddr, charge: RateLimitCharge) {
if !self.config.enabled {
return;
}
let Ok(mut records) = self.records.write() else {
return;
};
if let Some(record) = records.get_mut(&ip) {
if let Some(at) = record.timestamps.iter().position(|t| *t == charge.0) {
record.timestamps.remove(at);
}
}
}
fn maybe_cleanup(&self) {
let should_cleanup = self
.last_cleanup
.read()
.map(|t| t.elapsed() > self.config.window * 2)
.unwrap_or(false);
if !should_cleanup {
return;
}
if let Ok(mut last) = self.last_cleanup.write() {
if last.elapsed() <= self.config.window * 2 {
return;
}
*last = Instant::now();
if let Ok(mut records) = self.records.write() {
let cutoff = Instant::now() - self.config.window * 2;
records.retain(|_, record| {
record
.timestamps
.last()
.map(|&t| t > cutoff)
.unwrap_or(false)
});
if records.len() > self.config.max_tracked_ips {
let mut entries: Vec<_> = records
.iter()
.map(|(ip, r)| (*ip, r.timestamps.last().copied()))
.collect();
entries.sort_by_key(|(_, t)| *t);
let to_remove = records.len() - self.config.max_tracked_ips;
for (ip, _) in entries.into_iter().take(to_remove) {
records.remove(&ip);
}
}
}
}
}
pub fn stats(&self) -> RateLimitStats {
let tracked_ips = self.records.read().map(|r| r.len()).unwrap_or(0);
RateLimitStats {
tracked_ips,
max_requests: self.config.max_requests,
window_secs: self.config.window.as_secs(),
enabled: self.config.enabled,
}
}
}
impl Default for RateLimiter {
fn default() -> Self {
Self::new(RateLimitConfig::default())
}
}
#[derive(Debug, Clone)]
pub struct RateLimitStats {
pub tracked_ips: usize,
pub max_requests: u32,
pub window_secs: u64,
pub enabled: bool,
}
pub async fn rate_limit_middleware(
State(limiter): State<std::sync::Arc<RateLimiter>>,
ConnectInfo(addr): ConnectInfo<std::net::SocketAddr>,
mut request: Request,
next: Next,
) -> Response {
if request.uri().path() == "/health" {
return next.run(request).await;
}
match limiter.check(addr.ip()) {
RateLimitDecision::Unlimited => next.run(request).await,
RateLimitDecision::Allowed { remaining, charge } => {
request.extensions_mut().insert(charge);
let mut response = next.run(request).await;
let refused_elsewhere = response.status() == StatusCode::TOO_MANY_REQUESTS;
let headers = response.headers_mut();
if !refused_elsewhere
&& !headers.contains_key("X-RateLimit-Limit")
&& !headers.contains_key("X-RateLimit-Remaining")
{
headers.insert(
"X-RateLimit-Limit",
limiter.config.max_requests.to_string().parse().unwrap(),
);
headers.insert(
"X-RateLimit-Remaining",
remaining.to_string().parse().unwrap(),
);
}
response
}
RateLimitDecision::Limited { retry_after } => {
let mut response = (
StatusCode::TOO_MANY_REQUESTS,
"Rate limit exceeded. Please try again later.",
)
.into_response();
response.headers_mut().insert(
"Retry-After",
retry_after.as_secs().to_string().parse().unwrap(),
);
response.headers_mut().insert(
"X-RateLimit-Limit",
limiter.config.max_requests.to_string().parse().unwrap(),
);
response
.headers_mut()
.insert("X-RateLimit-Remaining", "0".parse().unwrap());
response
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::net::{Ipv4Addr, Ipv6Addr};
#[test]
fn test_rate_limit_config_default() {
let config = RateLimitConfig::default();
assert_eq!(config.max_requests, 100);
assert_eq!(config.window, Duration::from_secs(60));
assert!(config.enabled);
}
#[test]
fn test_rate_limit_config_disabled() {
let config = RateLimitConfig::disabled();
assert!(!config.enabled);
}
#[test]
fn test_rate_limit_config_custom() {
let config = RateLimitConfig::custom(50, 30);
assert_eq!(config.max_requests, 50);
assert_eq!(config.window, Duration::from_secs(30));
}
fn allowed(decision: RateLimitDecision) -> bool {
matches!(decision, RateLimitDecision::Allowed { .. })
}
fn limited(decision: RateLimitDecision) -> bool {
matches!(decision, RateLimitDecision::Limited { .. })
}
#[test]
fn test_rate_limiter_allows_requests() {
let limiter = RateLimiter::new(RateLimitConfig::custom(5, 60));
let ip = IpAddr::V4(Ipv4Addr::new(127, 0, 0, 1));
for i in 0..5 {
let result = limiter.check(ip);
assert!(allowed(result), "Request {} should be allowed", i);
}
}
#[test]
fn test_rate_limiter_blocks_excess() {
let limiter = RateLimiter::new(RateLimitConfig::custom(3, 60));
let ip = IpAddr::V4(Ipv4Addr::new(192, 168, 1, 1));
assert!(allowed(limiter.check(ip)));
assert!(allowed(limiter.check(ip)));
assert!(allowed(limiter.check(ip)));
assert!(limited(limiter.check(ip)));
}
#[test]
fn test_rate_limiter_different_ips() {
let limiter = RateLimiter::new(RateLimitConfig::custom(2, 60));
let ip1 = IpAddr::V4(Ipv4Addr::new(10, 0, 0, 1));
let ip2 = IpAddr::V4(Ipv4Addr::new(10, 0, 0, 2));
assert!(allowed(limiter.check(ip1)));
assert!(allowed(limiter.check(ip1)));
assert!(limited(limiter.check(ip1)));
assert!(allowed(limiter.check(ip2))); assert!(allowed(limiter.check(ip2)));
assert!(limited(limiter.check(ip2))); }
#[test]
fn test_rate_limiter_disabled() {
let limiter = RateLimiter::disabled();
let ip = IpAddr::V4(Ipv4Addr::new(1, 1, 1, 1));
for _ in 0..100 {
assert_eq!(limiter.check(ip), RateLimitDecision::Unlimited);
}
}
fn charge_of(decision: RateLimitDecision) -> RateLimitCharge {
match decision {
RateLimitDecision::Allowed { charge, .. } => charge,
other => panic!("expected an allowed decision, got {other:?}"),
}
}
#[test]
fn a_refund_returns_the_slot_it_was_charged() {
let limiter = RateLimiter::new(RateLimitConfig::custom(2, 60));
let ip = IpAddr::V4(Ipv4Addr::new(203, 0, 113, 7));
let first = limiter.check(ip);
assert_eq!(first.remaining(), Some(1));
limiter.refund(ip, charge_of(first));
assert_eq!(
limiter.check(ip).remaining(),
Some(1),
"the refunded slot is available again"
);
assert!(allowed(limiter.check(ip)));
assert!(limited(limiter.check(ip)));
}
#[test]
fn a_refund_of_an_expired_charge_takes_nothing_from_anyone_else() {
let limiter = RateLimiter::new(RateLimitConfig::custom(2, 1));
let ip = IpAddr::V4(Ipv4Addr::new(203, 0, 113, 9));
let stale = charge_of(limiter.check(ip));
std::thread::sleep(Duration::from_millis(1100));
assert_eq!(limiter.check(ip).remaining(), Some(1));
limiter.refund(ip, stale);
assert!(allowed(limiter.check(ip)), "the second slot is still free");
assert!(
limited(limiter.check(ip)),
"an expired charge must not have bought a third"
);
}
#[test]
fn a_refund_without_a_charge_creates_nothing() {
let limiter = RateLimiter::new(RateLimitConfig::custom(1, 60));
let unseen = IpAddr::V4(Ipv4Addr::new(203, 0, 113, 8));
let elsewhere = charge_of(
RateLimiter::new(RateLimitConfig::custom(9, 60))
.check(IpAddr::V4(Ipv4Addr::new(198, 51, 100, 1))),
);
for _ in 0..5 {
limiter.refund(unseen, elsewhere);
}
assert!(allowed(limiter.check(unseen)), "one request is the budget");
limiter.refund(unseen, elsewhere);
assert!(
limited(limiter.check(unseen)),
"a charge this limiter never issued banked nothing"
);
}
#[test]
fn test_rate_limiter_ipv6() {
let limiter = RateLimiter::new(RateLimitConfig::custom(2, 60));
let ip = IpAddr::V6(Ipv6Addr::new(0x2001, 0xdb8, 0, 0, 0, 0, 0, 1));
assert!(allowed(limiter.check(ip)));
assert!(allowed(limiter.check(ip)));
assert!(limited(limiter.check(ip)));
}
#[test]
fn test_rate_limiter_stats() {
let limiter = RateLimiter::new(RateLimitConfig::custom(10, 30));
let ip = IpAddr::V4(Ipv4Addr::new(8, 8, 8, 8));
limiter.check(ip);
let stats = limiter.stats();
assert_eq!(stats.tracked_ips, 1);
assert_eq!(stats.max_requests, 10);
assert_eq!(stats.window_secs, 30);
assert!(stats.enabled);
}
#[test]
fn test_rate_limiter_remaining_count() {
let limiter = RateLimiter::new(RateLimitConfig::custom(5, 60));
let ip = IpAddr::V4(Ipv4Addr::new(172, 16, 0, 1));
for expected in [4, 3, 2, 1, 0] {
assert_eq!(limiter.check(ip).remaining(), Some(expected));
}
assert!(limited(limiter.check(ip))); }
}