use super::store::ThrottleStore;
use super::{StoreError, ThrottleConfig, ThrottleDecision, ThrottleOutcome};
pub struct Throttle<S: ThrottleStore> {
store: S,
config: ThrottleConfig,
}
impl<S: ThrottleStore> Throttle<S> {
pub fn new(store: S, config: ThrottleConfig) -> Self {
Self { store, config }
}
pub fn config(&self) -> &ThrottleConfig {
&self.config
}
pub fn check(&self, key: &str, now: u64) -> ThrottleDecision {
match self.store.is_banned(key, now) {
Ok(Some(until)) if until > now => ThrottleDecision::Banned { until },
Ok(_) => match self.store.failure_count(key, now, self.config.window_secs) {
Ok(count) => ThrottleDecision::Allow {
remaining: self.remaining(count),
},
Err(_) => ThrottleDecision::Unavailable,
},
Err(_) => ThrottleDecision::Unavailable,
}
}
pub fn check_any(&self, keys: &[&str], now: u64) -> ThrottleDecision {
let mut banned_until: Option<u64> = None;
let mut unavailable = false;
let mut min_remaining: Option<u32> = None;
for key in keys {
match self.check(key, now) {
ThrottleDecision::Banned { until } => {
banned_until = Some(banned_until.map_or(until, |b: u64| b.max(until)));
}
ThrottleDecision::Unavailable => unavailable = true,
ThrottleDecision::Allow { remaining } => {
min_remaining = Some(min_remaining.map_or(remaining, |r| r.min(remaining)));
}
}
}
if let Some(until) = banned_until {
return ThrottleDecision::Banned { until };
}
if unavailable {
return ThrottleDecision::Unavailable;
}
ThrottleDecision::Allow {
remaining: min_remaining.unwrap_or(0),
}
}
pub fn record_failure(&self, key: &str, now: u64) -> Result<ThrottleOutcome, StoreError> {
let count = self
.store
.record_failure(key, now, self.config.window_secs)?;
if count >= self.config.threshold {
let until = now.saturating_add(self.config.ban_secs);
self.store.ban(key, until)?;
return Ok(ThrottleOutcome::Banned { until });
}
Ok(ThrottleOutcome::Allow {
remaining: self.remaining(count),
})
}
pub fn record_success(&self, key: &str) -> Result<(), StoreError> {
self.store.clear_failures(key)
}
pub fn reset(&self, key: &str) -> Result<(), StoreError> {
self.store.reset(key)
}
pub fn purge_expired(&self, now: u64) -> Result<usize, StoreError> {
self.store.purge_expired(now)
}
fn remaining(&self, count: u32) -> u32 {
self.config.threshold.saturating_sub(count)
}
}
#[cfg(test)]
mod tests {
use super::super::MemoryThrottleStore;
use super::*;
const NOW: u64 = 1_000_000;
fn throttle(threshold: u32, window_secs: u64, ban_secs: u64) -> Throttle<MemoryThrottleStore> {
Throttle::new(
MemoryThrottleStore::new(),
ThrottleConfig {
threshold,
window_secs,
ban_secs,
},
)
}
#[test]
fn config_accessor_returns_construction_config() {
let t = throttle(3, 60, 900);
assert_eq!(t.config().threshold, 3, "got {:?}", t.config());
assert_eq!(t.config().window_secs, 60, "got {:?}", t.config());
assert_eq!(t.config().ban_secs, 900, "got {:?}", t.config());
}
#[test]
fn throttle_is_generic_over_store() {
fn accepts<S: ThrottleStore>(t: &Throttle<S>) -> u32 {
t.config().threshold
}
let t = Throttle::new(MemoryThrottleStore::new(), ThrottleConfig::default());
assert_eq!(accepts(&t), 5);
}
#[test]
fn check_on_fresh_key_is_full_budget() {
let t = throttle(5, 60, 900);
assert_eq!(
t.check("ip:1.2.3.4", NOW),
ThrottleDecision::Allow { remaining: 5 },
"窗口为空时剩余额度应为满额"
);
}
#[test]
fn check_does_not_consume_budget() {
let t = throttle(5, 60, 900);
for _ in 0..10 {
assert_eq!(
t.check("ip:a", NOW),
ThrottleDecision::Allow { remaining: 5 }
);
}
assert_eq!(
t.record_failure("ip:a", NOW).unwrap(),
ThrottleOutcome::Allow { remaining: 4 },
"check 不该计入失败"
);
}
#[test]
fn check_reports_decremented_remaining() {
let t = throttle(5, 60, 900);
t.record_failure("ip:a", NOW).unwrap();
t.record_failure("ip:a", NOW).unwrap();
assert_eq!(
t.check("ip:a", NOW),
ThrottleDecision::Allow { remaining: 3 },
"放行时的剩余额度要反映窗口内已累计的失败"
);
}
#[test]
fn ban_outlives_the_counting_window() {
let t = throttle(1, 60, 900);
t.record_failure("ip:a", NOW).unwrap();
assert_eq!(
t.check("ip:a", NOW + 61),
ThrottleDecision::Banned { until: NOW + 900 },
"窗口滑出不影响封禁"
);
}
#[test]
fn purge_expired_delegates_to_store() {
let t = throttle(5, 60, 900);
t.record_failure("ip:a", NOW).unwrap();
assert_eq!(t.purge_expired(NOW).unwrap(), 0, "窗口内不该被清");
assert_eq!(t.purge_expired(NOW + 1_000).unwrap(), 1);
assert_eq!(
t.check("ip:a", NOW + 1_000),
ThrottleDecision::Allow { remaining: 5 }
);
}
}