use std::collections::HashMap;
use std::sync::Mutex;
use std::time::{Duration, Instant};
use super::{Next, Request, Response, error};
const CAPACITY: usize = 10_000;
#[derive(Debug)]
pub struct Throttle {
max: u32,
window: Duration,
trust_forwarded: bool,
capacity: usize,
hits: Mutex<HashMap<String, (Instant, u32)>>,
#[cfg(feature = "redis")]
shared: Option<(crate::redis::Redis, String)>,
}
impl Throttle {
pub fn new(max: u32, window: Duration) -> Self {
Self {
max,
window,
trust_forwarded: false,
capacity: CAPACITY,
hits: Mutex::default(),
#[cfg(feature = "redis")]
shared: None,
}
}
pub fn per_minute(max: u32) -> Self {
Self::new(max, Duration::from_secs(60))
}
pub fn from_config(config: &crate::config::Config, key: &str, max: u32) -> Self {
Self::per_minute(config.get_or(key, max))
.trust_forwarded(config.get_or("TRUST_PROXY", false))
}
#[must_use]
pub fn trust_forwarded(mut self, trust: bool) -> Self {
self.trust_forwarded = trust;
self
}
#[must_use]
pub fn capacity(mut self, clients: usize) -> Self {
self.capacity = clients.max(1);
self
}
#[cfg(feature = "redis")]
#[must_use]
pub fn shared(mut self, redis: crate::redis::Redis, prefix: &str) -> Self {
self.shared = Some((redis, prefix.to_owned()));
self
}
pub fn hit(&self, key: &str) -> Result<(), Duration> {
#[cfg(feature = "redis")]
if let Some((redis, prefix)) = &self.shared
&& let Ok((count, left)) = redis.hit(&format!("{prefix}{key}"), self.window)
{
return if count > i64::from(self.max) {
Err(left)
} else {
Ok(())
};
}
let now = Instant::now();
let mut hits = self
.hits
.lock()
.unwrap_or_else(|poisoned| poisoned.into_inner());
if hits.len() >= self.capacity && !hits.contains_key(key) {
hits.retain(|_, (start, _)| now.duration_since(*start) < self.window);
if hits.len() >= self.capacity
&& let Some(oldest) = hits
.iter()
.min_by_key(|(_, (start, _))| *start)
.map(|(k, _)| k.clone())
{
hits.remove(&oldest);
}
}
let entry = hits.entry(key.to_owned()).or_insert((now, 0));
if now.duration_since(entry.0) >= self.window {
*entry = (now, 0);
}
entry.1 += 1;
if entry.1 > self.max {
Err(self.window - now.duration_since(entry.0))
} else {
Ok(())
}
}
pub(super) fn client(&self, request: &Request) -> String {
let forwarded = self
.trust_forwarded
.then(|| request.header("x-forwarded-for"))
.flatten()
.and_then(|list| list.rsplit(',').next())
.map(str::trim);
match (forwarded, request.peer) {
(Some(address), _) => address.to_owned(),
(None, Some(peer)) => peer.to_string(),
(None, None) => "unknown".to_owned(),
}
}
pub fn middleware(self) -> impl Fn(&Request, Next) -> Response + Send + Sync + 'static {
move |request, next| match self.hit(&self.client(request)) {
Ok(()) => next(request),
Err(wait) => {
let seconds = wait.as_secs() + u64::from(wait.subsec_nanos() > 0);
error(429).with_header("Retry-After", &seconds.max(1).to_string())
}
}
}
}
pub fn throttle(
max: u32,
window: Duration,
) -> impl Fn(&Request, Next) -> Response + Send + Sync + 'static {
Throttle::new(max, window).middleware()
}
#[cfg(test)]
mod tests {
use super::*;
use crate::web::Router;
#[cfg(feature = "redis")]
#[test]
fn shared_counts_in_redis_and_falls_back_to_its_own_count() {
use std::io::{BufRead, BufReader, Write};
let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
let address = listener.local_addr().unwrap();
let server = std::thread::spawn(move || {
let (stream, _) = listener.accept().unwrap();
let mut reader = BufReader::new(stream.try_clone().unwrap());
let mut writer = stream;
for count in 1..=3 {
for _ in 0..11 {
reader.read_line(&mut String::new()).unwrap();
}
write!(writer, "*2\r\n:{count}\r\n:30000\r\n").unwrap();
}
});
let redis = crate::redis::Redis::connect(&format!("redis://{address}")).unwrap();
let limiter = Throttle::new(2, Duration::from_secs(60)).shared(redis, "throttle:");
assert!(limiter.hit("a").is_ok());
assert!(limiter.hit("a").is_ok());
assert_eq!(limiter.hit("a"), Err(Duration::from_secs(30)));
server.join().unwrap();
assert!(limiter.hit("a").is_ok());
assert!(limiter.hit("a").is_ok());
assert!(limiter.hit("a").is_err());
}
#[test]
fn limits_each_client_per_window() {
let limiter = Throttle::new(2, Duration::from_secs(60));
assert!(limiter.hit("a").is_ok());
assert!(limiter.hit("a").is_ok());
let wait = limiter.hit("a").unwrap_err();
assert!(wait > Duration::from_secs(58));
assert!(limiter.hit("b").is_ok());
let short = Throttle::new(1, Duration::from_millis(20));
assert!(short.hit("a").is_ok());
assert!(short.hit("a").is_err());
std::thread::sleep(Duration::from_millis(25));
assert!(short.hit("a").is_ok());
}
#[test]
fn memory_stays_bounded() {
let limiter = Throttle::new(1, Duration::from_millis(50)).capacity(3);
for key in ["a", "b", "c"] {
limiter.hit(key).unwrap();
}
limiter.hit("d").unwrap();
let keys = |limiter: &Throttle| limiter.hits.lock().unwrap().len();
assert_eq!(keys(&limiter), 3, "the oldest window makes room");
std::thread::sleep(Duration::from_millis(60));
limiter.hit("e").unwrap();
assert_eq!(keys(&limiter), 1, "expired windows are pruned first");
}
#[test]
fn middleware_answers_429_with_retry_after() {
let routes = Router::new()
.middleware(throttle(1, Duration::from_secs(30)))
.get("/", |_| Response::text(200, "ok"));
let peer = "10.0.0.1".parse().unwrap();
assert_eq!(
routes.handle(&Request::get("/").with_peer(peer)).status,
200
);
let limited = routes.handle(&Request::get("/").with_peer(peer));
assert_eq!(limited.status, 429);
assert_eq!(limited.header("retry-after"), Some("30"));
let other = "10.0.0.2".parse().unwrap();
assert_eq!(
routes.handle(&Request::get("/").with_peer(other)).status,
200
);
}
#[test]
fn forwarded_address_is_opt_in() {
let proxy = "127.0.0.1".parse().unwrap();
let request = |client: &str| {
Request::get("/")
.with_peer(proxy)
.with_header("X-Forwarded-For", client)
};
let direct = Throttle::new(1, Duration::from_secs(60));
assert_eq!(direct.client(&request("1.1.1.1")), "127.0.0.1");
let proxied = Throttle::new(1, Duration::from_secs(60)).trust_forwarded(true);
assert_eq!(proxied.client(&request("6.6.6.6, 1.1.1.1")), "1.1.1.1");
assert_eq!(proxied.client(&request("1.1.1.1")), "1.1.1.1");
let routes = crate::web::Router::new()
.middleware(proxied.middleware())
.get("/", |_| Response::text(200, "ok"));
assert_eq!(routes.handle(&request("fake-1, 1.1.1.1")).status, 200);
assert_eq!(
routes.handle(&request("fake-2, 1.1.1.1")).status,
429,
"spoofed prefixes share one bucket"
);
}
}