1#![forbid(unsafe_code)]
4use std::collections::hash_map::DefaultHasher;
51use std::hash::{Hash, Hasher};
52use std::time::{Duration, Instant};
53
54use crate::constants::{
55 AGENT_RETRY_BASE_MS, AGENT_RETRY_MAX_DELAY_MS, AGENT_RETRY_MAX_RETRIES, HARD_RETRY_MAX_DELAY_MS,
56 HARD_RETRY_MAX_RETRIES,
57};
58use crate::errors::exit_codes;
59
60#[derive(Debug, Clone, Copy, PartialEq, Eq)]
65pub struct RetryConfig {
66 pub max_retries: u32,
68 pub base_ms: u64,
70 pub max_delay_ms: u64,
72 pub enabled: bool,
74}
75
76impl RetryConfig {
77 #[must_use]
82 pub const fn agent_default() -> Self {
83 Self {
84 max_retries: AGENT_RETRY_MAX_RETRIES,
85 base_ms: AGENT_RETRY_BASE_MS,
86 max_delay_ms: AGENT_RETRY_MAX_DELAY_MS,
87 enabled: true,
88 }
89 }
90
91 #[must_use]
93 pub const fn disabled() -> Self {
94 Self {
95 max_retries: 0,
96 base_ms: AGENT_RETRY_BASE_MS,
97 max_delay_ms: AGENT_RETRY_MAX_DELAY_MS,
98 enabled: false,
99 }
100 }
101
102 #[must_use]
104 pub fn clamped(self) -> Self {
105 let base_ms = if self.base_ms == 0 {
106 AGENT_RETRY_BASE_MS
107 } else {
108 self.base_ms
109 };
110 let max_delay_ms = self
111 .max_delay_ms
112 .min(HARD_RETRY_MAX_DELAY_MS)
113 .max(base_ms);
114 let max_retries = self.max_retries.min(HARD_RETRY_MAX_RETRIES);
115 let enabled = self.enabled && max_retries > 0;
116 Self {
117 max_retries,
118 base_ms,
119 max_delay_ms,
120 enabled,
121 }
122 }
123
124 #[must_use]
126 pub fn max_attempts(self) -> u32 {
127 let p = self.clamped();
128 if !p.enabled {
129 1
130 } else {
131 p.max_retries.saturating_add(1)
132 }
133 }
134
135 #[must_use]
137 pub fn may_retry(self, attempt: u32) -> bool {
138 let p = self.clamped();
139 p.enabled && attempt < p.max_attempts()
140 }
141
142 #[must_use]
144 pub fn delay_for_attempt(self, attempt: u32) -> Duration {
145 let p = self.clamped();
146 backoff_full_jitter(p.base_ms, attempt, p.max_delay_ms)
147 }
148}
149
150impl Default for RetryConfig {
151 fn default() -> Self {
152 Self::disabled()
154 }
155}
156
157#[must_use]
162pub fn backoff_full_jitter(base_ms: u64, attempt: u32, max_delay_ms: u64) -> Duration {
163 let base = base_ms.max(1);
164 let max_delay = max_delay_ms.max(base);
165 let exp = base.saturating_mul(1u64 << attempt.min(16));
166 let cap = exp.min(max_delay);
167 let pick = mix_u64(attempt) % (cap.saturating_add(1));
168 Duration::from_millis(pick)
169}
170
171#[must_use]
177pub fn exit_code_is_retryable(code: i32) -> bool {
178 code == exit_codes::EX_IOERR
179}
180
181#[must_use]
186pub fn wait_for_retry(policy: RetryConfig, attempt: u32, hint: Option<Duration>) -> Duration {
187 let p = policy.clamped();
188 if let Some(d) = hint {
189 return d.min(Duration::from_millis(p.max_delay_ms));
190 }
191 p.delay_for_attempt(attempt)
192}
193
194fn mix_u64(attempt: u32) -> u64 {
195 let mut h = DefaultHasher::new();
196 Instant::now().hash(&mut h);
197 attempt.hash(&mut h);
198 std::thread::current().id().hash(&mut h);
199 let marker = &h as *const DefaultHasher as usize;
200 marker.hash(&mut h);
201 h.finish()
202}
203
204#[cfg(test)]
205mod tests {
206 use super::*;
207
208 #[test]
209 fn default_policy_is_disabled() {
210 let p = RetryConfig::default();
211 assert!(!p.enabled);
212 assert_eq!(p.max_attempts(), 1);
213 assert!(!p.may_retry(1));
214 }
215
216 #[test]
217 fn agent_default_allows_two_retries() {
218 let p = RetryConfig::agent_default().clamped();
219 assert!(p.enabled);
220 assert_eq!(p.max_retries, 2);
221 assert_eq!(p.max_attempts(), 3);
222 assert!(p.may_retry(1));
223 assert!(p.may_retry(2));
224 assert!(!p.may_retry(3));
225 }
226
227 #[test]
228 fn kill_switch_zero_retries() {
229 let p = RetryConfig {
230 max_retries: 0,
231 base_ms: 200,
232 max_delay_ms: 5_000,
233 enabled: true,
234 }
235 .clamped();
236 assert!(!p.enabled);
237 assert_eq!(p.max_attempts(), 1);
238 }
239
240 #[test]
241 fn backoff_respects_cap() {
242 let d = backoff_full_jitter(200, 20, 1_000);
243 assert!(d.as_millis() <= 1_000);
244 }
245
246 #[test]
247 fn backoff_never_exceeds_max() {
248 for attempt in 0..20 {
249 let d = backoff_full_jitter(100, attempt, 500);
250 assert!(d.as_millis() <= 500, "attempt {attempt}: {d:?}");
251 }
252 }
253
254 #[test]
255 fn only_ioerr_exit_is_retryable() {
256 assert!(exit_code_is_retryable(exit_codes::EX_IOERR));
257 assert!(!exit_code_is_retryable(exit_codes::EX_OK));
258 assert!(!exit_code_is_retryable(exit_codes::EX_USAGE));
259 assert!(!exit_code_is_retryable(exit_codes::EX_DATAERR));
260 assert!(!exit_code_is_retryable(exit_codes::EX_NOINPUT));
261 assert!(!exit_code_is_retryable(exit_codes::EX_NOPERM));
262 assert!(!exit_code_is_retryable(exit_codes::EX_GENERAL));
263 assert!(!exit_code_is_retryable(exit_codes::EX_PIPE));
264 assert!(!exit_code_is_retryable(exit_codes::EX_SIGINT));
265 }
266
267 #[test]
268 fn wait_hint_caps_to_max_delay() {
269 let p = RetryConfig::agent_default();
270 let d = wait_for_retry(p, 1, Some(Duration::from_secs(3600)));
271 assert_eq!(d, Duration::from_millis(AGENT_RETRY_MAX_DELAY_MS));
272 }
273
274 #[test]
275 fn hard_caps_clamp_pathological_config() {
276 let p = RetryConfig {
277 max_retries: 10_000,
278 base_ms: 1,
279 max_delay_ms: u64::MAX,
280 enabled: true,
281 }
282 .clamped();
283 assert!(p.max_retries <= HARD_RETRY_MAX_RETRIES);
284 assert!(p.max_delay_ms <= HARD_RETRY_MAX_DELAY_MS);
285 }
286}