Skip to main content

throttle_kit/
lib.rs

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