use std::collections::HashMap;
use std::net::SocketAddr;
use std::sync::{Arc, Mutex};
use std::time::{Duration, Instant};
use axum::Router;
use axum::extract::{ConnectInfo, Request, State};
use axum::http::StatusCode;
use axum::middleware::Next;
use axum::response::{IntoResponse, Response};
pub struct RateLimiter {
max_requests: u32,
window: Duration,
buckets: Mutex<HashMap<String, (Instant, u32)>>,
}
impl RateLimiter {
pub fn new(max_requests: u32, window: Duration) -> Self {
Self {
max_requests,
window,
buckets: Mutex::new(HashMap::new()),
}
}
pub fn search_tokens_from_env() -> Self {
let max_tokens = std::env::var("YORISHIRO_SEARCH_TOKENS_PER_MINUTE")
.ok()
.and_then(|v| v.parse().ok())
.unwrap_or(100_000);
Self::new(max_tokens, Duration::from_secs(60))
}
pub fn from_env() -> Self {
let max_requests = std::env::var("YORISHIRO_AUTH_RATE_LIMIT_MAX")
.ok()
.and_then(|v| v.parse().ok())
.unwrap_or(10);
let window_secs = std::env::var("YORISHIRO_AUTH_RATE_LIMIT_WINDOW_SECS")
.ok()
.and_then(|v| v.parse().ok())
.unwrap_or(60);
Self::new(max_requests, Duration::from_secs(window_secs))
}
pub fn allow(&self, key: &str) -> bool {
self.allow_cost(key, 1)
}
pub fn allow_cost(&self, key: &str, cost: u32) -> bool {
let mut buckets = self.buckets.lock().expect("rate limiter mutex poisoned");
let now = Instant::now();
if buckets.len() > 128 {
let window = self.window;
buckets.retain(|_, (start, _)| now.duration_since(*start) < window);
}
let entry = buckets.entry(key.to_string()).or_insert((now, 0));
if now.duration_since(entry.0) >= self.window {
*entry = (now, 0);
}
let was_empty = entry.1 == 0;
entry.1 = entry.1.saturating_add(cost);
entry.1 <= self.max_requests || was_empty
}
}
pub fn apply_rate_limit_layer<S>(router: Router<S>, limiter: Arc<RateLimiter>) -> Router<S>
where
S: Clone + Send + Sync + 'static,
{
router.layer(axum::middleware::from_fn_with_state(limiter, enforce))
}
pub async fn enforce(
State(limiter): State<std::sync::Arc<RateLimiter>>,
req: Request,
next: Next,
) -> Response {
let key = req
.extensions()
.get::<ConnectInfo<SocketAddr>>()
.map(|ConnectInfo(addr)| addr.ip().to_string())
.unwrap_or_else(|| "unknown".to_string());
if !limiter.allow(&key) {
tracing::warn!(client = %key, path = %req.uri().path(), "auth rate limit exceeded");
return StatusCode::TOO_MANY_REQUESTS.into_response();
}
next.run(req).await
}
#[cfg(test)]
#[path = "../../../../tests/http/middleware/rate_limit/mod.rs"]
mod tests;