Skip to main content

throttle_kit/
sliding_window.rs

1use std::collections::VecDeque;
2use std::time::Instant;
3
4use crate::backend::RateLimitBackend;
5use crate::metrics::RateLimitResult;
6use crate::quota::Quota;
7
8/// In-memory backend using a sliding window algorithm.
9///
10/// Tracks request timestamps in a deque and counts requests within
11/// the current time window. Rejects requests when the count exceeds
12/// the quota's burst limit.
13#[derive(Clone)]
14pub struct SlidingWindowBackend {
15    entries: dashmap::DashMap<String, VecDeque<Instant>>,
16}
17
18impl SlidingWindowBackend {
19    /// Create a new sliding window backend.
20    pub fn new() -> Self {
21        Self {
22            entries: dashmap::DashMap::new(),
23        }
24    }
25}
26
27impl Default for SlidingWindowBackend {
28    fn default() -> Self {
29        Self::new()
30    }
31}
32
33impl RateLimitBackend for SlidingWindowBackend {
34    async fn check(&self, key: &str, quota: &Quota) -> RateLimitResult {
35        let window = quota.interval() * quota.burst;
36        let limit = quota.burst as u64;
37
38        let mut entry = self.entries.entry(key.to_string()).or_default();
39
40        let now = Instant::now();
41        let window_start = now.checked_sub(window).unwrap_or(now);
42
43        while entry.front().is_some_and(|&ts| ts < window_start) {
44            entry.pop_front();
45        }
46
47        let count = entry.len() as u64;
48        let allowed = count < limit;
49
50        if allowed {
51            entry.push_back(now);
52        }
53
54        let remaining = if allowed { limit - count - 1 } else { 0 };
55
56        let reset_at = entry.front().map(|&ts| ts + window).unwrap_or(now + window);
57
58        let retry_after = if allowed {
59            None
60        } else {
61            entry.front().map(|&ts| ts + window - now)
62        };
63
64        RateLimitResult {
65            allowed,
66            remaining,
67            reset_at,
68            limit,
69            retry_after,
70        }
71    }
72}
73
74// Tests exercise failure paths and invariants directly; unwrap/expect,
75// slicing, and panicking asserts are acceptable here — violations
76// surface as test failures, not production panics.
77#[allow(
78    clippy::unwrap_used,
79    clippy::expect_used,
80    clippy::indexing_slicing,
81    clippy::panic
82)]
83#[cfg(test)]
84mod tests {
85    use super::*;
86    use std::time::Duration;
87
88    #[tokio::test]
89    async fn sliding_window_allows_requests_within_limit() {
90        let backend = SlidingWindowBackend::new();
91        let quota = Quota::per_second(3);
92
93        let r1 = backend.check("key", &quota).await;
94        assert!(r1.allowed);
95        assert_eq!(r1.remaining, 2);
96
97        let r2 = backend.check("key", &quota).await;
98        assert!(r2.allowed);
99        assert_eq!(r2.remaining, 1);
100
101        let r3 = backend.check("key", &quota).await;
102        assert!(r3.allowed);
103        assert_eq!(r3.remaining, 0);
104    }
105
106    #[tokio::test]
107    async fn sliding_window_rejects_when_exceeded() {
108        let backend = SlidingWindowBackend::new();
109        let quota = Quota::per_second(2);
110
111        let r1 = backend.check("key", &quota).await;
112        assert!(r1.allowed);
113
114        let r2 = backend.check("key", &quota).await;
115        assert!(r2.allowed);
116
117        let r3 = backend.check("key", &quota).await;
118        assert!(!r3.allowed);
119        assert!(r3.retry_after.is_some());
120        assert_eq!(r3.remaining, 0);
121    }
122
123    #[tokio::test]
124    async fn sliding_window_keys_are_isolated() {
125        let backend = SlidingWindowBackend::new();
126        let quota = Quota::per_second(1);
127
128        let r1 = backend.check("a", &quota).await;
129        assert!(r1.allowed);
130
131        let r2 = backend.check("a", &quota).await;
132        assert!(!r2.allowed);
133
134        let r3 = backend.check("b", &quota).await;
135        assert!(r3.allowed);
136    }
137
138    #[tokio::test]
139    async fn sliding_window_requests_expire() {
140        let backend = SlidingWindowBackend::new();
141        let quota = Quota::per_second(1);
142
143        let r1 = backend.check("key", &quota).await;
144        assert!(r1.allowed);
145
146        let r2 = backend.check("key", &quota).await;
147        assert!(!r2.allowed);
148
149        tokio::time::sleep(Duration::from_millis(1100)).await;
150
151        let r3 = backend.check("key", &quota).await;
152        assert!(r3.allowed);
153    }
154
155    #[test]
156    fn sliding_window_new_and_default() {
157        let _ = SlidingWindowBackend::new();
158        let _ = SlidingWindowBackend::default();
159    }
160
161    #[tokio::test]
162    async fn sliding_window_resets_only_after_the_full_window() {
163        // window = interval * burst = 500 ms * 2 = 1 s for per_second(2).
164        // At 600 ms the recorded requests are still inside the true window,
165        // so the request must stay denied; a window computed by division
166        // (250 ms) would have expired them and re-allowed the request.
167        let backend = SlidingWindowBackend::new();
168        let quota = Quota::per_second(2);
169
170        assert!(backend.check("w", &quota).await.allowed);
171        assert!(backend.check("w", &quota).await.allowed);
172        assert!(!backend.check("w", &quota).await.allowed);
173
174        tokio::time::sleep(Duration::from_millis(600)).await;
175        assert!(
176            !backend.check("w", &quota).await.allowed,
177            "requests inside the full window must still count"
178        );
179    }
180
181    #[tokio::test]
182    async fn sliding_window_reset_at_is_in_the_future_when_allowed() {
183        let backend = SlidingWindowBackend::new();
184        let r = backend.check("reset", &Quota::per_second(2)).await;
185        assert!(r.allowed);
186        assert!(
187            r.reset_at > std::time::Instant::now(),
188            "reset_at must be in the future for an allowed request"
189        );
190    }
191
192    #[tokio::test]
193    async fn sliding_window_retry_after_is_within_the_window() {
194        let backend = SlidingWindowBackend::new();
195        let quota = Quota::per_second(1); // window = 1 s
196
197        assert!(backend.check("retry", &quota).await.allowed);
198        tokio::time::sleep(Duration::from_millis(5)).await;
199        let r2 = backend.check("retry", &quota).await;
200        assert!(!r2.allowed);
201        let retry = r2.retry_after.expect("denied request carries retry_after");
202        assert!(retry > Duration::ZERO, "retry_after must be positive");
203        assert!(
204            retry <= quota.interval(),
205            "retry_after must not exceed the window"
206        );
207    }
208}