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    /// A request from a given address.
144    ///
145    /// The peer address, not `X-Forwarded-For`: a header is not evidence of
146    /// where a request came from unless `TrustProxies` says the connection
147    /// came from a proxy, and a limiter keyed on an unverified header is one
148    /// a client escapes by sending a different value each time.
149    fn from(ip: &str, path: &str) -> Request {
150        Request::new(Method::Get, path).with_peer(format!("{ip}:44321").parse().expect("an address"))
151    }
152
153    #[tokio::test]
154    async fn the_first_requests_pass_and_carry_the_rate_limit_headers() {
155        let client = client(Throttle::with_driver(store(), 3, Duration::from_secs(60)));
156
157        for expected_remaining in ["2", "1", "0"] {
158            client
159                .send(from("10.0.0.1", "/api/search"))
160                .await
161                .assert_ok()
162                .assert_see("results")
163                .assert_header("x-ratelimit-limit", "3")
164                .assert_header("x-ratelimit-remaining", expected_remaining);
165        }
166    }
167
168    #[tokio::test]
169    async fn the_request_after_the_limit_is_refused_with_429_and_retry_after() {
170        let client = client(Throttle::with_driver(store(), 2, Duration::from_secs(60)));
171
172        client.send(from("10.0.0.2", "/api/search")).await.assert_ok();
173        client.send(from("10.0.0.2", "/api/search")).await.assert_ok();
174
175        let refused = client
176            .send(from("10.0.0.2", "/api/search"))
177            .await
178            .assert_status(429)
179            .assert_header("x-ratelimit-limit", "2")
180            .assert_header("x-ratelimit-remaining", "0")
181            .assert_json("message", "Too many requests.");
182
183        let retry_after: u64 =
184            refused.header("retry-after").expect("a 429 must say when to come back").parse().unwrap();
185        assert!((1..=60).contains(&retry_after), "retry-after was {retry_after}");
186        assert!(refused.header("x-ratelimit-reset").is_some());
187    }
188
189    #[tokio::test]
190    async fn two_client_addresses_get_their_own_allowance() {
191        let client = client(Throttle::with_driver(store(), 1, Duration::from_secs(60)));
192
193        client.send(from("10.0.0.3", "/api/search")).await.assert_ok();
194        client.send(from("10.0.0.3", "/api/search")).await.assert_status(429);
195
196        // A different address is a different bucket entirely.
197        client.send(from("10.0.0.4", "/api/search")).await.assert_ok();
198    }
199
200    #[tokio::test]
201    async fn two_routes_get_their_own_allowance() {
202        let client = client(Throttle::with_driver(store(), 1, Duration::from_secs(60)));
203
204        client.send(from("10.0.0.5", "/api/search")).await.assert_ok();
205        client.send(from("10.0.0.5", "/api/search")).await.assert_status(429);
206
207        client.send(from("10.0.0.5", "/api/health")).await.assert_ok();
208    }
209
210    #[tokio::test]
211    async fn a_custom_key_function_replaces_the_ip() {
212        let throttle = Throttle::with_driver(store(), 1, Duration::from_secs(60))
213            .by(|request: &Request| request.header("x-api-key").unwrap_or("anonymous").to_string());
214
215        let client = client(throttle);
216
217        let with_token = |token: &str| {
218            Request::new(Method::Get, "/api/search")
219                .with_peer("10.0.0.6:44321".parse().expect("an address"))
220                .with_header("x-api-key", token)
221        };
222
223        client.send(with_token("alpha")).await.assert_ok();
224        client.send(with_token("alpha")).await.assert_status(429);
225        // Same IP, different token: the IP is no longer what is being counted.
226        client.send(with_token("beta")).await.assert_ok();
227    }
228
229    #[tokio::test]
230    async fn the_allowance_comes_back_when_the_window_passes() {
231        // A short window, and only one request inside it. Asserting the 429
232        // here too would need both requests to land inside 100ms, which a busy
233        // machine cannot promise — that is covered separately, with a window
234        // long enough that timing cannot enter into it.
235        let window = Duration::from_millis(200);
236        let client = client(Throttle::with_driver(store(), 1, window));
237
238        client.send(from("10.0.0.7", "/api/search")).await.assert_ok();
239
240        tokio::time::sleep(window * 3).await;
241        client.send(from("10.0.0.7", "/api/search")).await.assert_ok();
242    }
243
244    #[tokio::test]
245    async fn the_second_request_inside_the_window_is_refused() {
246        // A minute-long window, so the two requests are inside it whatever else
247        // the machine is doing.
248        let client = client(Throttle::with_driver(store(), 1, Duration::from_secs(60)));
249
250        client.send(from("10.0.0.8", "/api/search")).await.assert_ok();
251        client.send(from("10.0.0.8", "/api/search")).await.assert_status(429);
252    }
253
254    #[tokio::test]
255    async fn the_handler_never_runs_once_the_limit_is_reached() {
256        let mut router = Router::new();
257        router.get("/once", |_req: Request| async {
258            // Passing this a second time would mean the middleware let a
259            // refused request through to the handler.
260            static SEEN: std::sync::atomic::AtomicUsize = std::sync::atomic::AtomicUsize::new(0);
261            let count = SEEN.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
262            assert_eq!(count, 0, "the handler ran after the limit was reached");
263            "ok"
264        });
265        router.middleware(Throttle::with_driver(store(), 1, Duration::from_secs(60)));
266
267        let client = TestClient::new(router);
268        client.send(from("10.0.0.8", "/once")).await.assert_ok();
269        client.send(from("10.0.0.8", "/once")).await.assert_status(429);
270    }
271
272    #[tokio::test]
273    async fn a_request_without_an_ip_still_falls_under_a_limit() {
274        let client = client(Throttle::with_driver(store(), 1, Duration::from_secs(60)));
275
276        // No peer address and no forwarded header: fail closed, not open.
277        client.send(Request::new(Method::Get, "/api/search")).await.assert_ok();
278        client.send(Request::new(Method::Get, "/api/search")).await.assert_status(429);
279    }
280
281    #[tokio::test]
282    async fn a_throttle_built_from_a_cache_store_works_the_same_way() {
283        let store = CacheStore::from_driver(MemoryStore::new());
284        let client = client(Throttle::per_minute(&store, 1));
285
286        client.send(from("10.0.0.9", "/api/search")).await.assert_ok();
287        client.send(from("10.0.0.9", "/api/search")).await.assert_status(429);
288    }
289}