Skip to main content

renox_core/
rate_limit.rs

1//! Per-route rate limits: `Routes::throttle(60, Duration::from_secs(60))`.
2//!
3//! Requests are counted per logged-in user, or per IP address for guests
4//! (see [`ClientIp`](crate::ClientIp) behind a proxy), in
5//! fixed windows kept in memory. Over the limit, the response is 429 with
6//! `Retry-After`; every allowed response carries `X-RateLimit-Limit` and
7//! `X-RateLimit-Remaining`.
8
9use crate::clock::Stamp;
10use std::collections::HashMap;
11use std::sync::Mutex;
12use std::time::Duration;
13
14use axum::extract::Request;
15use axum::http::HeaderValue;
16use axum::middleware::Next;
17use axum::response::{IntoResponse, Response};
18
19use crate::Error;
20use crate::auth::CurrentUser;
21
22/// Keys kept before stale windows are dropped on the next hit.
23const SWEEP_AT: usize = 10_000;
24
25pub(crate) struct Limiter {
26    /// Tells this limit's counters apart in the shared table: made from the
27    /// routes it covers and its numbers, so every server agrees on it.
28    id: String,
29    max: u32,
30    window: Duration,
31    hits: Mutex<HashMap<String, (u32, Stamp)>>,
32}
33
34pub(crate) enum Verdict {
35    Allowed { remaining: u32 },
36    Limited { retry_after: u64 },
37}
38
39impl Limiter {
40    pub fn new(id: String, max: u32, window: Duration) -> Self {
41        Self {
42            id,
43            max: max.max(1),
44            window,
45            hits: Mutex::new(HashMap::new()),
46        }
47    }
48
49    pub fn hit(&self, key: &str) -> Verdict {
50        let mut hits = self.hits.lock().unwrap_or_else(|e| e.into_inner());
51        if hits.len() >= SWEEP_AT {
52            hits.retain(|_, (_, start)| start.elapsed() < self.window);
53        }
54        let entry = hits.entry(key.to_owned()).or_insert((0, Stamp::now()));
55        if entry.1.elapsed() >= self.window {
56            *entry = (0, Stamp::now());
57        }
58        if entry.0 >= self.max {
59            let left = self.window.saturating_sub(entry.1.elapsed());
60            return Verdict::Limited {
61                retry_after: left.as_secs().max(1),
62            };
63        }
64        entry.0 += 1;
65        Verdict::Allowed {
66            remaining: self.max - entry.0,
67        }
68    }
69
70    pub fn max(&self) -> u32 {
71        self.max
72    }
73}
74
75fn key(req: &Request) -> String {
76    let user = req
77        .extensions()
78        .get::<CurrentUser>()
79        .and_then(|c| c.user.as_ref().map(|u| u.id));
80    let device = req
81        .extensions()
82        .get::<CurrentUser>()
83        .and_then(|c| c.device.as_ref().map(|d| d.key().to_owned()));
84    match (user, device) {
85        (Some(id), _) => format!("user:{id}"),
86        (None, Some(device)) => format!("device:{device}"),
87        (None, None) => match crate::ClientIp::of(req) {
88            Some(ip) => format!("ip:{ip}"),
89            None => "ip:unknown".to_owned(),
90        },
91    }
92}
93
94/// Counts the request in the database (shared by every server), letting it
95/// through if the database can't be reached.
96async fn shared_hit(limiter: &Limiter, db: &crate::db::Db, key: &str) -> Verdict {
97    let key = format!("throttle:{}:{key}", limiter.id);
98    match crate::counters::increment(db, &key, limiter.window).await {
99        Ok((count, ends)) if count > limiter.max => Verdict::Limited {
100            retry_after: crate::counters::seconds_until(ends),
101        },
102        Ok((count, _)) => Verdict::Allowed {
103            remaining: limiter.max - count,
104        },
105        Err(err) => {
106            tracing::warn!(error = ?err, "could not count a rate-limited request");
107            Verdict::Allowed {
108                remaining: limiter.max,
109            }
110        }
111    }
112}
113
114pub(crate) async fn check(limiter: &Limiter, req: Request, next: Next) -> Response {
115    let shared = req
116        .extensions()
117        .get::<crate::AppState>()
118        .filter(|state| state.config.cache_store == crate::CacheStore::Database)
119        .map(|state| state.db.clone());
120    let verdict = match &shared {
121        Some(db) => shared_hit(limiter, db, &key(&req)).await,
122        None => limiter.hit(&key(&req)),
123    };
124    respond(verdict, limiter.max(), req, next).await
125}
126
127/// What a named limiter's rule sees of a request (`App::rate_limiter`).
128#[non_exhaustive]
129pub struct LimitRequest<'a> {
130    /// The logged-in user, if any.
131    pub user: Option<&'a crate::auth::User>,
132    /// The device a device token authenticated (`auth::DeviceToken`), if any.
133    pub device: Option<&'a crate::auth::Device>,
134    /// The client's IP (`ClientIp`), if known.
135    pub ip: Option<std::net::IpAddr>,
136    /// The request method.
137    pub method: &'a axum::http::Method,
138    /// The request path.
139    pub path: &'a str,
140    /// The request headers.
141    pub headers: &'a axum::http::HeaderMap,
142}
143
144/// A limit a rule picks for one request: how many per how long, counted
145/// under which key (the user, or the IP for guests, unless `by` says).
146///
147/// ```
148/// # use renox::prelude::*;
149/// use renox::rate_limit::Limit;
150///
151/// # let _ =
152/// App::new().rate_limiter("api", |req| match req.user {
153///     Some(user) if user.has_role("partner") => Limit::none(),
154///     Some(_) => Limit::per_minute(600),
155///     None => Limit::per_minute(60), // per IP
156/// })
157/// # ;
158/// // then: Routes::new().get("/api/orders", orders).throttle_by("api")
159/// ```
160#[derive(Debug, Clone, PartialEq, Eq)]
161pub struct Limit {
162    max: u32,
163    per: Duration,
164    key: Option<String>,
165    unlimited: bool,
166}
167
168impl Limit {
169    /// At most `max` requests a minute.
170    pub fn per_minute(max: u32) -> Self {
171        Self::per(max, Duration::from_secs(60))
172    }
173
174    /// At most `max` requests an hour.
175    pub fn per_hour(max: u32) -> Self {
176        Self::per(max, Duration::from_secs(3600))
177    }
178
179    /// At most `max` requests (at least 1) per `per`.
180    pub fn per(max: u32, per: Duration) -> Self {
181        Self {
182            max: max.max(1),
183            per: per.max(Duration::from_secs(1)),
184            key: None,
185            unlimited: false,
186        }
187    }
188
189    /// No limit for this request.
190    pub fn none() -> Self {
191        Self {
192            max: u32::MAX,
193            per: Duration::from_secs(1),
194            key: None,
195            unlimited: true,
196        }
197    }
198
199    /// Counts under `key` instead of the user or IP, e.g. an API key or
200    /// a team: `Limit::per_minute(100).by(format!("team:{team}"))`.
201    pub fn by(mut self, key: impl Into<String>) -> Self {
202        self.key = Some(key.into());
203        self
204    }
205}
206
207pub(crate) type LimitRule = std::sync::Arc<dyn Fn(&LimitRequest) -> Limit + Send + Sync>;
208
209/// A named limiter: its rule and its in-memory counters.
210pub(crate) struct NamedLimiter {
211    pub rule: LimitRule,
212    hits: Mutex<HashMap<String, (u32, Stamp)>>,
213}
214
215impl NamedLimiter {
216    pub fn new(rule: LimitRule) -> Self {
217        Self {
218            rule,
219            hits: Mutex::new(HashMap::new()),
220        }
221    }
222
223    fn hit(&self, key: &str, limit: &Limit) -> Verdict {
224        let mut hits = self.hits.lock().unwrap_or_else(|e| e.into_inner());
225        if hits.len() >= SWEEP_AT {
226            hits.retain(|_, (_, start)| start.elapsed() < Duration::from_secs(24 * 60 * 60));
227        }
228        let entry = hits.entry(key.to_owned()).or_insert((0, Stamp::now()));
229        if entry.1.elapsed() >= limit.per {
230            *entry = (0, Stamp::now());
231        }
232        if entry.0 >= limit.max {
233            let left = limit.per.saturating_sub(entry.1.elapsed());
234            return Verdict::Limited {
235                retry_after: left.as_secs().max(1),
236            };
237        }
238        entry.0 += 1;
239        Verdict::Allowed {
240            remaining: limit.max - entry.0,
241        }
242    }
243}
244
245/// `Routes::throttle_by(name)`: the named limiter's rule picks the limit.
246pub(crate) async fn check_named(name: &str, req: Request, next: Next) -> Response {
247    let Some(state) = req.extensions().get::<crate::AppState>().cloned() else {
248        return next.run(req).await;
249    };
250    let Some(limiter) = state.limiters.get(name) else {
251        return Error::Internal(anyhow::anyhow!(
252            "no rate limiter `{name}`: define it with App::rate_limiter"
253        ))
254        .into_response();
255    };
256    let user = req
257        .extensions()
258        .get::<CurrentUser>()
259        .and_then(|c| c.user.clone());
260    let device = req
261        .extensions()
262        .get::<CurrentUser>()
263        .and_then(|c| c.device.clone());
264    let limit = (limiter.rule)(&LimitRequest {
265        user: user.as_deref(),
266        device: device.as_deref(),
267        ip: crate::ClientIp::of(&req),
268        method: req.method(),
269        path: req.uri().path(),
270        headers: req.headers(),
271    });
272    if limit.unlimited {
273        return next.run(req).await;
274    }
275    let key = format!(
276        "{name}:{}:{}",
277        limit.key.clone().unwrap_or_else(|| key(&req)),
278        limit.per.as_secs()
279    );
280    let verdict = if state.config.cache_store == crate::CacheStore::Database {
281        let shared = Limiter::new(format!("named:{name}"), limit.max, limit.per);
282        shared_hit(&shared, &state.db, &key).await
283    } else {
284        limiter.hit(&key, &limit)
285    };
286    respond(verdict, limit.max, req, next).await
287}
288
289async fn respond(verdict: Verdict, max: u32, req: Request, next: Next) -> Response {
290    match verdict {
291        Verdict::Limited { retry_after } => {
292            let mut res = Error::TooManyRequests.into_response();
293            if let Ok(value) = HeaderValue::from_str(&retry_after.to_string()) {
294                res.headers_mut().insert("retry-after", value);
295            }
296            res
297        }
298        Verdict::Allowed { remaining } => {
299            let mut res = next.run(req).await;
300            let headers = res.headers_mut();
301            headers.insert("x-ratelimit-limit", max.into());
302            headers.insert("x-ratelimit-remaining", remaining.into());
303            res
304        }
305    }
306}
307
308#[cfg(test)]
309mod tests {
310    use super::*;
311
312    #[test]
313    fn counts_per_key_and_window() {
314        let limiter = Limiter::new("t".into(), 2, Duration::from_millis(50));
315        assert!(matches!(
316            limiter.hit("a"),
317            Verdict::Allowed { remaining: 1 }
318        ));
319        assert!(matches!(
320            limiter.hit("a"),
321            Verdict::Allowed { remaining: 0 }
322        ));
323        assert!(matches!(
324            limiter.hit("a"),
325            Verdict::Limited { retry_after: 1 }
326        ));
327        assert!(matches!(limiter.hit("b"), Verdict::Allowed { .. }));
328        std::thread::sleep(Duration::from_millis(60));
329        assert!(matches!(
330            limiter.hit("a"),
331            Verdict::Allowed { remaining: 1 }
332        ));
333    }
334
335    /// Past `SWEEP_AT` keys, counters whose window ended are dropped, so
336    /// many one-off visitors don't grow the map for good.
337    #[test]
338    fn old_counters_are_swept_once_there_are_many() {
339        let limiter = Limiter::new("t".into(), 5, Duration::from_secs(60));
340        let named = NamedLimiter::new(std::sync::Arc::new(|_: &LimitRequest| Limit::per_hour(5)));
341        let limit = Limit::per_hour(5);
342        for i in 0..SWEEP_AT {
343            limiter.hit(&format!("ip:{i}"));
344            named.hit(&format!("ip:{i}"), &limit);
345        }
346        // A day later (and a minute for the plain limiter), the next hit
347        // finds only itself.
348        crate::clock::with_offset_sync(25 * 60 * 60, || {
349            limiter.hit("late");
350            named.hit("late", &limit);
351        });
352        assert_eq!(limiter.hits.lock().unwrap().len(), 1);
353        assert_eq!(named.hits.lock().unwrap().len(), 1);
354        // The clock can go back too: a counter is never older than now.
355        crate::clock::with_offset_sync(-10, || {
356            assert!(matches!(
357                limiter.hit("late"),
358                Verdict::Allowed { remaining: 3 }
359            ));
360        });
361    }
362
363    #[tokio::test]
364    async fn a_named_limit_outside_the_app_lets_requests_through_and_an_unknown_one_is_a_500() {
365        use tower::ServiceExt;
366        let router = |name: &'static str| {
367            axum::Router::new()
368                .route("/", axum::routing::get(|| async { "ok" }))
369                .layer(axum::middleware::from_fn(move |req, next| {
370                    check_named(name, req, next)
371                }))
372        };
373        let request = || {
374            Request::builder()
375                .uri("/")
376                .body(axum::body::Body::empty())
377                .unwrap()
378        };
379        // No app state (a router used on its own): not limited.
380        let res = router("api").oneshot(request()).await.unwrap();
381        assert_eq!(res.status(), axum::http::StatusCode::OK);
382        // `App::boot` refuses a `throttle_by` name with no limiter; a
383        // router merged in another way still gets a clear 500.
384        let app = crate::testing::TestApp::new(crate::App::new()).await;
385        let mut req = request();
386        req.extensions_mut().insert(app.state().clone());
387        let res = router("nowhere").oneshot(req).await.unwrap();
388        assert_eq!(res.status(), axum::http::StatusCode::INTERNAL_SERVER_ERROR);
389    }
390}