1use super::store::ThrottleStore;
4use super::{StoreError, ThrottleConfig, ThrottleDecision, ThrottleOutcome};
5
6pub struct Throttle<S: ThrottleStore> {
19 store: S,
20 config: ThrottleConfig,
21}
22
23impl<S: ThrottleStore> Throttle<S> {
24 pub fn new(store: S, config: ThrottleConfig) -> Self {
25 Self { store, config }
26 }
27
28 pub fn config(&self) -> &ThrottleConfig {
29 &self.config
30 }
31
32 pub fn check(&self, key: &str, now: u64) -> ThrottleDecision {
40 match self.store.is_banned(key, now) {
41 Ok(Some(until)) if until > now => ThrottleDecision::Banned { until },
44 Ok(_) => match self.store.failure_count(key, now, self.config.window_secs) {
45 Ok(count) => ThrottleDecision::Allow {
46 remaining: self.remaining(count),
47 },
48 Err(_) => ThrottleDecision::Unavailable,
49 },
50 Err(_) => ThrottleDecision::Unavailable,
51 }
52 }
53
54 pub fn check_any(&self, keys: &[&str], now: u64) -> ThrottleDecision {
65 let mut banned_until: Option<u64> = None;
66 let mut unavailable = false;
67 let mut min_remaining: Option<u32> = None;
68 for key in keys {
69 match self.check(key, now) {
70 ThrottleDecision::Banned { until } => {
71 banned_until = Some(banned_until.map_or(until, |b: u64| b.max(until)));
73 }
74 ThrottleDecision::Unavailable => unavailable = true,
75 ThrottleDecision::Allow { remaining } => {
76 min_remaining = Some(min_remaining.map_or(remaining, |r| r.min(remaining)));
77 }
78 }
79 }
80 if let Some(until) = banned_until {
81 return ThrottleDecision::Banned { until };
82 }
83 if unavailable {
84 return ThrottleDecision::Unavailable;
85 }
86 ThrottleDecision::Allow {
87 remaining: min_remaining.unwrap_or(0),
88 }
89 }
90
91 pub fn record_failure(&self, key: &str, now: u64) -> Result<ThrottleOutcome, StoreError> {
109 let count = self
110 .store
111 .record_failure(key, now, self.config.window_secs)?;
112 if count >= self.config.threshold {
113 let until = now.saturating_add(self.config.ban_secs);
114 self.store.ban(key, until)?;
115 return Ok(ThrottleOutcome::Banned { until });
116 }
117 Ok(ThrottleOutcome::Allow {
118 remaining: self.remaining(count),
119 })
120 }
121
122 pub fn record_success(&self, key: &str) -> Result<(), StoreError> {
133 self.store.clear_failures(key)
134 }
135
136 pub fn reset(&self, key: &str) -> Result<(), StoreError> {
138 self.store.reset(key)
139 }
140
141 pub fn purge_expired(&self, now: u64) -> Result<usize, StoreError> {
142 self.store.purge_expired(now)
143 }
144
145 fn remaining(&self, count: u32) -> u32 {
147 self.config.threshold.saturating_sub(count)
148 }
149}
150
151#[cfg(test)]
152mod tests {
153 use super::super::MemoryThrottleStore;
154 use super::*;
155
156 const NOW: u64 = 1_000_000;
157
158 fn throttle(threshold: u32, window_secs: u64, ban_secs: u64) -> Throttle<MemoryThrottleStore> {
159 Throttle::new(
160 MemoryThrottleStore::new(),
161 ThrottleConfig {
162 threshold,
163 window_secs,
164 ban_secs,
165 },
166 )
167 }
168
169 #[test]
170 fn config_accessor_returns_construction_config() {
171 let t = throttle(3, 60, 900);
172 assert_eq!(t.config().threshold, 3, "got {:?}", t.config());
173 assert_eq!(t.config().window_secs, 60, "got {:?}", t.config());
174 assert_eq!(t.config().ban_secs, 900, "got {:?}", t.config());
175 }
176
177 #[test]
178 fn throttle_is_generic_over_store() {
179 fn accepts<S: ThrottleStore>(t: &Throttle<S>) -> u32 {
181 t.config().threshold
182 }
183 let t = Throttle::new(MemoryThrottleStore::new(), ThrottleConfig::default());
184 assert_eq!(accepts(&t), 5);
185 }
186
187 #[test]
188 fn check_on_fresh_key_is_full_budget() {
189 let t = throttle(5, 60, 900);
190 assert_eq!(
191 t.check("ip:1.2.3.4", NOW),
192 ThrottleDecision::Allow { remaining: 5 },
193 "窗口为空时剩余额度应为满额"
194 );
195 }
196
197 #[test]
198 fn check_does_not_consume_budget() {
199 let t = throttle(5, 60, 900);
200 for _ in 0..10 {
201 assert_eq!(
202 t.check("ip:a", NOW),
203 ThrottleDecision::Allow { remaining: 5 }
204 );
205 }
206 assert_eq!(
207 t.record_failure("ip:a", NOW).unwrap(),
208 ThrottleOutcome::Allow { remaining: 4 },
209 "check 不该计入失败"
210 );
211 }
212
213 #[test]
214 fn check_reports_decremented_remaining() {
215 let t = throttle(5, 60, 900);
216 t.record_failure("ip:a", NOW).unwrap();
217 t.record_failure("ip:a", NOW).unwrap();
218 assert_eq!(
219 t.check("ip:a", NOW),
220 ThrottleDecision::Allow { remaining: 3 },
221 "放行时的剩余额度要反映窗口内已累计的失败"
222 );
223 }
224
225 #[test]
226 fn ban_outlives_the_counting_window() {
227 let t = throttle(1, 60, 900);
229 t.record_failure("ip:a", NOW).unwrap();
230 assert_eq!(
231 t.check("ip:a", NOW + 61),
232 ThrottleDecision::Banned { until: NOW + 900 },
233 "窗口滑出不影响封禁"
234 );
235 }
236
237 #[test]
238 fn purge_expired_delegates_to_store() {
239 let t = throttle(5, 60, 900);
240 t.record_failure("ip:a", NOW).unwrap();
241 assert_eq!(t.purge_expired(NOW).unwrap(), 0, "窗口内不该被清");
242 assert_eq!(t.purge_expired(NOW + 1_000).unwrap(), 1);
243 assert_eq!(
244 t.check("ip:a", NOW + 1_000),
245 ThrottleDecision::Allow { remaining: 5 }
246 );
247 }
248}