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,
56 HARD_RETRY_MAX_DELAY_MS, 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.max_delay_ms.min(HARD_RETRY_MAX_DELAY_MS).max(base_ms);
111 let max_retries = self.max_retries.min(HARD_RETRY_MAX_RETRIES);
112 let enabled = self.enabled && max_retries > 0;
113 Self {
114 max_retries,
115 base_ms,
116 max_delay_ms,
117 enabled,
118 }
119 }
120
121 #[must_use]
123 pub fn max_attempts(self) -> u32 {
124 let p = self.clamped();
125 if !p.enabled {
126 1
127 } else {
128 p.max_retries.saturating_add(1)
129 }
130 }
131
132 #[must_use]
134 pub fn may_retry(self, attempt: u32) -> bool {
135 let p = self.clamped();
136 p.enabled && attempt < p.max_attempts()
137 }
138
139 #[must_use]
141 pub fn delay_for_attempt(self, attempt: u32) -> Duration {
142 let p = self.clamped();
143 backoff_full_jitter(p.base_ms, attempt, p.max_delay_ms)
144 }
145}
146
147impl Default for RetryConfig {
148 fn default() -> Self {
149 Self::disabled()
151 }
152}
153
154#[must_use]
159pub fn backoff_full_jitter(base_ms: u64, attempt: u32, max_delay_ms: u64) -> Duration {
160 let base = base_ms.max(1);
161 let max_delay = max_delay_ms.max(base);
162 let exp = base.saturating_mul(1u64 << attempt.min(16));
163 let cap = exp.min(max_delay);
164 let pick = mix_u64(attempt) % (cap.saturating_add(1));
165 Duration::from_millis(pick)
166}
167
168#[must_use]
174pub fn exit_code_is_retryable(code: i32) -> bool {
175 code == exit_codes::EX_IOERR
176}
177
178#[must_use]
183pub fn wait_for_retry(policy: RetryConfig, attempt: u32, hint: Option<Duration>) -> Duration {
184 let p = policy.clamped();
185 if let Some(d) = hint {
186 return d.min(Duration::from_millis(p.max_delay_ms));
187 }
188 p.delay_for_attempt(attempt)
189}
190
191fn mix_u64(attempt: u32) -> u64 {
192 let mut h = DefaultHasher::new();
193 Instant::now().hash(&mut h);
194 attempt.hash(&mut h);
195 std::thread::current().id().hash(&mut h);
196 let marker = &h as *const DefaultHasher as usize;
197 marker.hash(&mut h);
198 h.finish()
199}
200
201#[cfg(test)]
202mod tests {
203 use super::*;
204
205 #[test]
206 fn default_policy_is_disabled() {
207 let p = RetryConfig::default();
208 assert!(!p.enabled);
209 assert_eq!(p.max_attempts(), 1);
210 assert!(!p.may_retry(1));
211 }
212
213 #[test]
214 fn agent_default_allows_two_retries() {
215 let p = RetryConfig::agent_default().clamped();
216 assert!(p.enabled);
217 assert_eq!(p.max_retries, 2);
218 assert_eq!(p.max_attempts(), 3);
219 assert!(p.may_retry(1));
220 assert!(p.may_retry(2));
221 assert!(!p.may_retry(3));
222 }
223
224 #[test]
225 fn kill_switch_zero_retries() {
226 let p = RetryConfig {
227 max_retries: 0,
228 base_ms: 200,
229 max_delay_ms: 5_000,
230 enabled: true,
231 }
232 .clamped();
233 assert!(!p.enabled);
234 assert_eq!(p.max_attempts(), 1);
235 }
236
237 #[test]
238 fn backoff_respects_cap() {
239 let d = backoff_full_jitter(200, 20, 1_000);
240 assert!(d.as_millis() <= 1_000);
241 }
242
243 #[test]
244 fn backoff_never_exceeds_max() {
245 for attempt in 0..20 {
246 let d = backoff_full_jitter(100, attempt, 500);
247 assert!(d.as_millis() <= 500, "attempt {attempt}: {d:?}");
248 }
249 }
250
251 #[test]
252 fn only_ioerr_exit_is_retryable() {
253 assert!(exit_code_is_retryable(exit_codes::EX_IOERR));
254 assert!(!exit_code_is_retryable(exit_codes::EX_OK));
255 assert!(!exit_code_is_retryable(exit_codes::EX_USAGE));
256 assert!(!exit_code_is_retryable(exit_codes::EX_DATAERR));
257 assert!(!exit_code_is_retryable(exit_codes::EX_NOINPUT));
258 assert!(!exit_code_is_retryable(exit_codes::EX_NOPERM));
259 assert!(!exit_code_is_retryable(exit_codes::EX_GENERAL));
260 assert!(!exit_code_is_retryable(exit_codes::EX_PIPE));
261 assert!(!exit_code_is_retryable(exit_codes::EX_SIGINT));
262 }
263
264 #[test]
265 fn wait_hint_caps_to_max_delay() {
266 let p = RetryConfig::agent_default();
267 let d = wait_for_retry(p, 1, Some(Duration::from_secs(3600)));
268 assert_eq!(d, Duration::from_millis(AGENT_RETRY_MAX_DELAY_MS));
269 }
270
271 #[test]
272 fn hard_caps_clamp_pathological_config() {
273 let p = RetryConfig {
274 max_retries: 10_000,
275 base_ms: 1,
276 max_delay_ms: u64::MAX,
277 enabled: true,
278 }
279 .clamped();
280 assert!(p.max_retries <= HARD_RETRY_MAX_RETRIES);
281 assert!(p.max_delay_ms <= HARD_RETRY_MAX_DELAY_MS);
282 }
283}