Skip to main content

throttle_kit/
lib.rs

1#![cfg_attr(docsrs, feature(doc_cfg))]
2#![cfg_attr(docsrs, allow(unused_attributes))]
3#![forbid(unsafe_code)]
4#![deny(missing_docs)]
5//! Rate limiting for Rust.
6//!
7//! Implements the **GCRA** (Generic Cell Rate Algorithm) with pluggable
8//! backends (in-memory via `DashMap`, optional Redis) and optional Tower
9//! layer integration.
10//!
11//! # Quick Start
12//!
13//! ```no_run
14//! # #[cfg(feature = "in-memory")]
15//! # fn demo() {
16//! use throttle_kit::{RateLimiter, Quota, InMemoryBackend};
17//!
18//! # async fn run() {
19//! let backend = InMemoryBackend::new();
20//! let limiter = RateLimiter::new(
21//!     Quota::per_second(10),
22//!     backend,
23//! );
24//!
25//! let result = limiter.check("user-123").await;
26//! assert!(result.allowed);
27//! # }
28//! # }
29//! ```
30
31//! # Client identity (tower feature)
32//!
33//! The Tower layer keys requests by the client's socket address by
34//! default and ignores `X-Forwarded-For` (secure by default). Behind
35//! proxies you control, configure [`ClientIpConfig`]; see the
36//! [`client_ip`] module docs for the resolution algorithm and the
37//! README for axum wiring (`.into_make_service_with_connect_info`).
38//!
39//! [`ClientIpConfig`]: client_ip::ClientIpConfig
40
41mod backend;
42mod error;
43mod metrics;
44mod quota;
45
46#[cfg(feature = "redis")]
47mod redis;
48
49#[cfg(feature = "sqlite")]
50mod sqlite;
51
52#[cfg(feature = "sliding-window")]
53mod sliding_window;
54
55#[cfg(feature = "tower")]
56#[cfg_attr(docsrs, doc(cfg(feature = "tower")))]
57pub mod client_ip;
58
59#[cfg(feature = "tower")]
60mod tower_layer;
61
62#[cfg(feature = "tower")]
63#[cfg_attr(docsrs, doc(cfg(feature = "tower")))]
64pub use client_ip::{
65    ClientIpConfig, ClientIpError, ClientIpSource, IpNet, MissingClientIdentity,
66    MissingClientPolicy, ResolvedClient,
67};
68#[cfg(feature = "tower")]
69#[cfg_attr(docsrs, doc(cfg(feature = "tower")))]
70pub use tower_layer::{KeyExtractor, RateLimitLayer, RateLimitService};
71
72#[cfg(feature = "in-memory")]
73#[cfg_attr(docsrs, doc(cfg(feature = "in-memory")))]
74pub use backend::InMemoryBackend;
75#[cfg(feature = "test-util")]
76#[cfg_attr(docsrs, doc(cfg(feature = "test-util")))]
77pub use backend::NoopBackend;
78pub use backend::{RateLimitBackend, gcra_decide};
79
80pub use error::RateLimitError;
81pub use metrics::RateLimitResult;
82pub use quota::Quota;
83#[cfg(feature = "sliding-window")]
84#[cfg_attr(docsrs, doc(cfg(feature = "sliding-window")))]
85pub use sliding_window::SlidingWindowBackend;
86
87#[cfg(feature = "redis")]
88#[cfg_attr(docsrs, doc(cfg(feature = "redis")))]
89pub use redis::RedisBackend;
90
91#[cfg(feature = "sqlite")]
92#[cfg_attr(docsrs, doc(cfg(feature = "sqlite")))]
93pub use sqlite::SqliteBackend;
94
95use std::sync::Arc;
96
97/// Core rate limiter that delegates to a [`RateLimitBackend`].
98pub struct RateLimiter<B: RateLimitBackend> {
99    quota: Quota,
100    backend: Arc<B>,
101}
102
103impl<B: RateLimitBackend> Clone for RateLimiter<B> {
104    fn clone(&self) -> Self {
105        Self {
106            // Quota is `Copy`; the backend is shared behind an `Arc`, so
107            // cloning costs one refcount bump and needs no `B: Clone`.
108            quota: self.quota,
109            backend: Arc::clone(&self.backend),
110        }
111    }
112}
113
114impl<B: RateLimitBackend> RateLimiter<B> {
115    /// Create a new rate limiter with the given quota and backend.
116    pub fn new(quota: Quota, backend: B) -> Self {
117        Self {
118            quota,
119            backend: Arc::new(backend),
120        }
121    }
122
123    /// Check whether the caller identified by `key` is allowed to proceed.
124    pub async fn check(&self, key: &str) -> RateLimitResult {
125        self.backend.check(key, &self.quota).await
126    }
127
128    /// Synchronous version of [`check`](RateLimiter::check).
129    ///
130    /// Blocks the current thread until the check completes.
131    /// Useful for synchronous frameworks (e.g. Actix, Rocket).
132    pub fn check_sync(&self, key: &str) -> RateLimitResult {
133        // INVARIANT: building a current-thread runtime fails only on I/O
134        // or allocation errors; a synchronous API that owns its runtime
135        // has no meaningful recovery path.
136        #[allow(clippy::expect_used)]
137        let rt = tokio::runtime::Builder::new_current_thread()
138            .build()
139            .expect("failed to create tokio runtime for sync check");
140        rt.block_on(self.check(key))
141    }
142}
143
144#[cfg(feature = "in-memory")]
145pub use keyed::KeyedRateLimiter;
146
147#[cfg(feature = "in-memory")]
148mod keyed {
149    use std::sync::Arc;
150
151    use dashmap::DashMap;
152
153    use crate::backend::RateLimitBackend;
154    use crate::metrics::RateLimitResult;
155    use crate::quota::Quota;
156
157    /// Per-key rate limiter that tracks separate rate limits for each key.
158    ///
159    /// Keys either use the default quota or a per-key override set via
160    /// [`with_quota_for_key`](KeyedRateLimiter::with_quota_for_key).
161    /// Overrides live in a `DashMap` consulted by borrowed key on every
162    /// check; the check decision itself is delegated straight to the
163    /// shared backend, so the warm path performs no allocation.
164    ///
165    /// # Example
166    ///
167    /// ```no_run
168    /// use throttle_kit::{KeyedRateLimiter, Quota, InMemoryBackend};
169    ///
170    /// #[tokio::main]
171    /// async fn main() {
172    ///     let backend = InMemoryBackend::new();
173    ///     let limiter = KeyedRateLimiter::new(Quota::per_second(100), backend);
174    ///
175    ///     // Tighter limit for login endpoint
176    ///     limiter.with_quota_for_key("login", Quota::per_second(10));
177    ///
178    ///     let result = limiter.check("192.168.1.1").await;
179    ///     assert!(result.allowed);
180    /// }
181    /// ```
182    pub struct KeyedRateLimiter<B: RateLimitBackend> {
183        /// Quota overrides for specific keys; keys without an override
184        /// use `default_quota`.
185        overrides: DashMap<String, Quota>,
186        default_quota: Quota,
187        // Shared by every key: the backend is behind an `Arc`, so the
188        // check path needs no clone of it (or of any limiter wrapper).
189        backend: Arc<B>,
190    }
191
192    impl<B: RateLimitBackend> KeyedRateLimiter<B> {
193        /// Create a new keyed rate limiter with the given default quota and backend.
194        pub fn new(default_quota: Quota, backend: B) -> Self {
195            Self {
196                overrides: DashMap::new(),
197                default_quota,
198                backend: Arc::new(backend),
199            }
200        }
201
202        /// Override the rate limit quota for a specific key.
203        ///
204        /// This replaces any existing override for the key. The next call
205        /// to [`check`](KeyedRateLimiter::check) uses this quota.
206        pub fn with_quota_for_key(&self, key: &str, quota: Quota) {
207            self.overrides.insert(key.to_owned(), quota);
208        }
209
210        /// Check whether the caller identified by `key` is allowed to proceed.
211        ///
212        /// The warm path (any repeated check) is allocation-free: the
213        /// override map is read by borrowed key, and the decision comes
214        /// straight from the shared backend — no per-key limiter is
215        /// materialized or cloned. The map guard is never held across
216        /// the backend `await`.
217        pub async fn check(&self, key: &str) -> RateLimitResult {
218            let quota = match self.overrides.get(key) {
219                Some(entry) => *entry.value(),
220                None => self.default_quota,
221            };
222            self.backend.check(key, &quota).await
223        }
224
225        /// Synchronous version of [`check`](KeyedRateLimiter::check).
226        ///
227        /// Blocks the current thread until the check completes.
228        /// Useful for synchronous frameworks (e.g. Actix, Rocket).
229        pub fn check_sync(&self, key: &str) -> RateLimitResult {
230            // INVARIANT: building a current-thread runtime fails only on
231            // I/O or allocation errors; a synchronous API that owns its
232            // runtime has no meaningful recovery path.
233            #[allow(clippy::expect_used)]
234            let rt = tokio::runtime::Builder::new_current_thread()
235                .build()
236                .expect("failed to create tokio runtime for sync check");
237            rt.block_on(self.check(key))
238        }
239    }
240
241    #[cfg(test)]
242    mod keyed_tests {
243        use super::KeyedRateLimiter;
244
245        use crate::backend::InMemoryBackend;
246        use crate::quota::Quota;
247
248        #[tokio::test]
249        async fn warm_path_inserts_nothing() {
250            // Default-quota keys are never materialized in the override
251            // map: repeated checks stay a borrowed-key read (no
252            // allocation, no per-key limiter objects). Only
253            // `with_quota_for_key` writes.
254            let limiter = KeyedRateLimiter::new(Quota::per_second(10), InMemoryBackend::new());
255
256            limiter.check("warm").await;
257            limiter.check("warm").await;
258            assert!(
259                limiter.overrides.is_empty(),
260                "default-quota checks must not populate the override map"
261            );
262
263            limiter.with_quota_for_key("warm", Quota::per_second(2));
264            assert_eq!(limiter.overrides.len(), 1);
265            let r = limiter.check("warm").await;
266            assert!(r.allowed);
267            assert_eq!(r.limit, 2, "the override quota must apply");
268        }
269    }
270}
271
272// Tests exercise failure paths and invariants directly; unwrap/expect,
273// slicing, and panicking asserts are acceptable here — violations
274// surface as test failures, not production panics.
275#[allow(
276    clippy::unwrap_used,
277    clippy::expect_used,
278    clippy::indexing_slicing,
279    clippy::panic
280)]
281#[cfg(test)]
282mod tests {
283    use super::*;
284    use std::time::Duration;
285
286    #[test]
287    fn quota_per_second() {
288        let q = Quota::per_second(10);
289        assert_eq!(q.burst, 10);
290        assert_eq!(q.interval(), Duration::from_millis(100));
291    }
292
293    #[test]
294    fn quota_per_minute() {
295        let q = Quota::per_minute(100);
296        assert_eq!(q.burst, 100);
297        assert_eq!(q.interval(), Duration::from_millis(600));
298    }
299
300    #[test]
301    fn quota_per_hour() {
302        let q = Quota::per_hour(3600);
303        assert_eq!(q.burst, 3600);
304        assert_eq!(q.interval(), Duration::from_secs(1));
305    }
306
307    #[test]
308    fn quota_allow_burst() {
309        let q = Quota::per_second(10).allow_burst(20);
310        assert_eq!(q.burst, 20);
311    }
312
313    #[test]
314    fn rate_limit_result_fields() {
315        let r = RateLimitResult {
316            allowed: true,
317            remaining: 5,
318            reset_at: std::time::Instant::now(),
319            limit: 10,
320            retry_after: None,
321        };
322        assert!(r.allowed);
323        assert_eq!(r.remaining, 5);
324        assert_eq!(r.limit, 10);
325        assert!(r.retry_after.is_none());
326    }
327
328    #[test]
329    fn rate_limit_result_headers() {
330        let r = RateLimitResult {
331            allowed: true,
332            remaining: 7,
333            reset_at: std::time::Instant::now() + Duration::from_secs(30),
334            limit: 10,
335            retry_after: None,
336        };
337        let headers = r.headers();
338        assert_eq!(headers.len(), 3);
339        assert_eq!(headers[0].0, "X-RateLimit-Limit");
340        assert_eq!(headers[0].1, "10");
341        assert_eq!(headers[1].0, "X-RateLimit-Remaining");
342        assert_eq!(headers[1].1, "7");
343        assert_eq!(headers[2].0, "X-RateLimit-Reset");
344    }
345
346    #[test]
347    fn rate_limit_result_retry_after() {
348        let r = RateLimitResult {
349            allowed: false,
350            remaining: 0,
351            reset_at: std::time::Instant::now(),
352            limit: 10,
353            retry_after: Some(Duration::from_millis(100)),
354        };
355        assert!(!r.allowed);
356        assert_eq!(r.retry_after, Some(Duration::from_millis(100)));
357    }
358
359    #[cfg(feature = "in-memory")]
360    #[test]
361    fn in_memory_backend_new() {
362        let _ = InMemoryBackend::new();
363    }
364
365    #[cfg(feature = "in-memory")]
366    #[test]
367    fn in_memory_backend_default() {
368        let _ = InMemoryBackend::default();
369    }
370
371    #[test]
372    fn rate_limit_error_display() {
373        let e = RateLimitError::RateLimited;
374        assert_eq!(e.to_string(), "rate limit exceeded");
375
376        let e = RateLimitError::BackendError("connection refused".into());
377        assert_eq!(
378            e.to_string(),
379            "rate limit backend error: connection refused"
380        );
381    }
382
383    #[cfg(feature = "in-memory")]
384    #[tokio::test]
385    async fn first_request_is_allowed() {
386        let backend = InMemoryBackend::new();
387        let limiter = RateLimiter::new(Quota::per_second(10), backend);
388        let result = limiter.check("user-1").await;
389        assert!(result.allowed);
390        // GCRA remaining (matching the Redis GCRA backend): the number of
391        // conforming requests visible right now, floor((new_tat - now) /
392        // emission), which is 1 for a fresh key — not the token-bucket
393        // `burst - consumed` this backend reported before the pure
394        // `gcra_decide` core was extracted.
395        assert_eq!(result.remaining, 1);
396        assert_eq!(result.limit, 10);
397        assert!(result.retry_after.is_none());
398    }
399
400    #[cfg(feature = "in-memory")]
401    #[tokio::test]
402    async fn rate_limit_exhaustion() {
403        let backend = InMemoryBackend::new();
404        let limiter = RateLimiter::new(Quota::per_second(2), backend);
405        let r1 = limiter.check("user-1").await;
406        assert!(r1.allowed);
407        let r2 = limiter.check("user-1").await;
408        assert!(r2.allowed);
409        let r3 = limiter.check("user-1").await;
410        assert!(!r3.allowed);
411        assert!(r3.retry_after.is_some());
412    }
413
414    #[cfg(feature = "in-memory")]
415    #[tokio::test]
416    async fn keyed_limiter_default_quota() {
417        use crate::KeyedRateLimiter;
418        let backend = InMemoryBackend::new();
419        let limiter = KeyedRateLimiter::new(Quota::per_second(5), backend);
420
421        let r = limiter.check("client-1").await;
422        assert!(r.allowed);
423        assert_eq!(r.limit, 5);
424    }
425
426    #[cfg(feature = "in-memory")]
427    #[tokio::test]
428    async fn keyed_limiter_custom_quota_per_key() {
429        use crate::KeyedRateLimiter;
430        let backend = InMemoryBackend::new();
431        let limiter = KeyedRateLimiter::new(Quota::per_second(100), backend);
432
433        // Tighter limit for login
434        limiter.with_quota_for_key("login", Quota::per_second(2));
435
436        let r_login = limiter.check("login").await;
437        assert!(r_login.allowed);
438        assert_eq!(r_login.limit, 2);
439
440        let r_api = limiter.check("api").await;
441        assert!(r_api.allowed);
442        assert_eq!(r_api.limit, 100);
443    }
444
445    #[cfg(feature = "in-memory")]
446    #[tokio::test]
447    async fn keyed_limiter_isolation() {
448        use crate::KeyedRateLimiter;
449        let backend = InMemoryBackend::new();
450        let limiter = KeyedRateLimiter::new(Quota::per_second(1), backend);
451
452        let r1 = limiter.check("a").await;
453        assert!(r1.allowed);
454
455        // "a" is exhausted but "b" should be fine
456        let r1_again = limiter.check("a").await;
457        assert!(!r1_again.allowed);
458
459        let r2 = limiter.check("b").await;
460        assert!(r2.allowed);
461    }
462
463    #[cfg(feature = "in-memory")]
464    #[test]
465    fn keyed_limiter_sync_check() {
466        use crate::KeyedRateLimiter;
467        let backend = InMemoryBackend::new();
468        let limiter = KeyedRateLimiter::new(Quota::per_second(10), backend);
469
470        let r = limiter.check_sync("client-1");
471        assert!(r.allowed);
472        assert_eq!(r.limit, 10);
473    }
474
475    #[cfg(feature = "in-memory")]
476    #[tokio::test]
477    async fn keyed_limiter_headers() {
478        use crate::KeyedRateLimiter;
479        let backend = InMemoryBackend::new();
480        let limiter = KeyedRateLimiter::new(Quota::per_second(10), backend);
481
482        let r = limiter.check("client-1").await;
483        let headers = r.headers();
484        assert!(headers.iter().any(|(k, _)| *k == "X-RateLimit-Limit"));
485        assert!(
486            headers
487                .iter()
488                .any(|(k, v)| *k == "X-RateLimit-Remaining" && v == "1")
489        );
490    }
491
492    #[cfg(feature = "in-memory")]
493    #[test]
494    fn sync_check_basic() {
495        let backend = InMemoryBackend::new();
496        let limiter = RateLimiter::new(Quota::per_second(10), backend);
497        let r = limiter.check_sync("user-1");
498        assert!(r.allowed);
499    }
500}