larust_http/throttle.rs
1//! Laravel's `throttle` middleware - a fixed-window request-rate limit,
2//! keyed by the caller's real TCP peer address (`ConnectInfo<SocketAddr>`,
3//! see `larust_core::Application::serve()`'s `into_make_service_with_connect_info`
4//! wiring), never a client-supplied header like `X-Forwarded-For` - this
5//! framework has no "trusted proxy" concept to validate such a header
6//! against, so trusting it would make the limiter trivially bypassable
7//! (a different value per request) while looking like it works.
8//!
9//! Rejects with `429 Too Many Requests` plus a `Retry-After` header once a
10//! key's bucket is exhausted, matching Laravel's own `ThrottleRequests`
11//! response shape. Hand-rolled rather than built on `tower::limit`
12//! (present in this workspace's dependency tree only via `tower`'s default
13//! features; its `RateLimit` service isn't `Clone`, failing `Router::
14//! middleware()`'s bound without extra wrapping, and it has no per-key
15//! bucketing at all) or a new dependency like `governor` - this mirrors
16//! `csrf.rs`'s own hand-written `axum::middleware::from_fn`-style shape
17//! instead, the established house style for this kind of thing.
18
19use axum::extract::{ConnectInfo, Request, State};
20use axum::http::{header, StatusCode};
21use axum::middleware::{from_fn_with_state, FromFnLayer, Next};
22use axum::response::{IntoResponse, Response};
23use std::collections::HashMap;
24use std::future::Future;
25use std::net::SocketAddr;
26use std::pin::Pin;
27use std::sync::{Arc, Mutex};
28use std::time::{Duration, Instant};
29
30/// Caps how many distinct keys (source IPs, plus the one shared fallback
31/// below) this process tracks at once, mirroring `larust_live::lock`'s
32/// identical `MAX_SESSION_LOCKS` cap and reasoning: without a bound, a
33/// flood of requests from many distinct source addresses could grow this
34/// table without limit. Unlike that module, a new key arriving at the cap
35/// **fails open** here (allowed, just not tracked) rather than evicting
36/// the oldest entry - this cap protects this process's own memory, it
37/// isn't meant to be a rejection mechanism in its own right, and a
38/// genuinely abusive client keeps reusing the same key regardless, so its
39/// own bucket still catches it.
40const MAX_TRACKED_KEYS: usize = 10_000;
41
42/// The shared bucket key for any request with no `ConnectInfo` at all.
43/// Every request driven through `larust_testing::TestClient` falls into
44/// this case - it dispatches via `tower::ServiceExt::oneshot`, never a
45/// real accepted TCP connection, so `ConnectInfo` is never populated.
46/// Sharing one bucket keeps existing and future tests from needing to
47/// know anything about rate limiting to pass, at the cost of tests
48/// sharing a limit with each other if a single test fires more requests
49/// than the configured limit within one window - not the case for any
50/// test in this codebase today.
51const NO_CONNECT_INFO_KEY: &str = "__no_connect_info__";
52
53struct Bucket {
54 count: u32,
55 window_start: Instant,
56}
57
58/// `pub` only because it has to appear in [`per`]/[`per_minute`]'s public
59/// return type (see that doc comment) - every field stays private, so
60/// nothing outside this module can construct or inspect one.
61pub struct ThrottleState {
62 max_requests: u32,
63 window: Duration,
64 buckets: Mutex<HashMap<String, Bucket>>,
65}
66
67impl ThrottleState {
68 /// `true` if a request keyed `key` may proceed, recording it against
69 /// that key's bucket either way. A fixed-window counter (matches
70 /// Laravel's own `RateLimiter` algorithm, not token-bucket/sliding-
71 /// window) - a key's window simply resets, rather than aligning to a
72 /// global clock, once `window` has elapsed since it was first seen.
73 fn allow(&self, key: &str) -> bool {
74 let mut buckets = self
75 .buckets
76 .lock()
77 .unwrap_or_else(|poisoned| poisoned.into_inner());
78 let now = Instant::now();
79 // Opportunistic sweep, same as `larust_live::lock` - a bucket
80 // whose window has fully elapsed is equivalent to a reset count
81 // of zero, so removing it here and re-inserting fresh below (if
82 // this request needs to) is exactly a window reset.
83 buckets.retain(|_, bucket| now.duration_since(bucket.window_start) < self.window);
84
85 if let Some(bucket) = buckets.get_mut(key) {
86 if bucket.count >= self.max_requests {
87 return false;
88 }
89 bucket.count += 1;
90 return true;
91 }
92
93 if buckets.len() < MAX_TRACKED_KEYS {
94 buckets.insert(
95 key.to_string(),
96 Bucket {
97 count: 1,
98 window_start: now,
99 },
100 );
101 }
102 true
103 }
104}
105
106/// `middleware`'s own extractor arguments (every one before `Next`), as a
107/// tuple - `axum::middleware::from_fn_with_state`'s hidden extractor-arity
108/// marker parameter, spelled out explicitly (see [`per`]'s own doc comment
109/// for why it has to be).
110type Extractors = (
111 State<Arc<ThrottleState>>,
112 Option<ConnectInfo<SocketAddr>>,
113 Request,
114);
115
116/// A plain `fn` pointer, not the anonymous type of an `async fn` item -
117/// see [`per`]'s own doc comment for why `middleware` has to be written to
118/// coerce to this rather than left as an `async fn`.
119type MiddlewareFn = fn(
120 State<Arc<ThrottleState>>,
121 Option<ConnectInfo<SocketAddr>>,
122 Request,
123 Next,
124) -> MiddlewareFuture;
125type MiddlewareFuture = Pin<Box<dyn Future<Output = Response> + Send>>;
126
127/// `max_requests` per rolling minute - Laravel's own common default
128/// (`throttle:60,1`) when a route doesn't specify its own limit.
129pub fn per_minute(max_requests: u32) -> FromFnLayer<MiddlewareFn, Arc<ThrottleState>, Extractors> {
130 per(max_requests, Duration::from_secs(60))
131}
132
133/// `max_requests` per `window` - the general form behind [`per_minute`].
134/// Usable via `Router::middleware(...)` the same way `csrf::verify`/
135/// `DefaultBodyLimit::max(...)` already are.
136///
137/// Returns the fully concrete `FromFnLayer<...>` type rather than `impl
138/// tower::Layer<axum::routing::Route> + Clone + Send + 'static` - tempting
139/// since that's exactly what `Router::middleware()` requires, but an
140/// opaque `impl Trait` return only carries the bounds spelled out on it,
141/// not the *other* facts a caller in a different crate needs (that
142/// `L::Service` is itself `Clone + Send + tower::Service<Request>`,
143/// `Router::middleware()`'s real bound on `L::Service`) - those get
144/// erased at the opaque-type boundary, so `demo`/`examples/blog`'s own
145/// `.middleware(throttle::per_minute(60))` call failed to type-check
146/// against `impl Trait` even though this exact value works fine passed
147/// directly. A concrete, nameable return type has no such boundary; every
148/// real trait impl on it stays visible everywhere. Same reason `Router::
149/// middleware()`'s own doc comment says to call `axum::middleware::
150/// from_fn(handler)` directly at the `.middleware(...)` call site instead
151/// of through a wrapping function - this file does the equivalent by
152/// keeping the concrete type nameable instead.
153pub fn per(
154 max_requests: u32,
155 window: Duration,
156) -> FromFnLayer<MiddlewareFn, Arc<ThrottleState>, Extractors> {
157 let state = Arc::new(ThrottleState {
158 max_requests,
159 window,
160 buckets: Mutex::new(HashMap::new()),
161 });
162 from_fn_with_state(state, middleware as MiddlewareFn)
163}
164
165/// A plain `fn`, not `async fn` - `async fn`'s return type is anonymous
166/// and can't be named, which [`MiddlewareFn`] (and so [`per`]'s own
167/// concrete return type) needs it to be; boxing the future by hand is what
168/// makes that possible.
169fn middleware(
170 State(state): State<Arc<ThrottleState>>,
171 connect_info: Option<ConnectInfo<SocketAddr>>,
172 request: Request,
173 next: Next,
174) -> MiddlewareFuture {
175 Box::pin(async move {
176 let key = connect_info
177 .map(|ConnectInfo(addr)| addr.ip().to_string())
178 .unwrap_or_else(|| NO_CONNECT_INFO_KEY.to_string());
179
180 if !state.allow(&key) {
181 return reject(state.window);
182 }
183
184 next.run(request).await
185 })
186}
187
188fn reject(window: Duration) -> Response {
189 (
190 StatusCode::TOO_MANY_REQUESTS,
191 [(header::RETRY_AFTER, window.as_secs().to_string())],
192 "Too Many Requests",
193 )
194 .into_response()
195}
196
197#[cfg(test)]
198mod tests {
199 use super::*;
200
201 fn state(max_requests: u32, window: Duration) -> ThrottleState {
202 ThrottleState {
203 max_requests,
204 window,
205 buckets: Mutex::new(HashMap::new()),
206 }
207 }
208
209 #[test]
210 fn allows_up_to_the_limit_then_rejects_the_next_request_in_the_same_window() {
211 let state = state(3, Duration::from_secs(60));
212 assert!(state.allow("a"));
213 assert!(state.allow("a"));
214 assert!(state.allow("a"));
215 assert!(!state.allow("a"));
216 }
217
218 #[test]
219 fn a_fresh_window_resets_the_count() {
220 // A generous margin (150ms) between the window's own length and how
221 // long the test sleeps past it - found genuinely flaky under load,
222 // not hypothetically: the previous 50ms/80ms pairing (a 30ms
223 // margin) passed reliably in isolation but intermittently failed
224 // when run alongside this crate's other ~19 tests in the same
225 // binary, since `cargo test`'s default parallel scheduling can
226 // delay a real `thread::sleep`'s wakeup by tens of milliseconds
227 // under contention. Still fast in the common case (a quarter
228 // second), just no longer assuming near-zero scheduling jitter.
229 let state = state(1, Duration::from_millis(100));
230 assert!(state.allow("a"));
231 assert!(!state.allow("a"));
232 std::thread::sleep(Duration::from_millis(250));
233 assert!(state.allow("a"));
234 }
235
236 #[test]
237 fn different_keys_have_independent_buckets() {
238 let state = state(1, Duration::from_secs(60));
239 assert!(state.allow("a"));
240 assert!(!state.allow("a"));
241 assert!(state.allow("b"));
242 }
243
244 #[test]
245 fn a_new_key_at_capacity_fails_open_rather_than_panicking_or_rejecting() {
246 let state = state(1, Duration::from_secs(60));
247 for i in 0..MAX_TRACKED_KEYS {
248 assert!(state.allow(&i.to_string()));
249 }
250 assert!(state.allow("one-more-past-capacity"));
251 }
252}