Skip to main content

rainier_framework/
limits.rs

1//! Rate limits that survive a second replica — [`RateLimits`] and [`shared`].
2//!
3//! ```ignore
4//! // routes/api.rs
5//! router.post("/login", login).middleware(limits::shared(
6//!     ThrottleRequests::per_minute(5)
7//!         .named("login")
8//!         .keyed_by(|request| request.input("email")),
9//! ));
10//! ```
11//!
12//! A bare [`ThrottleRequests`] counts in its own process, which is the right
13//! default for development and for a single node. Behind a load balancer it
14//! means five replicas each enforce "five attempts a minute" separately, and
15//! the real limit is twenty-five.
16//!
17//! For a page-view limiter that is a rounding error. For a credential limiter
18//! it is the difference between a control and a decoration, which is why the
19//! bootstrap **says so at boot** when it finds one over a per-process cache.
20
21use std::sync::Arc;
22
23use rainier_cache::CacheRateLimiter;
24use rainier_middleware::{MiddlewareStack, RateLimitStore, ThrottleRequests};
25use rainier_routing::CompiledRouter;
26
27/// The store every shared limiter in this application counts in.
28///
29/// Bound at boot from the same cache everything else uses, so a deployment
30/// decides where its shared state lives once.
31pub struct RateLimits(Arc<dyn RateLimitStore>);
32
33impl RateLimits {
34    /// Count in `store`.
35    pub fn new(store: Arc<dyn RateLimitStore>) -> Self {
36        Self(store)
37    }
38
39    /// Count in `cache`.
40    pub fn over_cache(cache: Arc<dyn rainier_cache::Cache>) -> Self {
41        Self(Arc::new(CacheRateLimiter::new(cache)))
42    }
43
44    /// The store.
45    pub fn store(&self) -> Arc<dyn RateLimitStore> {
46        Arc::clone(&self.0)
47    }
48
49    /// Whether counters here are visible to other instances.
50    pub fn is_shared(&self) -> bool {
51        self.0.is_shared()
52    }
53
54    /// What is behind it — `"memory"`, `"redis"`.
55    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
69/// Count `throttle` in the application's shared store.
70///
71/// Returns a stack rather than a middleware because the store is resolved from
72/// the container, and routes are declared before there is one — the same
73/// reason [`Authenticate::resolved`](rainier_auth::Authenticate) exists.
74///
75/// Without this a throttle counts in its own process. With it, every limiter in
76/// the application counts in one place and a deployment moves them all at once
77/// by changing `CACHE_DRIVER`.
78pub fn shared(throttle: ThrottleRequests) -> MiddlewareStack {
79    MiddlewareStack::new()
80        .resolved(move |limits: Arc<RateLimits>| throttle.clone().stored_in(limits.store()))
81}
82
83/// Say so when a route rate-limits against a per-process counter.
84///
85/// Reads the compiled route table for anything carrying a throttle, and
86/// compares it with what the shared store actually is. The failure this
87/// prevents is silent by construction: the limiter works, returns `429`s,
88/// looks correct in every test — and permits `n × replicas` in production.
89///
90/// A warning rather than a refusal, in every environment. Unlike a scheduler
91/// lock, a per-process limit is a *weaker* control rather than an absent one,
92/// and plenty of applications limit for politeness rather than for safety. The
93/// deployment gets told; it does not get stopped.
94pub 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        // Nothing bound one, so nothing is claiming to be shared.
100        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        // Better than falling back to a per-process counter, which would be a
165        // limiter that silently does less than the route asked for.
166        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        // No throttle anywhere: nothing to say.
178        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        // With one, it has something to say — asserted by it not panicking and
185        // by the route table containing what the check reads.
186        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}