Skip to main content

zeph_config/
rate_limit.rs

1// SPDX-FileCopyrightText: 2026 Andrei G <bug-ops>
2// SPDX-License-Identifier: MIT OR Apache-2.0
3
4use serde::{Deserialize, Serialize};
5
6fn default_shell_limit() -> usize {
7    30
8}
9
10fn default_web_limit() -> usize {
11    20
12}
13
14fn default_memory_limit() -> usize {
15    60
16}
17
18fn default_mcp_limit() -> usize {
19    40
20}
21
22fn default_other_limit() -> usize {
23    60
24}
25
26fn default_cooldown_secs() -> u64 {
27    30
28}
29
30fn default_rate_limit_enabled() -> bool {
31    true
32}
33
34/// Configuration for the tool rate limiter, nested under `[security.rate_limit]`.
35///
36/// Enabled by default. All limits are calls per 60-second window.
37#[derive(Debug, Clone, PartialEq, Deserialize, Serialize)]
38pub struct RateLimitConfig {
39    /// Master switch. When `false`, all checks are no-ops.
40    #[serde(default = "default_rate_limit_enabled")]
41    pub enabled: bool,
42    /// Maximum shell tool calls per minute.
43    #[serde(default = "default_shell_limit")]
44    pub shell_calls_per_minute: usize,
45    /// Maximum web tool calls per minute.
46    #[serde(default = "default_web_limit")]
47    pub web_calls_per_minute: usize,
48    /// Maximum memory tool calls per minute.
49    #[serde(default = "default_memory_limit")]
50    pub memory_calls_per_minute: usize,
51    /// Maximum MCP tool calls per minute.
52    #[serde(default = "default_mcp_limit")]
53    pub mcp_calls_per_minute: usize,
54    /// Maximum other tool calls per minute.
55    #[serde(default = "default_other_limit")]
56    pub other_calls_per_minute: usize,
57    /// Seconds the circuit breaker stays tripped after a limit is exceeded.
58    #[serde(default = "default_cooldown_secs")]
59    pub circuit_breaker_cooldown_secs: u64,
60}
61
62impl Default for RateLimitConfig {
63    fn default() -> Self {
64        Self {
65            enabled: true,
66            shell_calls_per_minute: default_shell_limit(),
67            web_calls_per_minute: default_web_limit(),
68            memory_calls_per_minute: default_memory_limit(),
69            mcp_calls_per_minute: default_mcp_limit(),
70            other_calls_per_minute: default_other_limit(),
71            circuit_breaker_cooldown_secs: default_cooldown_secs(),
72        }
73    }
74}
75
76#[cfg(test)]
77mod tests {
78    use super::*;
79
80    #[test]
81    fn default_is_enabled() {
82        assert!(RateLimitConfig::default().enabled);
83    }
84
85    #[test]
86    fn absent_section_defaults_to_enabled() {
87        let cfg: RateLimitConfig = toml::from_str("").unwrap();
88        assert!(
89            cfg.enabled,
90            "absent [security.rate_limit] must default to enabled = true"
91        );
92    }
93
94    /// Regression test for the serde field-default split-brain (issue #6469): a
95    /// present-but-partial section must resolve `enabled` via the named default
96    /// function, not `bool::default()` (`false`), so it agrees with an absent section.
97    #[test]
98    fn partial_section_still_defaults_to_enabled() {
99        let cfg: RateLimitConfig = toml::from_str("shell_calls_per_minute = 50").unwrap();
100        assert!(
101            cfg.enabled,
102            "partial [security.rate_limit] section must still default enabled = true"
103        );
104        assert_eq!(cfg.shell_calls_per_minute, 50);
105    }
106
107    #[test]
108    fn explicit_disabled_is_preserved() {
109        let cfg: RateLimitConfig = toml::from_str("enabled = false").unwrap();
110        assert!(!cfg.enabled, "explicit enabled = false must be preserved");
111    }
112
113    #[test]
114    fn explicit_disabled_with_partial_section_is_preserved() {
115        let cfg: RateLimitConfig =
116            toml::from_str("enabled = false\nshell_calls_per_minute = 50").unwrap();
117        assert!(!cfg.enabled);
118        assert_eq!(cfg.shell_calls_per_minute, 50);
119    }
120}