1use anyhow::{Result, ensure};
2use serde::{Deserialize, Serialize};
3
4const MIN_HTTP_TIMEOUT_SECONDS: u64 = 5;
8
9#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
10#[derive(Debug, Clone, Deserialize, Serialize)]
11pub struct TimeoutsConfig {
12 #[serde(default = "TimeoutsConfig::default_default_ceiling_seconds")]
14 pub default_ceiling_seconds: u64,
15 #[serde(default = "TimeoutsConfig::default_pty_ceiling_seconds")]
17 pub pty_ceiling_seconds: u64,
18 #[serde(default = "TimeoutsConfig::default_mcp_ceiling_seconds")]
20 pub mcp_ceiling_seconds: u64,
21 #[serde(default = "TimeoutsConfig::default_streaming_ceiling_seconds")]
23 pub streaming_ceiling_seconds: u64,
24 #[serde(default = "TimeoutsConfig::default_long_running_command_ceiling_seconds")]
26 pub long_running_command_ceiling_seconds: u64,
27 #[serde(default = "TimeoutsConfig::default_warning_threshold_percent")]
29 pub warning_threshold_percent: u8,
30 #[serde(default = "TimeoutsConfig::default_decay_ratio")]
32 pub adaptive_decay_ratio: f64,
33 #[serde(default = "TimeoutsConfig::default_success_streak")]
35 pub adaptive_success_streak: u32,
36 #[serde(default = "TimeoutsConfig::default_min_floor_ms")]
38 pub adaptive_min_floor_ms: u64,
39 #[serde(default = "TimeoutsConfig::default_http_connect_seconds")]
42 pub http_connect_seconds: u64,
43 #[serde(default = "TimeoutsConfig::default_http_request_seconds")]
46 pub http_request_seconds: u64,
47 #[serde(default = "TimeoutsConfig::default_http_download_seconds")]
50 pub http_download_seconds: u64,
51}
52
53impl Default for TimeoutsConfig {
54 fn default() -> Self {
55 Self {
56 default_ceiling_seconds: Self::default_default_ceiling_seconds(),
57 pty_ceiling_seconds: Self::default_pty_ceiling_seconds(),
58 mcp_ceiling_seconds: Self::default_mcp_ceiling_seconds(),
59 streaming_ceiling_seconds: Self::default_streaming_ceiling_seconds(),
60 long_running_command_ceiling_seconds: Self::default_long_running_command_ceiling_seconds(),
61 warning_threshold_percent: Self::default_warning_threshold_percent(),
62 adaptive_decay_ratio: Self::default_decay_ratio(),
63 adaptive_success_streak: Self::default_success_streak(),
64 adaptive_min_floor_ms: Self::default_min_floor_ms(),
65 http_connect_seconds: Self::default_http_connect_seconds(),
66 http_request_seconds: Self::default_http_request_seconds(),
67 http_download_seconds: Self::default_http_download_seconds(),
68 }
69 }
70}
71
72impl TimeoutsConfig {
73 const MIN_CEILING_SECONDS: u64 = 15;
74
75 const fn default_default_ceiling_seconds() -> u64 {
76 180
77 }
78
79 const fn default_pty_ceiling_seconds() -> u64 {
80 300
81 }
82
83 const fn default_mcp_ceiling_seconds() -> u64 {
84 120
85 }
86
87 const fn default_streaming_ceiling_seconds() -> u64 {
88 600
89 }
90
91 const fn default_long_running_command_ceiling_seconds() -> u64 {
92 3_600
93 }
94
95 const fn default_warning_threshold_percent() -> u8 {
96 80
97 }
98
99 const fn default_decay_ratio() -> f64 {
100 0.875
101 }
102
103 const fn default_success_streak() -> u32 {
104 5
105 }
106
107 const fn default_min_floor_ms() -> u64 {
108 1_000
109 }
110
111 fn default_http_connect_seconds() -> u64 {
116 env_seconds(crate::constants::env_vars::VTCODE_HTTP_CONNECT_TIMEOUT_SECS).unwrap_or(5)
117 }
118
119 fn default_http_request_seconds() -> u64 {
120 env_seconds(crate::constants::env_vars::VTCODE_HTTP_REQUEST_TIMEOUT_SECS).unwrap_or(30)
121 }
122
123 fn default_http_download_seconds() -> u64 {
124 env_seconds(crate::constants::env_vars::VTCODE_HTTP_DOWNLOAD_TIMEOUT_SECS).unwrap_or(300)
125 }
126
127 pub fn warning_threshold_fraction(&self) -> f32 {
129 f32::from(self.warning_threshold_percent) / 100.0
130 }
131
132 pub fn http_timeouts(&self) -> vtcode_commons::http::HttpTimeouts {
135 vtcode_commons::http::HttpTimeouts::from_seconds(
136 self.http_connect_seconds,
137 self.http_request_seconds,
138 self.http_download_seconds,
139 )
140 }
141
142 pub fn ceiling_duration(&self, seconds: u64) -> Option<std::time::Duration> {
144 if seconds == 0 {
145 None
146 } else {
147 Some(std::time::Duration::from_secs(seconds))
148 }
149 }
150
151 pub(crate) fn validate(&self) -> Result<()> {
152 ensure!(
153 self.warning_threshold_percent > 0 && self.warning_threshold_percent < 100,
154 "timeouts.warning_threshold_percent must be between 1 and 99",
155 );
156
157 ensure!(
158 (0.1..=1.0).contains(&self.adaptive_decay_ratio),
159 "timeouts.adaptive_decay_ratio must be between 0.1 and 1.0"
160 );
161 ensure!(self.adaptive_success_streak > 0, "timeouts.adaptive_success_streak must be at least 1");
162 ensure!(self.adaptive_min_floor_ms >= 100, "timeouts.adaptive_min_floor_ms must be at least 100ms");
163
164 ensure!(
165 self.default_ceiling_seconds == 0 || self.default_ceiling_seconds >= Self::MIN_CEILING_SECONDS,
166 "timeouts.default_ceiling_seconds must be at least {} seconds (or 0 to disable)",
167 Self::MIN_CEILING_SECONDS
168 );
169
170 ensure!(
171 self.pty_ceiling_seconds == 0 || self.pty_ceiling_seconds >= Self::MIN_CEILING_SECONDS,
172 "timeouts.pty_ceiling_seconds must be at least {} seconds (or 0 to disable)",
173 Self::MIN_CEILING_SECONDS
174 );
175
176 ensure!(
177 self.mcp_ceiling_seconds == 0 || self.mcp_ceiling_seconds >= Self::MIN_CEILING_SECONDS,
178 "timeouts.mcp_ceiling_seconds must be at least {} seconds (or 0 to disable)",
179 Self::MIN_CEILING_SECONDS
180 );
181
182 ensure!(
183 self.streaming_ceiling_seconds == 0 || self.streaming_ceiling_seconds >= Self::MIN_CEILING_SECONDS,
184 "timeouts.streaming_ceiling_seconds must be at least {} seconds (or 0 to disable)",
185 Self::MIN_CEILING_SECONDS
186 );
187
188 ensure!(
189 self.long_running_command_ceiling_seconds == 0
190 || self.long_running_command_ceiling_seconds >= Self::MIN_CEILING_SECONDS,
191 "timeouts.long_running_command_ceiling_seconds must be at least {} seconds (or 0 to disable)",
192 Self::MIN_CEILING_SECONDS
193 );
194
195 for (name, seconds) in [
196 ("http_connect_seconds", self.http_connect_seconds),
197 ("http_request_seconds", self.http_request_seconds),
198 ("http_download_seconds", self.http_download_seconds),
199 ] {
200 ensure!(
201 seconds == 0 || seconds >= MIN_HTTP_TIMEOUT_SECONDS,
202 "timeouts.{name} must be at least {MIN_HTTP_TIMEOUT_SECONDS} seconds (or 0 to disable)",
203 );
204 }
205
206 Ok(())
207 }
208}
209
210fn env_seconds(name: &str) -> Option<u64> {
215 crate::env_helpers::read_env_var(name)
216 .and_then(|value| value.trim().parse::<u64>().ok())
217 .filter(|v| *v > 0)
218}
219
220pub fn resolve_timeout(user_timeout: Option<u64>) -> u64 {
222 use crate::constants::execution::{DEFAULT_TIMEOUT_SECS, MAX_TIMEOUT_SECS, MIN_TIMEOUT_SECS};
223
224 match user_timeout {
225 None | Some(0) => DEFAULT_TIMEOUT_SECS,
226 Some(value) if value < MIN_TIMEOUT_SECS => MIN_TIMEOUT_SECS,
227 Some(value) if value > MAX_TIMEOUT_SECS => MAX_TIMEOUT_SECS,
228 Some(value) => value,
229 }
230}
231
232#[cfg(test)]
233mod tests {
234 use super::MIN_HTTP_TIMEOUT_SECONDS;
235 use super::TimeoutsConfig;
236 use super::resolve_timeout;
237 use crate::constants::execution::{DEFAULT_TIMEOUT_SECS, MAX_TIMEOUT_SECS, MIN_TIMEOUT_SECS};
238
239 #[test]
240 fn default_values_are_safe() {
241 let config = TimeoutsConfig::default();
242 assert_eq!(config.default_ceiling_seconds, 180);
243 assert_eq!(config.pty_ceiling_seconds, 300);
244 assert_eq!(config.mcp_ceiling_seconds, 120);
245 assert_eq!(config.streaming_ceiling_seconds, 600);
246 assert_eq!(config.long_running_command_ceiling_seconds, 3_600);
247 assert_eq!(config.warning_threshold_percent, 80);
248 assert!(config.validate().is_ok());
249 }
250
251 #[test]
252 fn zero_ceiling_disables_limit() {
253 let config = TimeoutsConfig { default_ceiling_seconds: 0, ..Default::default() };
254 assert!(config.validate().is_ok());
255 assert!(config.ceiling_duration(config.default_ceiling_seconds).is_none());
256 }
257
258 #[test]
259 fn warning_threshold_bounds_are_enforced() {
260 let config_low = TimeoutsConfig { warning_threshold_percent: 0, ..Default::default() };
261 assert!(config_low.validate().is_err());
262
263 let config_high = TimeoutsConfig {
264 warning_threshold_percent: 100,
265 ..Default::default()
266 };
267 assert!(config_high.validate().is_err());
268 }
269
270 #[test]
271 fn resolve_timeout_applies_bounds() {
272 assert_eq!(resolve_timeout(None), DEFAULT_TIMEOUT_SECS);
273 assert_eq!(resolve_timeout(Some(0)), DEFAULT_TIMEOUT_SECS);
274 assert_eq!(resolve_timeout(Some(1)), MIN_TIMEOUT_SECS);
275 assert_eq!(resolve_timeout(Some(MAX_TIMEOUT_SECS + 1)), MAX_TIMEOUT_SECS);
276 assert_eq!(resolve_timeout(Some(120)), 120);
277 }
278
279 #[test]
280 fn http_timeout_defaults_match_commons_http_defaults() {
281 let config = TimeoutsConfig::default();
282 assert_eq!(config.http_connect_seconds, 5);
283 assert_eq!(config.http_request_seconds, 30);
284 assert_eq!(config.http_download_seconds, 300);
285
286 let resolved = config.http_timeouts();
287 let defaults = vtcode_commons::http::HttpTimeouts::default();
288 assert_eq!(resolved, defaults);
289 }
290
291 #[test]
292 fn http_timeouts_map_zero_to_disabled() {
293 let config = TimeoutsConfig {
294 http_connect_seconds: 0,
295 http_request_seconds: 0,
296 http_download_seconds: 0,
297 ..Default::default()
298 };
299 assert!(config.validate().is_ok());
300
301 let resolved = config.http_timeouts();
302 assert_eq!(resolved.connect, None);
303 assert_eq!(resolved.request, None);
304 assert_eq!(resolved.download, None);
305 }
306
307 #[test]
308 fn http_timeouts_reject_sub_second_typos_but_allow_minimum() {
309 for seconds in 1..MIN_HTTP_TIMEOUT_SECONDS {
310 let config = TimeoutsConfig {
311 http_request_seconds: seconds,
312 ..Default::default()
313 };
314 assert!(config.validate().is_err(), "{seconds}s must be rejected as a likely unit mistake");
315 }
316
317 let config = TimeoutsConfig {
318 http_request_seconds: MIN_HTTP_TIMEOUT_SECONDS,
319 ..Default::default()
320 };
321 assert!(config.validate().is_ok());
322 }
323
324 #[test]
325 fn http_env_overrides_back_the_defaults() {
326 let defaults = TimeoutsConfig::default();
329 assert_eq!(defaults.http_request_seconds, 30);
330
331 crate::env_helpers::test_env_overrides::set("VTCODE_HTTP_REQUEST_TIMEOUT_SECS", Some("120"));
332 crate::env_helpers::test_env_overrides::set("VTCODE_HTTP_CONNECT_TIMEOUT_SECS", Some("15"));
333 crate::env_helpers::test_env_overrides::set("VTCODE_HTTP_DOWNLOAD_TIMEOUT_SECS", Some("0"));
335
336 let overridden = TimeoutsConfig::default();
337 crate::env_helpers::test_env_overrides::set("VTCODE_HTTP_REQUEST_TIMEOUT_SECS", None);
338 crate::env_helpers::test_env_overrides::set("VTCODE_HTTP_CONNECT_TIMEOUT_SECS", None);
339 crate::env_helpers::test_env_overrides::set("VTCODE_HTTP_DOWNLOAD_TIMEOUT_SECS", None);
340
341 assert_eq!(overridden.http_request_seconds, 120);
342 assert_eq!(overridden.http_connect_seconds, 15);
343 assert_eq!(overridden.http_download_seconds, 300, "a zero env override is ignored");
344 }
345}