Skip to main content

churust_ratelimit/
lib.rs

1//! Rate limiting for the [Churust] web framework.
2//!
3//! [`RateLimit`] admits a bounded number of requests per key per period and
4//! answers everything above that with `429 Too Many Requests` and a
5//! `Retry-After` header. It is both a [`Plugin`] (server-wide) and a
6//! [`Middleware`] (scoped to part of the route tree with
7//! `RouteBuilder::intercept`).
8//!
9//! ```
10//! use churust_core::{Call, Churust, TestClient};
11//! use churust_ratelimit::RateLimit;
12//!
13//! # tokio::runtime::Runtime::new().unwrap().block_on(async {
14//! let app = Churust::server()
15//!     .install(RateLimit::per_minute(2))
16//!     .routing(|r| {
17//!         r.get("/", |_c: Call| async { "ok" });
18//!     })
19//!     .build();
20//!
21//! let client = TestClient::new(app);
22//! assert_eq!(client.get("/").send().await.status().as_u16(), 200);
23//! assert_eq!(client.get("/").send().await.status().as_u16(), 200);
24//! let limited = client.get("/").send().await;
25//! assert_eq!(limited.status().as_u16(), 429);
26//! assert!(limited.header("retry-after").is_some());
27//! # });
28//! ```
29//!
30//! # The algorithm
31//!
32//! A generic cell rate algorithm (GCRA), which is a leaky bucket expressed as
33//! one timestamp per key rather than a counter plus a timer. Each key stores a
34//! *theoretical arrival time*: the earliest moment at which the next request
35//! would be perfectly conforming. A request is admitted when it arrives no more
36//! than the burst tolerance ahead of that time, and the timestamp then advances
37//! by one emission interval.
38//!
39//! Two properties follow, and both are why this is preferred over a fixed
40//! window counter. Requests are smoothed rather than admitted in a stampede at
41//! the top of each window, and `Retry-After` is an exact figure that falls out
42//! of the arithmetic instead of an estimate.
43//!
44//! # Choosing a key
45//!
46//! The default key is the connection's peer address, without the port, so
47//! several connections from one client share a bucket. An IPv4 peer is keyed on
48//! the whole address; an IPv6 peer is keyed on its /64 prefix, because the low
49//! 64 bits of an IPv6 address are the interface identifier and the host picks
50//! those itself. Without the mask, a peer that walks its own subnet gets a
51//! fresh allowance per address and is never limited at all, and even an honest
52//! client rotating privacy addresses drifts out of its bucket. The cost of the
53//! mask is that hosts which genuinely share one /64 share a budget; if that is
54//! wrong for your deployment, key on the full address yourself:
55//!
56//! ```
57//! use churust_ratelimit::RateLimit;
58//!
59//! let limiter = RateLimit::per_minute(60)
60//!     .by(|call| call.peer_addr().map(|addr| addr.ip().to_string()));
61//! ```
62//!
63//! Behind a reverse proxy the peer address is the proxy, which would put every
64//! visitor in one bucket. Use [`RateLimit::by`] there too, and read a
65//! forwarding header only after checking
66//! [`Call::peer_addr`](churust_core::Call::peer_addr) against proxies you
67//! actually trust. `X-Forwarded-For` is caller-supplied and trivially spoofed.
68//!
69//! [Churust]: churust_core::Churust
70
71#![deny(missing_docs)]
72
73use async_trait::async_trait;
74use churust_core::{AppBuilder, Call, Error, Middleware, Next, Phase, Plugin, Response};
75use http::header::RETRY_AFTER;
76use http::{HeaderValue, StatusCode};
77use std::collections::hash_map::RandomState;
78use std::collections::HashMap;
79use std::hash::BuildHasher;
80use std::net::{IpAddr, Ipv6Addr};
81use std::sync::{Arc, Mutex};
82use std::time::{Duration, Instant};
83
84/// How many keys are tracked before the table is pruned.
85const DEFAULT_MAX_KEYS: usize = 100_000;
86
87/// A function that derives the bucket key for a call, or `None` to exempt it.
88type KeyFn = Arc<dyn Fn(&Call) -> Option<String> + Send + Sync>;
89
90/// The tracked timestamps, behind one lock.
91///
92/// A `HashMap` under a `Mutex` rather than a sharded or lock-free map: the
93/// critical section is a hash lookup and an insert, and a lock held that
94/// briefly is not the bottleneck in a pipeline that is about to do I/O.
95#[derive(Debug, Default)]
96struct Table {
97    /// Key digest to theoretical arrival time.
98    ///
99    /// The digest, not the key. `max_keys` bounds how many entries there are
100    /// and nothing bounds how long a [`KeyFn`] makes one, so storing the key
101    /// itself would let whoever supplies it choose what a bucket costs: a
102    /// header-derived key at a few hundred kilobytes apiece stays far under the
103    /// entry cap, never trips [`Table::prune`], and still pins gigabytes for
104    /// the life of the process. At a fixed width the documented bound holds by
105    /// construction, for every `KeyFn`, without the limiter having to have an
106    /// opinion about what a reasonable key looks like.
107    tat: HashMap<u64, Instant>,
108    /// The seed the digests are taken under.
109    ///
110    /// Two distinct keys that collide share one budget, so the seed is per
111    /// table and randomly chosen rather than fixed: a caller who could compute
112    /// a digest offline could hunt for a value colliding with somebody else's
113    /// key and spend it for them. Against a secret seed that costs the birthday
114    /// work on a 64-bit output with no way to observe a hit, while an accidental
115    /// collision across the default 100,000 live entries sits around one in four
116    /// billion. Truncating the key instead would have been cheaper and would
117    /// have handed that collision to anyone who can type a prefix.
118    seed: RandomState,
119}
120
121impl Table {
122    /// The fixed-width stand-in for a key.
123    fn digest(&self, key: &str) -> u64 {
124        self.seed.hash_one(key)
125    }
126
127    /// Drop keys that have gone idle, then, if the table is still over its cap,
128    /// evict the entries nearest to expiry until it is under.
129    ///
130    /// Evicting a live entry forgives whatever that key had spent, so this is a
131    /// memory bound rather than a security boundary. It only engages when the
132    /// key space is being flooded, which is the case where the alternative,
133    /// unbounded growth, is the more direct denial of service.
134    fn prune(&mut self, now: Instant, max_keys: usize) {
135        self.tat.retain(|_, tat| *tat > now);
136        if self.tat.len() < max_keys {
137            return;
138        }
139        let target = max_keys * 9 / 10;
140        let mut by_expiry: Vec<(u64, Instant)> = self.tat.iter().map(|(k, v)| (*k, *v)).collect();
141        by_expiry.sort_by_key(|(_, tat)| *tat);
142        for (key, _) in by_expiry.into_iter().take(self.tat.len() - target) {
143            self.tat.remove(&key);
144        }
145    }
146}
147
148/// A rate limiter, usable as a plugin or as scoped middleware.
149///
150/// Construct with [`per_second`](RateLimit::per_second),
151/// [`per_minute`](RateLimit::per_minute), [`per_hour`](RateLimit::per_hour) or
152/// [`per`](RateLimit::per), then refine with [`burst`](RateLimit::burst) and
153/// [`by`](RateLimit::by).
154///
155/// Cloning shares the underlying table, so the same limiter can be installed in
156/// several places and still count one budget.
157#[derive(Clone)]
158pub struct RateLimit {
159    /// The spacing between two perfectly conforming requests.
160    emission: Duration,
161    /// How far ahead of its schedule a key may run.
162    tolerance: Duration,
163    /// The advertised allowance, used only for the error message.
164    limit: u32,
165    /// The advertised period, used only for the error message.
166    period: Duration,
167    max_keys: usize,
168    key: KeyFn,
169    table: Arc<Mutex<Table>>,
170}
171
172impl std::fmt::Debug for RateLimit {
173    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
174        f.debug_struct("RateLimit")
175            .field("limit", &self.limit)
176            .field("period", &self.period)
177            .field("emission", &self.emission)
178            .field("tolerance", &self.tolerance)
179            .field("max_keys", &self.max_keys)
180            .finish_non_exhaustive()
181    }
182}
183
184impl RateLimit {
185    /// Allow `limit` requests per `period`, per key.
186    ///
187    /// The initial burst equals `limit`: a key that has been quiet may spend
188    /// its whole allowance at once and then refills at `limit / period`. Narrow
189    /// that with [`burst`](RateLimit::burst).
190    ///
191    /// # Panics
192    ///
193    /// If `limit` is zero, or `period` is zero. Both describe a limiter that
194    /// can never admit anything, which is a configuration mistake rather than a
195    /// policy, and failing at startup is the repo's rule for those.
196    pub fn per(limit: u32, period: Duration) -> Self {
197        assert!(limit > 0, "rate limit must admit at least one request");
198        assert!(
199            !period.is_zero(),
200            "rate limit period must be greater than zero"
201        );
202        let emission = period / limit;
203        Self {
204            emission,
205            tolerance: emission * (limit - 1),
206            limit,
207            period,
208            max_keys: DEFAULT_MAX_KEYS,
209            key: Arc::new(default_key),
210            table: Arc::new(Mutex::new(Table::default())),
211        }
212    }
213
214    /// Allow `limit` requests per second, per key.
215    pub fn per_second(limit: u32) -> Self {
216        Self::per(limit, Duration::from_secs(1))
217    }
218
219    /// Allow `limit` requests per minute, per key.
220    pub fn per_minute(limit: u32) -> Self {
221        Self::per(limit, Duration::from_secs(60))
222    }
223
224    /// Allow `limit` requests per hour, per key.
225    pub fn per_hour(limit: u32) -> Self {
226        Self::per(limit, Duration::from_secs(3600))
227    }
228
229    /// Cap the instantaneous burst at `burst` requests.
230    ///
231    /// The sustained rate is unchanged. `burst(1)` admits no burst at all:
232    /// requests must be spaced by a full emission interval.
233    ///
234    /// # Panics
235    ///
236    /// If `burst` is zero.
237    pub fn burst(mut self, burst: u32) -> Self {
238        assert!(burst > 0, "burst must be at least one request");
239        self.tolerance = self.emission * (burst - 1);
240        self
241    }
242
243    /// Derive the bucket key from the call instead of using the peer IP.
244    ///
245    /// Returning `None` exempts the request from limiting entirely, which is
246    /// how you let health checks or an authenticated internal caller through.
247    ///
248    /// The key is hashed and dropped rather than kept, so its length costs
249    /// nothing beyond the one call and a caller-supplied value needs no length
250    /// check of its own before it is handed over.
251    ///
252    /// ```
253    /// use churust_ratelimit::RateLimit;
254    ///
255    /// // Per API key, falling back to no limit for unauthenticated callers.
256    /// let limiter = RateLimit::per_minute(60)
257    ///     .by(|call| call.header("x-api-key").map(str::to_owned));
258    /// ```
259    pub fn by<F>(mut self, f: F) -> Self
260    where
261        F: Fn(&Call) -> Option<String> + Send + Sync + 'static,
262    {
263        self.key = Arc::new(f);
264        self
265    }
266
267    /// Set how many keys are tracked before the table is pruned.
268    ///
269    /// The table holds a 64-bit digest of the key and one timestamp per active
270    /// client, so an entry costs the same whatever the key is and the default of
271    /// 100,000 is a few megabytes for any [`by`](RateLimit::by) function. Raise
272    /// it for a large fleet, lower it for a memory-constrained deployment.
273    ///
274    /// # Panics
275    ///
276    /// If `n` is zero.
277    pub fn max_keys(mut self, n: usize) -> Self {
278        assert!(n > 0, "max_keys must be at least one");
279        self.max_keys = n;
280        self
281    }
282
283    /// Test one request against the limiter.
284    ///
285    /// `Ok(())` admits it. `Err(delay)` rejects it, where `delay` is how long
286    /// the caller must wait before a request would conform.
287    fn check(&self, key: &str) -> Result<(), Duration> {
288        let now = Instant::now();
289        let mut table = self.table.lock().unwrap_or_else(|p| p.into_inner());
290
291        if table.tat.len() >= self.max_keys {
292            table.prune(now, self.max_keys);
293        }
294
295        let digest = table.digest(key);
296        let tat = table.tat.get(&digest).copied().unwrap_or(now);
297        // The earliest arrival this key may make. `checked_sub` failing means
298        // the tolerance reaches back past the process's own clock origin, which
299        // can only mean the key is idle, so the request conforms.
300        let earliest = tat.checked_sub(self.tolerance).unwrap_or(now);
301        if now < earliest {
302            return Err(earliest - now);
303        }
304
305        // A key that has fallen behind schedule resumes from now rather than
306        // accumulating credit for the time it was quiet. That credit is what
307        // the burst tolerance already expresses.
308        let base = if tat > now { tat } else { now };
309        table.tat.insert(digest, base + self.emission);
310        Ok(())
311    }
312
313    /// The response for a rejected request.
314    ///
315    /// `Retry-After` is whole seconds per RFC 9110 §10.2.3, rounded up so the
316    /// advertised moment is never earlier than the conforming one, and floored
317    /// at one so a sub-second wait does not read as "retry immediately".
318    fn too_many(&self, delay: Duration) -> Response {
319        let secs = delay.as_secs_f64().ceil().max(1.0) as u64;
320        let message = format!(
321            "rate limit exceeded: {} requests per {} seconds",
322            self.limit,
323            self.period.as_secs_f64()
324        );
325        let mut error = Error::new(StatusCode::TOO_MANY_REQUESTS, message);
326        if let Ok(value) = HeaderValue::from_str(&secs.to_string()) {
327            error = error.with_response_header(RETRY_AFTER, value);
328        }
329        churust_core::IntoResponse::into_response(error)
330    }
331}
332
333/// The peer address without its port, so several connections from one client
334/// share a bucket.
335///
336/// A call with no peer address (the in-process test client, or a transport that
337/// does not carry one) falls into a single shared bucket rather than being
338/// exempted. Exempting would make "arrive without an address" the way around
339/// the limiter.
340fn default_key(call: &Call) -> Option<String> {
341    Some(match call.peer_addr() {
342        Some(addr) => address_bucket(addr.ip()),
343        None => "unknown".to_string(),
344    })
345}
346
347/// The bucket an address belongs to: the whole address for IPv4, the /64 prefix
348/// for IPv6.
349///
350/// Keying on the full IPv6 address was the obvious reading of "one bucket per
351/// client" and it was the wrong one, because an IPv6 address does not name a
352/// client the way an IPv4 address does. The smallest allocation anybody is
353/// delegated is a /64 — RFC 4291 §2.5.1 spends the low half of every unicast
354/// address on an interface identifier, and SLAAC has the host mint those itself
355/// — so the low 64 bits are chosen by the peer, for free, as often as it likes.
356/// That broke the limiter in both directions. An attacker took a fresh address
357/// per request and never met a 429, because every request missed the table,
358/// defaulted its arrival time to `now` and conformed; and an ordinary laptop
359/// running RFC 8981 temporary addresses silently earned a new allowance every
360/// time it rotated. Masking to the /64 keys on the part of the address that is
361/// routed to the peer rather than the part the peer writes itself, which is the
362/// only half that costs anything to change.
363///
364/// A /64 and no coarser. Aggregating to a /56 or /48 would bucket by delegation
365/// rather than by subnet, and delegation sizes are a matter of ISP taste, so it
366/// would fold strangers together at some providers to catch a rotation that a
367/// /64 already catches. The residual is that hosts sharing one provider's /64 —
368/// virtual machines handed single addresses out of a rack prefix, say — share a
369/// bucket; that is the trade IPv4 has always made behind NAT, and a deployment
370/// that cannot afford it keys on something else with [`RateLimit::by`].
371///
372/// IPv4-mapped addresses are unwrapped before any of that. A dual-stack
373/// listener reports IPv4 peers as `::ffff:a.b.c.d`, and every one of those sits
374/// in `::ffff:0:0/96` — inside a single /64 — so masking them would have put
375/// the entire IPv4 internet in one bucket and turned the fix into a far worse
376/// defect than the one it repairs.
377fn address_bucket(ip: IpAddr) -> String {
378    let v6 = match ip {
379        IpAddr::V4(v4) => return v4.to_string(),
380        IpAddr::V6(v6) => v6,
381    };
382    if let Some(v4) = v6.to_ipv4_mapped() {
383        return v4.to_string();
384    }
385    let mut octets = v6.octets();
386    octets[8..].fill(0);
387    Ipv6Addr::from(octets).to_string()
388}
389
390#[async_trait]
391impl Middleware for RateLimit {
392    async fn handle(&self, call: Call, next: Next) -> Response {
393        let Some(key) = (self.key)(&call) else {
394            return next.run(call).await;
395        };
396        match self.check(&key) {
397            Ok(()) => next.run(call).await,
398            Err(delay) => self.too_many(delay),
399        }
400    }
401}
402
403impl Plugin for RateLimit {
404    /// Installed in [`Phase::Plugins`], so a rejected request is still logged by
405    /// a `CallLogging` plugin sitting in [`Phase::Monitoring`] outside it.
406    fn install(self: Box<Self>, app: &mut AppBuilder) {
407        app.add_middleware_in(Phase::Plugins, Arc::new(*self));
408    }
409}
410
411#[cfg(test)]
412mod tests {
413    use super::*;
414
415    #[test]
416    fn a_fresh_key_may_spend_the_whole_allowance_at_once() {
417        let limiter = RateLimit::per(3, Duration::from_secs(10));
418        assert!(limiter.check("a").is_ok());
419        assert!(limiter.check("a").is_ok());
420        assert!(limiter.check("a").is_ok());
421        assert!(limiter.check("a").is_err(), "the fourth exceeds the burst");
422    }
423
424    #[test]
425    fn keys_are_independent() {
426        let limiter = RateLimit::per(1, Duration::from_secs(10));
427        assert!(limiter.check("a").is_ok());
428        assert!(limiter.check("a").is_err());
429        assert!(limiter.check("b").is_ok(), "b has its own budget");
430    }
431
432    #[test]
433    fn burst_one_admits_a_single_request() {
434        let limiter = RateLimit::per(100, Duration::from_secs(1)).burst(1);
435        assert!(limiter.check("a").is_ok());
436        assert!(limiter.check("a").is_err());
437    }
438
439    #[test]
440    fn the_reported_delay_never_exceeds_the_period() {
441        let limiter = RateLimit::per(2, Duration::from_secs(4));
442        assert!(limiter.check("a").is_ok());
443        assert!(limiter.check("a").is_ok());
444        let delay = limiter.check("a").expect_err("should be limited");
445        assert!(
446            delay <= Duration::from_secs(4),
447            "waiting longer than the period would be wrong: {delay:?}"
448        );
449    }
450
451    #[test]
452    fn clones_share_one_budget() {
453        let limiter = RateLimit::per(1, Duration::from_secs(10));
454        let clone = limiter.clone();
455        assert!(limiter.check("a").is_ok());
456        assert!(
457            clone.check("a").is_err(),
458            "a clone must not reset the table"
459        );
460    }
461
462    #[test]
463    fn pruning_bounds_the_table() {
464        let limiter = RateLimit::per(1, Duration::from_millis(1)).max_keys(8);
465        for i in 0..64 {
466            let _ = limiter.check(&format!("key-{i}"));
467        }
468        let len = limiter.table.lock().unwrap().tat.len();
469        assert!(len <= 8, "table grew past its cap: {len}");
470    }
471
472    #[test]
473    fn a_long_key_is_stored_at_the_same_width_as_a_short_one() {
474        // A key that comes out of a header is chosen by the caller in length as
475        // well as in value, and `max_keys` counts entries rather than bytes, so
476        // nothing else in this file stops one client from deciding what a
477        // bucket costs. Debug renders precisely what an entry holds, which
478        // makes the rendered length a proxy for the entry's footprint that does
479        // not depend on knowing the key type.
480        let limiter = RateLimit::per(2, Duration::from_secs(30));
481        let long = "k".repeat(1 << 20);
482        assert!(limiter.check("k").is_ok());
483        assert!(limiter.check(&long).is_ok());
484
485        let table = limiter.table.lock().unwrap();
486        assert_eq!(table.tat.len(), 2, "the two keys are distinct");
487        let rendered = format!("{:?}", table.tat);
488        assert!(
489            rendered.len() < 256,
490            "the table retained the key's {} bytes instead of a fixed-width \
491             digest, so a caller picks the cost of its own bucket",
492            long.len()
493        );
494    }
495
496    #[test]
497    fn a_client_rotating_through_one_ipv6_subnet_gets_no_extra_budget() {
498        let limiter = RateLimit::per(2, Duration::from_secs(30));
499        let rotate = |suffix: &str| {
500            let ip: IpAddr = format!("2001:db8:1:2::{suffix}").parse().unwrap();
501            limiter.check(&address_bucket(ip))
502        };
503        assert!(rotate("1").is_ok());
504        assert!(rotate("2").is_ok());
505        assert!(
506            rotate("3").is_err(),
507            "a new address out of the same /64 is the same client and must not \
508             reset the budget"
509        );
510    }
511
512    #[test]
513    fn separate_ipv6_subnets_keep_separate_budgets() {
514        let one: IpAddr = "2001:db8:1:2::1".parse().unwrap();
515        let other: IpAddr = "2001:db8:1:3::1".parse().unwrap();
516        assert_ne!(
517            address_bucket(one),
518            address_bucket(other),
519            "different /64s are different networks"
520        );
521    }
522
523    #[test]
524    fn an_ipv4_peer_keeps_every_octet() {
525        let ip: IpAddr = "203.0.113.7".parse().unwrap();
526        assert_eq!(address_bucket(ip), "203.0.113.7");
527    }
528
529    #[test]
530    fn an_ipv4_mapped_peer_is_bucketed_as_the_address_it_carries() {
531        let mapped: IpAddr = "::ffff:203.0.113.7".parse().unwrap();
532        let neighbour: IpAddr = "::ffff:203.0.113.8".parse().unwrap();
533        assert_eq!(address_bucket(mapped), "203.0.113.7");
534        assert_ne!(
535            address_bucket(mapped),
536            address_bucket(neighbour),
537            "a dual-stack socket reports IPv4 peers inside one /64, so masking \
538             them would put the whole IPv4 internet in a single bucket"
539        );
540    }
541
542    #[test]
543    #[should_panic(expected = "at least one request")]
544    fn a_zero_limit_is_a_configuration_error() {
545        let _ = RateLimit::per(0, Duration::from_secs(1));
546    }
547
548    #[test]
549    #[should_panic(expected = "greater than zero")]
550    fn a_zero_period_is_a_configuration_error() {
551        let _ = RateLimit::per(1, Duration::ZERO);
552    }
553}