use std::convert::Infallible;
use std::sync::Arc;
use std::task::{Context, Poll};
use std::time::Duration;
use axum::body::Body;
use axum::http::{HeaderName, HeaderValue, Request, Response, StatusCode};
use futures::future::BoxFuture;
use hwhkit_integration_redis::RedisHandle;
use redis_client::Script;
use serde_json::json;
use tower::{Layer, Service};
const LUA_TOKEN_BUCKET: &str = r#"
-- KEYS[1] = bucket key
-- ARGV[1] = capacity
-- ARGV[2] = refill_per_sec (decimal)
-- ARGV[3] = ttl_ms
local key = KEYS[1]
local capacity = tonumber(ARGV[1])
local refill = tonumber(ARGV[2])
local ttl = tonumber(ARGV[3])
local time = redis.call('TIME')
-- redis.call('TIME') returns {seconds, microseconds}.
local now = (tonumber(time[1]) * 1000) + math.floor(tonumber(time[2]) / 1000)
local data = redis.call('HMGET', key, 'tokens', 'ts')
local tokens = tonumber(data[1])
local ts = tonumber(data[2])
if tokens == nil then
tokens = capacity
ts = now
end
local elapsed = math.max(0, now - ts) / 1000.0
tokens = math.min(capacity, tokens + elapsed * refill)
local allowed = 0
local retry_after_ms = 0
if tokens >= 1 then
tokens = tokens - 1
allowed = 1
else
-- ms until one token regenerates
if refill > 0 then
retry_after_ms = math.ceil((1 - tokens) / refill * 1000)
else
retry_after_ms = ttl
end
end
redis.call('HMSET', key, 'tokens', tokens, 'ts', now)
redis.call('PEXPIRE', key, ttl)
return {allowed, retry_after_ms, math.floor(tokens)}
"#;
pub type KeyFn = Arc<dyn Fn(&Request<Body>) -> Option<String> + Send + Sync + 'static>;
#[derive(Clone)]
pub struct RateLimitLayer {
handle: RedisHandle,
capacity: u32,
window: Duration,
key_fn: KeyFn,
namespace: String,
}
impl RateLimitLayer {
pub fn new(handle: RedisHandle, capacity: u32, window: Duration, key_fn: KeyFn) -> Self {
Self {
handle,
capacity,
window,
key_fn,
namespace: "hwhkit:rl".to_string(),
}
}
#[must_use]
pub fn with_namespace(mut self, ns: impl Into<String>) -> Self {
self.namespace = ns.into();
self
}
pub fn per_ip(handle: RedisHandle, capacity: u32, window: Duration) -> Self {
let key_fn: KeyFn = Arc::new(|req: &Request<Body>| {
if let Some(xff) = req
.headers()
.get("x-forwarded-for")
.and_then(|v| v.to_str().ok())
{
let first = xff.split(',').next()?.trim();
if !first.is_empty() {
return Some(format!("ip:{first}"));
}
}
if let Some(addr) = req
.extensions()
.get::<axum::extract::ConnectInfo<std::net::SocketAddr>>()
{
return Some(format!("ip:{}", addr.0.ip()));
}
None
});
Self::new(handle, capacity, window, key_fn)
}
pub fn per_route(handle: RedisHandle, capacity: u32, window: Duration) -> Self {
let key_fn: KeyFn = Arc::new(|req: &Request<Body>| {
Some(format!("route:{}:{}", req.method(), req.uri().path()))
});
Self::new(handle, capacity, window, key_fn)
}
pub fn per_user(handle: RedisHandle, capacity: u32, window: Duration) -> Self {
let key_fn: KeyFn = Arc::new(|req: &Request<Body>| {
req.extensions()
.get::<UserId>()
.map(|u| format!("user:{}", u.0))
});
Self::new(handle, capacity, window, key_fn)
}
#[cfg(feature = "multi-tenant")]
pub fn per_tenant(handle: RedisHandle, capacity: u32, window: Duration) -> Self {
let key_fn: KeyFn = Arc::new(|req: &Request<Body>| {
if let Some(t) = req.extensions().get::<hwhkit_core::TenantId>() {
return Some(format!("tenant:{}", t.as_str()));
}
if let Some(xff) = req
.headers()
.get("x-forwarded-for")
.and_then(|v| v.to_str().ok())
{
let first = xff.split(',').next()?.trim();
if !first.is_empty() {
return Some(format!("ip:{first}"));
}
}
req.extensions()
.get::<axum::extract::ConnectInfo<std::net::SocketAddr>>()
.map(|addr| format!("ip:{}", addr.0.ip()))
});
Self::new(handle, capacity, window, key_fn)
}
#[cfg(feature = "multi-tenant")]
pub fn per_tenant_strict(handle: RedisHandle, capacity: u32, window: Duration) -> Self {
let key_fn: KeyFn = Arc::new(|req: &Request<Body>| {
req.extensions()
.get::<hwhkit_core::TenantId>()
.map(|t| format!("tenant:{}", t.as_str()))
});
Self::new(handle, capacity, window, key_fn)
}
}
#[derive(Clone, Debug)]
pub struct UserId(pub String);
impl<S> Layer<S> for RateLimitLayer {
type Service = RateLimit<S>;
fn layer(&self, inner: S) -> Self::Service {
RateLimit {
inner,
handle: self.handle.clone(),
capacity: self.capacity,
window: self.window,
key_fn: self.key_fn.clone(),
namespace: self.namespace.clone(),
}
}
}
#[derive(Clone)]
pub struct RateLimit<S> {
inner: S,
handle: RedisHandle,
capacity: u32,
window: Duration,
key_fn: KeyFn,
namespace: String,
}
impl<S> Service<Request<Body>> for RateLimit<S>
where
S: Service<Request<Body>, Response = Response<Body>, Error = Infallible>
+ Clone
+ Send
+ 'static,
S::Future: Send + 'static,
{
type Response = Response<Body>;
type Error = Infallible;
type Future = BoxFuture<'static, Result<Self::Response, Self::Error>>;
fn poll_ready(&mut self, cx: &mut Context<'_>) -> Poll<Result<(), Self::Error>> {
self.inner.poll_ready(cx)
}
fn call(&mut self, req: Request<Body>) -> Self::Future {
let key = match (self.key_fn)(&req) {
Some(k) => format!("{}:{}", self.namespace, k),
None => {
let clone = self.inner.clone();
let mut inner = std::mem::replace(&mut self.inner, clone);
return Box::pin(async move { inner.call(req).await });
}
};
let capacity = self.capacity;
let refill_per_sec = self.capacity as f64 / self.window.as_secs_f64().max(0.001);
let ttl_ms = (self.window.as_millis() as i64).max(1_000);
let mut conn = self.handle.manager();
let clone = self.inner.clone();
let mut inner = std::mem::replace(&mut self.inner, clone);
Box::pin(async move {
let result: redis_client::RedisResult<(i32, i64, i64)> = Script::new(LUA_TOKEN_BUCKET)
.key(key.clone())
.arg(capacity)
.arg(refill_per_sec)
.arg(ttl_ms)
.invoke_async(&mut conn)
.await;
match result {
Ok((1, _, remaining)) => {
let mut response = inner.call(req).await?;
response.headers_mut().insert(
HeaderName::from_static("x-ratelimit-remaining"),
HeaderValue::from(remaining),
);
Ok(response)
}
Ok((_, retry_after_ms, _)) => {
let retry_ms: i64 = retry_after_ms;
Ok(too_many_requests(retry_ms.max(0) as u64))
}
Err(err) => {
tracing::warn!(error = %err, "rate-limit redis failure; failing open");
inner.call(req).await
}
}
})
}
}
#[cfg(all(test, feature = "multi-tenant"))]
mod tenant_tests {
use super::*;
use axum::body::Body;
use axum::http::Request;
use hwhkit_core::TenantId;
#[test]
fn per_tenant_strict_keys_are_per_tenant() {
let key_fn: KeyFn = Arc::new(|req: &Request<Body>| {
req.extensions()
.get::<TenantId>()
.map(|t| format!("tenant:{}", t.as_str()))
});
let mut req_a = Request::builder().uri("/x").body(Body::empty()).unwrap();
req_a.extensions_mut().insert(TenantId::new("tenant-a"));
let mut req_b = Request::builder().uri("/x").body(Body::empty()).unwrap();
req_b.extensions_mut().insert(TenantId::new("tenant-b"));
let req_none = Request::builder().uri("/x").body(Body::empty()).unwrap();
let ka = key_fn(&req_a);
let kb = key_fn(&req_b);
let kn = key_fn(&req_none);
assert_eq!(ka.as_deref(), Some("tenant:tenant-a"));
assert_eq!(kb.as_deref(), Some("tenant:tenant-b"));
assert_eq!(kn, None);
assert_ne!(ka, kb);
}
}
fn too_many_requests(retry_after_ms: u64) -> Response<Body> {
let retry_secs = (retry_after_ms / 1000).max(1);
let body = json!({
"type": "about:blank",
"title": "Too Many Requests",
"status": 429,
"detail": format!("rate limit exceeded; retry in {retry_secs}s"),
});
let bytes = serde_json::to_vec(&body).unwrap_or_default();
Response::builder()
.status(StatusCode::TOO_MANY_REQUESTS)
.header("retry-after", retry_secs.to_string())
.header("content-type", "application/problem+json")
.body(Body::from(bytes))
.unwrap_or_else(|_| Response::new(Body::empty()))
}