use axum::extract::{ConnectInfo, Request, State};
use axum::http::{header, StatusCode};
use axum::middleware::{from_fn_with_state, FromFnLayer, Next};
use axum::response::{IntoResponse, Response};
use std::collections::HashMap;
use std::future::Future;
use std::net::SocketAddr;
use std::pin::Pin;
use std::sync::{Arc, Mutex};
use std::time::{Duration, Instant};
const MAX_TRACKED_KEYS: usize = 10_000;
const NO_CONNECT_INFO_KEY: &str = "__no_connect_info__";
struct Bucket {
count: u32,
window_start: Instant,
}
pub struct ThrottleState {
max_requests: u32,
window: Duration,
buckets: Mutex<HashMap<String, Bucket>>,
}
impl ThrottleState {
fn allow(&self, key: &str) -> bool {
let mut buckets = self
.buckets
.lock()
.unwrap_or_else(|poisoned| poisoned.into_inner());
let now = Instant::now();
buckets.retain(|_, bucket| now.duration_since(bucket.window_start) < self.window);
if let Some(bucket) = buckets.get_mut(key) {
if bucket.count >= self.max_requests {
return false;
}
bucket.count += 1;
return true;
}
if buckets.len() < MAX_TRACKED_KEYS {
buckets.insert(
key.to_string(),
Bucket {
count: 1,
window_start: now,
},
);
}
true
}
}
type Extractors = (
State<Arc<ThrottleState>>,
Option<ConnectInfo<SocketAddr>>,
Request,
);
type MiddlewareFn = fn(
State<Arc<ThrottleState>>,
Option<ConnectInfo<SocketAddr>>,
Request,
Next,
) -> MiddlewareFuture;
type MiddlewareFuture = Pin<Box<dyn Future<Output = Response> + Send>>;
pub fn per_minute(max_requests: u32) -> FromFnLayer<MiddlewareFn, Arc<ThrottleState>, Extractors> {
per(max_requests, Duration::from_secs(60))
}
pub fn per(
max_requests: u32,
window: Duration,
) -> FromFnLayer<MiddlewareFn, Arc<ThrottleState>, Extractors> {
let state = Arc::new(ThrottleState {
max_requests,
window,
buckets: Mutex::new(HashMap::new()),
});
from_fn_with_state(state, middleware as MiddlewareFn)
}
fn middleware(
State(state): State<Arc<ThrottleState>>,
connect_info: Option<ConnectInfo<SocketAddr>>,
request: Request,
next: Next,
) -> MiddlewareFuture {
Box::pin(async move {
let key = connect_info
.map(|ConnectInfo(addr)| addr.ip().to_string())
.unwrap_or_else(|| NO_CONNECT_INFO_KEY.to_string());
if !state.allow(&key) {
return reject(state.window);
}
next.run(request).await
})
}
fn reject(window: Duration) -> Response {
(
StatusCode::TOO_MANY_REQUESTS,
[(header::RETRY_AFTER, window.as_secs().to_string())],
"Too Many Requests",
)
.into_response()
}
#[cfg(test)]
mod tests {
use super::*;
fn state(max_requests: u32, window: Duration) -> ThrottleState {
ThrottleState {
max_requests,
window,
buckets: Mutex::new(HashMap::new()),
}
}
#[test]
fn allows_up_to_the_limit_then_rejects_the_next_request_in_the_same_window() {
let state = state(3, Duration::from_secs(60));
assert!(state.allow("a"));
assert!(state.allow("a"));
assert!(state.allow("a"));
assert!(!state.allow("a"));
}
#[test]
fn a_fresh_window_resets_the_count() {
let state = state(1, Duration::from_millis(100));
assert!(state.allow("a"));
assert!(!state.allow("a"));
std::thread::sleep(Duration::from_millis(250));
assert!(state.allow("a"));
}
#[test]
fn different_keys_have_independent_buckets() {
let state = state(1, Duration::from_secs(60));
assert!(state.allow("a"));
assert!(!state.allow("a"));
assert!(state.allow("b"));
}
#[test]
fn a_new_key_at_capacity_fails_open_rather_than_panicking_or_rejecting() {
let state = state(1, Duration::from_secs(60));
for i in 0..MAX_TRACKED_KEYS {
assert!(state.allow(&i.to_string()));
}
assert!(state.allow("one-more-past-capacity"));
}
}