Skip to main content

rustlavel_cache/
throttle.rs

1//! The `throttle` middleware.
2//!
3//! ```ignore
4//! let cache = CacheStore::from_config(&config)?;
5//! router.group("/api", |r| {
6//!     r.get("/search", search);
7//! })
8//! .middleware(Throttle::per_minute(&cache, 60));
9//! ```
10//!
11//! Every response carries `X-RateLimit-Limit` and `X-RateLimit-Remaining`, so a
12//! client can slow itself down before it is refused. A refused request gets 429
13//! with `Retry-After` as well — the one header that actually tells a
14//! well-behaved client what to do.
15
16use crate::config::CacheStore;
17use crate::rate_limit::{RateLimit, RateLimiter};
18use crate::store::Cache;
19use rustlavel_http::handler::BoxFuture;
20use rustlavel_http::{Middleware, Next, Request, Response, Status};
21use rustlavel_core::Json;
22use std::sync::Arc;
23use std::time::Duration;
24
25/// Builds the bucket key for a request.
26type KeyFn = Arc<dyn Fn(&Request) -> String + Send + Sync>;
27
28/// Limits how often one client may hit the routes it guards.
29#[derive(Clone)]
30pub struct Throttle {
31    limiter: RateLimiter,
32    max: u64,
33    window: Duration,
34    key: KeyFn,
35}
36
37impl Throttle {
38    /// `max` requests per `window`, keyed by client IP and route.
39    pub fn new(cache: &CacheStore, max: u64, window: Duration) -> Self {
40        Throttle {
41            limiter: RateLimiter::new(cache.driver_handle()),
42            max,
43            window,
44            key: Arc::new(default_key),
45        }
46    }
47
48    /// The common case, and the one Laravel spells `throttle:60,1`.
49    pub fn per_minute(cache: &CacheStore, max: u64) -> Self {
50        Throttle::new(cache, max, Duration::from_secs(60))
51    }
52
53    pub fn per_second(cache: &CacheStore, max: u64) -> Self {
54        Throttle::new(cache, max, Duration::from_secs(1))
55    }
56
57    /// Build directly on a driver, for tests and for callers that never made a
58    /// [`CacheStore`].
59    pub fn with_driver(store: Arc<dyn Cache>, max: u64, window: Duration) -> Self {
60        Throttle { limiter: RateLimiter::new(store), max, window, key: Arc::new(default_key) }
61    }
62
63    /// Key the bucket by something other than the client IP: an API token, a
64    /// tenant, an authenticated user id.
65    ///
66    /// Worth doing whenever requests arrive through a NAT or a mobile carrier,
67    /// where thousands of unrelated users share one address.
68    pub fn by(mut self, key: impl Fn(&Request) -> String + Send + Sync + 'static) -> Self {
69        self.key = Arc::new(key);
70        self
71    }
72
73    fn headers(response: Response, outcome: &RateLimit) -> Response {
74        response
75            .with_header("x-ratelimit-limit", outcome.limit.to_string())
76            .with_header("x-ratelimit-remaining", outcome.remaining.to_string())
77    }
78}
79
80/// IP plus route, so a client that is being throttled on `/api/search` can
81/// still reach `/api/health`.
82///
83/// A request with no discoverable IP falls into one shared bucket rather than
84/// escaping the limit — failing closed is the only safe direction here.
85fn default_key(request: &Request) -> String {
86    let who = request.ip().unwrap_or_else(|| "unknown".to_string());
87    let what = request.route().unwrap_or_else(|| request.path());
88    format!("{who}|{what}")
89}
90
91impl Middleware for Throttle {
92    fn handle(&self, request: Request, next: Next) -> BoxFuture<Response> {
93        let limiter = self.limiter.clone();
94        let max = self.max;
95        let window = self.window;
96        let key = (self.key)(&request);
97
98        Box::pin(async move {
99            let outcome = match limiter.attempt(&key, max, window).await {
100                Ok(outcome) => outcome,
101                // A cache that is down must not take the whole site with it:
102                // the request goes through unthrottled rather than 500ing.
103                Err(_) => return next.run(request).await,
104            };
105
106            if outcome.exceeded {
107                let retry_after = outcome.retry_after_seconds();
108                let body = Json::object([
109                    ("message", Json::from("Too many requests.")),
110                    ("retry_after", Json::from(retry_after)),
111                ]);
112
113                let response = Response::new(Status::TOO_MANY_REQUESTS)
114                    .with_json(body)
115                    .with_header("retry-after", retry_after.to_string())
116                    .with_header("x-ratelimit-reset", outcome.reset_at().to_string());
117                return Throttle::headers(response, &outcome);
118            }
119
120            Throttle::headers(next.run(request).await, &outcome)
121        })
122    }
123}
124
125#[cfg(test)]
126mod tests {
127    use super::*;
128    use crate::memory::MemoryStore;
129    use rustlavel_http::{Method, Router, TestClient};
130
131    fn client(throttle: Throttle) -> TestClient {
132        let mut router = Router::new();
133        router.get("/api/search", |_req: Request| async { "results" });
134        router.get("/api/health", |_req: Request| async { "ok" });
135        router.middleware(throttle);
136        TestClient::new(router)
137    }
138
139    fn store() -> Arc<dyn Cache> {
140        Arc::new(MemoryStore::new())
141    }
142
143    /// The test client builds requests with no peer address, so an IP is
144    /// supplied the way a proxy would.
145    fn from(ip: &str, path: &str) -> Request {
146        Request::new(Method::Get, path).with_header("x-forwarded-for", ip)
147    }
148
149    #[tokio::test]
150    async fn the_first_requests_pass_and_carry_the_rate_limit_headers() {
151        let client = client(Throttle::with_driver(store(), 3, Duration::from_secs(60)));
152
153        for expected_remaining in ["2", "1", "0"] {
154            client
155                .send(from("10.0.0.1", "/api/search"))
156                .await
157                .assert_ok()
158                .assert_see("results")
159                .assert_header("x-ratelimit-limit", "3")
160                .assert_header("x-ratelimit-remaining", expected_remaining);
161        }
162    }
163
164    #[tokio::test]
165    async fn the_request_after_the_limit_is_refused_with_429_and_retry_after() {
166        let client = client(Throttle::with_driver(store(), 2, Duration::from_secs(60)));
167
168        client.send(from("10.0.0.2", "/api/search")).await.assert_ok();
169        client.send(from("10.0.0.2", "/api/search")).await.assert_ok();
170
171        let refused = client
172            .send(from("10.0.0.2", "/api/search"))
173            .await
174            .assert_status(429)
175            .assert_header("x-ratelimit-limit", "2")
176            .assert_header("x-ratelimit-remaining", "0")
177            .assert_json("message", "Too many requests.");
178
179        let retry_after: u64 =
180            refused.header("retry-after").expect("a 429 must say when to come back").parse().unwrap();
181        assert!((1..=60).contains(&retry_after), "retry-after was {retry_after}");
182        assert!(refused.header("x-ratelimit-reset").is_some());
183    }
184
185    #[tokio::test]
186    async fn two_client_addresses_get_their_own_allowance() {
187        let client = client(Throttle::with_driver(store(), 1, Duration::from_secs(60)));
188
189        client.send(from("10.0.0.3", "/api/search")).await.assert_ok();
190        client.send(from("10.0.0.3", "/api/search")).await.assert_status(429);
191
192        // A different address is a different bucket entirely.
193        client.send(from("10.0.0.4", "/api/search")).await.assert_ok();
194    }
195
196    #[tokio::test]
197    async fn two_routes_get_their_own_allowance() {
198        let client = client(Throttle::with_driver(store(), 1, Duration::from_secs(60)));
199
200        client.send(from("10.0.0.5", "/api/search")).await.assert_ok();
201        client.send(from("10.0.0.5", "/api/search")).await.assert_status(429);
202
203        client.send(from("10.0.0.5", "/api/health")).await.assert_ok();
204    }
205
206    #[tokio::test]
207    async fn a_custom_key_function_replaces_the_ip() {
208        let throttle = Throttle::with_driver(store(), 1, Duration::from_secs(60))
209            .by(|request: &Request| request.header("x-api-key").unwrap_or("anonymous").to_string());
210
211        let client = client(throttle);
212
213        let with_token = |token: &str| {
214            Request::new(Method::Get, "/api/search")
215                .with_header("x-forwarded-for", "10.0.0.6")
216                .with_header("x-api-key", token)
217        };
218
219        client.send(with_token("alpha")).await.assert_ok();
220        client.send(with_token("alpha")).await.assert_status(429);
221        // Same IP, different token: the IP is no longer what is being counted.
222        client.send(with_token("beta")).await.assert_ok();
223    }
224
225    #[tokio::test]
226    async fn the_allowance_comes_back_when_the_window_passes() {
227        // A short window, and only one request inside it. Asserting the 429
228        // here too would need both requests to land inside 100ms, which a busy
229        // machine cannot promise — that is covered separately, with a window
230        // long enough that timing cannot enter into it.
231        let window = Duration::from_millis(200);
232        let client = client(Throttle::with_driver(store(), 1, window));
233
234        client.send(from("10.0.0.7", "/api/search")).await.assert_ok();
235
236        tokio::time::sleep(window * 3).await;
237        client.send(from("10.0.0.7", "/api/search")).await.assert_ok();
238    }
239
240    #[tokio::test]
241    async fn the_second_request_inside_the_window_is_refused() {
242        // A minute-long window, so the two requests are inside it whatever else
243        // the machine is doing.
244        let client = client(Throttle::with_driver(store(), 1, Duration::from_secs(60)));
245
246        client.send(from("10.0.0.8", "/api/search")).await.assert_ok();
247        client.send(from("10.0.0.8", "/api/search")).await.assert_status(429);
248    }
249
250    #[tokio::test]
251    async fn the_handler_never_runs_once_the_limit_is_reached() {
252        let mut router = Router::new();
253        router.get("/once", |_req: Request| async {
254            // Passing this a second time would mean the middleware let a
255            // refused request through to the handler.
256            static SEEN: std::sync::atomic::AtomicUsize = std::sync::atomic::AtomicUsize::new(0);
257            let count = SEEN.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
258            assert_eq!(count, 0, "the handler ran after the limit was reached");
259            "ok"
260        });
261        router.middleware(Throttle::with_driver(store(), 1, Duration::from_secs(60)));
262
263        let client = TestClient::new(router);
264        client.send(from("10.0.0.8", "/once")).await.assert_ok();
265        client.send(from("10.0.0.8", "/once")).await.assert_status(429);
266    }
267
268    #[tokio::test]
269    async fn a_request_without_an_ip_still_falls_under_a_limit() {
270        let client = client(Throttle::with_driver(store(), 1, Duration::from_secs(60)));
271
272        // No peer address and no forwarded header: fail closed, not open.
273        client.send(Request::new(Method::Get, "/api/search")).await.assert_ok();
274        client.send(Request::new(Method::Get, "/api/search")).await.assert_status(429);
275    }
276
277    #[tokio::test]
278    async fn a_throttle_built_from_a_cache_store_works_the_same_way() {
279        let store = CacheStore::from_driver(MemoryStore::new());
280        let client = client(Throttle::per_minute(&store, 1));
281
282        client.send(from("10.0.0.9", "/api/search")).await.assert_ok();
283        client.send(from("10.0.0.9", "/api/search")).await.assert_status(429);
284    }
285}