use std::sync::Arc;
use rainier_cache::CacheRateLimiter;
use rainier_middleware::{MiddlewareStack, RateLimitStore, ThrottleRequests};
use rainier_routing::CompiledRouter;
pub struct RateLimits(Arc<dyn RateLimitStore>);
impl RateLimits {
pub fn new(store: Arc<dyn RateLimitStore>) -> Self {
Self(store)
}
pub fn over_cache(cache: Arc<dyn rainier_cache::Cache>) -> Self {
Self(Arc::new(CacheRateLimiter::new(cache)))
}
pub fn store(&self) -> Arc<dyn RateLimitStore> {
Arc::clone(&self.0)
}
pub fn is_shared(&self) -> bool {
self.0.is_shared()
}
pub fn name(&self) -> &str {
self.0.name()
}
}
impl std::fmt::Debug for RateLimits {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("RateLimits")
.field("store", &self.0.name())
.field("shared", &self.0.is_shared())
.finish()
}
}
pub fn shared(throttle: ThrottleRequests) -> MiddlewareStack {
MiddlewareStack::new()
.resolved(move |limits: Arc<RateLimits>| throttle.clone().stored_in(limits.store()))
}
pub fn warn_if_rate_limits_are_not_shared(
app: &rainier_container::Application,
router: &CompiledRouter,
) {
let Ok(limits) = app.resolve::<RateLimits>() else {
return;
};
if limits.is_shared() {
return;
}
let throttled: Vec<String> = router
.describe()
.into_iter()
.filter(|route| route.middleware.iter().any(|stage| stage.contains("Throttle")))
.map(|route| format!("{} {}", route.methods.join("|"), route.uri))
.collect();
if throttled.is_empty() {
return;
}
tracing::warn!(
store = limits.name(),
routes = %throttled.join(", "),
"{} route(s) are rate-limited against a per-process counter, so the effective limit is \
the configured one multiplied by the number of replicas. Set CACHE_DRIVER to a shared \
store (redis, redis-cluster, memcached, dynamodb) if any of these protects credentials.",
throttled.len()
);
}
#[cfg(test)]
mod tests {
use super::*;
use rainier_cache::MemoryCache;
use rainier_container::Application;
use rainier_middleware::MemoryRateLimitStore;
use rainier_routing::Router;
#[test]
fn a_memory_backed_store_reports_itself_honestly() {
let limits = RateLimits::new(Arc::new(MemoryRateLimitStore::new()));
assert!(!limits.is_shared());
assert_eq!(limits.name(), "memory");
}
#[test]
fn a_cache_backed_store_reports_its_cache() {
let limits = RateLimits::over_cache(Arc::new(MemoryCache::new()));
assert!(!limits.is_shared(), "a memory cache is still a memory cache");
assert_eq!(limits.name(), "memory");
}
#[tokio::test]
async fn the_shared_stack_resolves_the_bound_store() {
let app = Application::new(".");
app.instance(RateLimits::over_cache(Arc::new(MemoryCache::new())));
let stack = shared(ThrottleRequests::per_minute(5).named("login"));
assert!(stack.resolve(app.container()).is_ok(), "the stack should build");
}
#[tokio::test]
async fn the_shared_stack_fails_the_boot_when_nothing_is_bound() {
let app = Application::new(".");
let stack = shared(ThrottleRequests::per_minute(5));
assert!(stack.resolve(app.container()).is_err());
}
#[tokio::test]
async fn the_warning_only_fires_for_routes_that_are_actually_throttled() {
let app = Application::new(".");
app.instance(RateLimits::over_cache(Arc::new(MemoryCache::new())));
let mut router = Router::new();
router.get("/", || async { "home" });
let compiled = router.compile(app.container()).unwrap();
warn_if_rate_limits_are_not_shared(&app, &compiled);
let mut router = Router::new();
router.post("/login", || async { "in" }).middleware(ThrottleRequests::per_minute(5));
let compiled = router.compile(app.container()).unwrap();
assert!(compiled.describe()[0].middleware.iter().any(|s| s.contains("Throttle")));
warn_if_rate_limits_are_not_shared(&app, &compiled);
}
}