Skip to main content

security_rust/throttle/
mod.rs

1// Copyright (c) 2026 erik <erik@erik.xyz> — https://erik.xyz
2
3use std::fmt;
4
5pub mod guard;
6pub mod store;
7
8/// 复用 session 模块的错误类型,本模块不新造 —— 后端故障的形状是一样的。
9pub use crate::session::StoreError;
10
11pub use guard::Throttle;
12pub use store::{MemoryThrottleStore, ThrottleStore};
13
14/// 只放校准旋钮,不放策略。
15#[derive(Debug, Clone)]
16pub struct ThrottleConfig {
17    /// 窗口内允许的失败次数,达到即封禁。`0` 表示不给宽限:首次失败即封。
18    pub threshold: u32,
19    /// 计数窗口(秒)。`0` 表示失败互不相干(每次 `record_failure` 都从 1 起算)。
20    pub window_secs: u64,
21    /// 触发后的封禁时长(秒)。`0` 表示立即解封(仅记录,不拦人)。
22    pub ban_secs: u64,
23}
24
25impl Default for ThrottleConfig {
26    fn default() -> Self {
27        Self {
28            threshold: 5,
29            window_secs: 60,
30            ban_secs: 900,
31        }
32    }
33}
34
35#[derive(Debug, Clone, Copy, PartialEq, Eq)]
36pub enum ThrottleDecision {
37    /// 放行,`remaining` 为窗口内剩余可失败次数(可写进 X-RateLimit-* 响应头)。
38    ///
39    /// **`remaining == 0` 表示本请求应被拒绝**(额度已耗尽),不是「还能再试一次」;
40    /// 调用方必须据此拒绝,否则最后一次额度形同虚设。仍叫 `Allow` 而非 `Banned`,
41    /// 是因为此刻并没有封禁在生效 —— 例如 `ban_secs = 0` 的配置下,额度耗尽的 key
42    /// 会一直落在这一支。
43    Allow { remaining: u32 },
44    /// 已封禁,`until` 是解封时刻(unix 秒)。`now >= until` 即视为已解封。
45    Banned { until: u64 },
46    /// 存储后端不可用,**本模块不替调用方做决定**。
47    ///
48    /// 与 session 模块的 fail-closed 有意不同:限流是纵深防御而非主认证闸门,
49    /// 后端抖动时把全体用户挡在门外是自我 DoS,而放行只是暂时失去暴力破解防护
50    /// —— 主认证闸门(SessionGuard)仍然在拦。调用方拿到这个变体后自行选择
51    /// (建议:放行 + 告警)。
52    ///
53    /// 这条「不 fail-closed」是写死的设计,不是漏写的兜底:`Throttle::check` 里
54    /// 只把 `Err` 映射到本变体,绝不映射到 `Banned`。
55    ///
56    /// 只由 [`guard::Throttle::check`] / [`guard::Throttle::check_any`] 产生。
57    /// [`guard::Throttle::record_failure`] **没有这个变体**(它返回
58    /// [`ThrottleOutcome`],故障走 `Err`),所以调用方不必为它写死分支。
59    Unavailable,
60}
61
62/// 状态标签,与 [`Severity`](crate::Severity) 同样用大写。
63impl fmt::Display for ThrottleDecision {
64    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
65        match self {
66            ThrottleDecision::Allow { .. } => write!(f, "ALLOW"),
67            ThrottleDecision::Banned { .. } => write!(f, "BANNED"),
68            ThrottleDecision::Unavailable => write!(f, "UNAVAILABLE"),
69        }
70    }
71}
72
73/// `record_failure` 的结果。与 `check` 的 `ThrottleDecision` 不同,
74/// 这里不存在 `Unavailable` —— 存储故障走 `Err` 返回,不混在正常结果里。
75#[derive(Debug, Clone, Copy, PartialEq, Eq)]
76pub enum ThrottleOutcome {
77    /// 未达阈值:本次失败已记下,`remaining` 是窗口内剩余可失败次数。
78    Allow { remaining: u32 },
79    /// 本次失败达到阈值,已写入封禁;`until` 是解封时刻(unix 秒)。
80    Banned { until: u64 },
81}
82
83impl fmt::Display for ThrottleOutcome {
84    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
85        match self {
86            ThrottleOutcome::Allow { .. } => write!(f, "ALLOW"),
87            ThrottleOutcome::Banned { .. } => write!(f, "BANNED"),
88        }
89    }
90}
91
92#[cfg(test)]
93mod tests {
94    use super::*;
95
96    #[test]
97    fn config_defaults_match_spec() {
98        let c = ThrottleConfig::default();
99        assert_eq!(c.threshold, 5, "got {:?}", c);
100        assert_eq!(c.window_secs, 60, "got {:?}", c);
101        assert_eq!(c.ban_secs, 900, "got {:?}", c);
102    }
103
104    #[test]
105    fn decision_and_outcome_display_uppercase() {
106        assert_eq!(
107            ThrottleDecision::Allow { remaining: 3 }.to_string(),
108            "ALLOW"
109        );
110        assert_eq!(ThrottleDecision::Banned { until: 7 }.to_string(), "BANNED");
111        assert_eq!(ThrottleDecision::Unavailable.to_string(), "UNAVAILABLE");
112        assert_eq!(ThrottleOutcome::Allow { remaining: 3 }.to_string(), "ALLOW");
113        assert_eq!(ThrottleOutcome::Banned { until: 7 }.to_string(), "BANNED");
114    }
115}