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