throttle_kit/
sliding_window.rs1use std::collections::VecDeque;
2use std::time::Instant;
3
4use crate::backend::RateLimitBackend;
5use crate::metrics::RateLimitResult;
6use crate::quota::Quota;
7
8#[derive(Clone)]
14pub struct SlidingWindowBackend {
15 entries: dashmap::DashMap<String, VecDeque<Instant>>,
16}
17
18impl SlidingWindowBackend {
19 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#[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", "a).await;
94 assert!(r1.allowed);
95 assert_eq!(r1.remaining, 2);
96
97 let r2 = backend.check("key", "a).await;
98 assert!(r2.allowed);
99 assert_eq!(r2.remaining, 1);
100
101 let r3 = backend.check("key", "a).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", "a).await;
112 assert!(r1.allowed);
113
114 let r2 = backend.check("key", "a).await;
115 assert!(r2.allowed);
116
117 let r3 = backend.check("key", "a).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", "a).await;
129 assert!(r1.allowed);
130
131 let r2 = backend.check("a", "a).await;
132 assert!(!r2.allowed);
133
134 let r3 = backend.check("b", "a).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", "a).await;
144 assert!(r1.allowed);
145
146 let r2 = backend.check("key", "a).await;
147 assert!(!r2.allowed);
148
149 tokio::time::sleep(Duration::from_millis(1100)).await;
150
151 let r3 = backend.check("key", "a).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 let backend = SlidingWindowBackend::new();
168 let quota = Quota::per_second(2);
169
170 assert!(backend.check("w", "a).await.allowed);
171 assert!(backend.check("w", "a).await.allowed);
172 assert!(!backend.check("w", "a).await.allowed);
173
174 tokio::time::sleep(Duration::from_millis(600)).await;
175 assert!(
176 !backend.check("w", "a).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); assert!(backend.check("retry", "a).await.allowed);
198 tokio::time::sleep(Duration::from_millis(5)).await;
199 let r2 = backend.check("retry", "a).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}