Skip to main content

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}