rainier_framework/
limits.rs1use std::sync::Arc;
22
23use rainier_cache::CacheRateLimiter;
24use rainier_middleware::{MiddlewareStack, RateLimitStore, ThrottleRequests};
25use rainier_routing::CompiledRouter;
26
27pub struct RateLimits(Arc<dyn RateLimitStore>);
32
33impl RateLimits {
34 pub fn new(store: Arc<dyn RateLimitStore>) -> Self {
36 Self(store)
37 }
38
39 pub fn over_cache(cache: Arc<dyn rainier_cache::Cache>) -> Self {
41 Self(Arc::new(CacheRateLimiter::new(cache)))
42 }
43
44 pub fn store(&self) -> Arc<dyn RateLimitStore> {
46 Arc::clone(&self.0)
47 }
48
49 pub fn is_shared(&self) -> bool {
51 self.0.is_shared()
52 }
53
54 pub fn name(&self) -> &str {
56 self.0.name()
57 }
58}
59
60impl std::fmt::Debug for RateLimits {
61 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
62 f.debug_struct("RateLimits")
63 .field("store", &self.0.name())
64 .field("shared", &self.0.is_shared())
65 .finish()
66 }
67}
68
69pub fn shared(throttle: ThrottleRequests) -> MiddlewareStack {
79 MiddlewareStack::new()
80 .resolved(move |limits: Arc<RateLimits>| throttle.clone().stored_in(limits.store()))
81}
82
83pub fn warn_if_rate_limits_are_not_shared(
95 app: &rainier_container::Application,
96 router: &CompiledRouter,
97) {
98 let Ok(limits) = app.resolve::<RateLimits>() else {
99 return;
101 };
102
103 if limits.is_shared() {
104 return;
105 }
106
107 let throttled: Vec<String> = router
108 .describe()
109 .into_iter()
110 .filter(|route| route.middleware.iter().any(|stage| stage.contains("Throttle")))
111 .map(|route| format!("{} {}", route.methods.join("|"), route.uri))
112 .collect();
113
114 if throttled.is_empty() {
115 return;
116 }
117
118 tracing::warn!(
119 store = limits.name(),
120 routes = %throttled.join(", "),
121 "{} route(s) are rate-limited against a per-process counter, so the effective limit is \
122 the configured one multiplied by the number of replicas. Set CACHE_DRIVER to a shared \
123 store (redis, redis-cluster, memcached, dynamodb) if any of these protects credentials.",
124 throttled.len()
125 );
126}
127
128#[cfg(test)]
129mod tests {
130 use super::*;
131 use rainier_cache::MemoryCache;
132 use rainier_container::Application;
133 use rainier_middleware::MemoryRateLimitStore;
134 use rainier_routing::Router;
135
136 #[test]
137 fn a_memory_backed_store_reports_itself_honestly() {
138 let limits = RateLimits::new(Arc::new(MemoryRateLimitStore::new()));
139
140 assert!(!limits.is_shared());
141 assert_eq!(limits.name(), "memory");
142 }
143
144 #[test]
145 fn a_cache_backed_store_reports_its_cache() {
146 let limits = RateLimits::over_cache(Arc::new(MemoryCache::new()));
147
148 assert!(!limits.is_shared(), "a memory cache is still a memory cache");
149 assert_eq!(limits.name(), "memory");
150 }
151
152 #[tokio::test]
153 async fn the_shared_stack_resolves_the_bound_store() {
154 let app = Application::new(".");
155 app.instance(RateLimits::over_cache(Arc::new(MemoryCache::new())));
156
157 let stack = shared(ThrottleRequests::per_minute(5).named("login"));
158
159 assert!(stack.resolve(app.container()).is_ok(), "the stack should build");
160 }
161
162 #[tokio::test]
163 async fn the_shared_stack_fails_the_boot_when_nothing_is_bound() {
164 let app = Application::new(".");
167 let stack = shared(ThrottleRequests::per_minute(5));
168
169 assert!(stack.resolve(app.container()).is_err());
170 }
171
172 #[tokio::test]
173 async fn the_warning_only_fires_for_routes_that_are_actually_throttled() {
174 let app = Application::new(".");
175 app.instance(RateLimits::over_cache(Arc::new(MemoryCache::new())));
176
177 let mut router = Router::new();
179 router.get("/", || async { "home" });
180 let compiled = router.compile(app.container()).unwrap();
181
182 warn_if_rate_limits_are_not_shared(&app, &compiled);
183
184 let mut router = Router::new();
187 router.post("/login", || async { "in" }).middleware(ThrottleRequests::per_minute(5));
188 let compiled = router.compile(app.container()).unwrap();
189
190 assert!(compiled.describe()[0].middleware.iter().any(|s| s.contains("Throttle")));
191 warn_if_rate_limits_are_not_shared(&app, &compiled);
192 }
193}