1#![allow(dead_code)]
2use std::fmt;
8use std::time::Duration;
9
10#[derive(Debug, Clone, PartialEq)]
12pub enum BackoffStrategy {
13 Constant {
15 delay: Duration,
17 },
18 Linear {
20 base: Duration,
22 step: Duration,
24 },
25 Exponential {
27 base: Duration,
29 multiplier: f64,
31 max_delay: Duration,
33 },
34}
35
36impl fmt::Display for BackoffStrategy {
37 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
38 match self {
39 Self::Constant { delay } => write!(f, "Constant({delay:?})"),
40 Self::Linear { base, step } => write!(f, "Linear(base={base:?}, step={step:?})"),
41 Self::Exponential {
42 base, multiplier, ..
43 } => write!(f, "Exponential(base={base:?}, mult={multiplier:.1})"),
44 }
45 }
46}
47
48#[derive(Debug, Clone)]
50pub struct RetryPolicy {
51 pub max_retries: u32,
53 pub backoff: BackoffStrategy,
55 pub jitter: bool,
57 pub jitter_percent: u32,
59 pub retryable_codes: Vec<String>,
61}
62
63impl RetryPolicy {
64 #[must_use]
66 pub fn new(max_retries: u32) -> Self {
67 Self {
68 max_retries,
69 backoff: BackoffStrategy::Exponential {
70 base: Duration::from_millis(100),
71 multiplier: 2.0,
72 max_delay: Duration::from_secs(30),
73 },
74 jitter: false,
75 jitter_percent: 20,
76 retryable_codes: Vec::new(),
77 }
78 }
79
80 #[must_use]
82 pub fn with_backoff(mut self, backoff: BackoffStrategy) -> Self {
83 self.backoff = backoff;
84 self
85 }
86
87 #[must_use]
89 pub fn with_jitter(mut self, percent: u32) -> Self {
90 self.jitter = true;
91 self.jitter_percent = percent.min(100);
92 self
93 }
94
95 pub fn add_retryable_code(&mut self, code: &str) {
97 self.retryable_codes.push(code.to_string());
98 }
99
100 #[must_use]
102 pub fn is_retryable(&self, code: &str) -> bool {
103 if self.retryable_codes.is_empty() {
104 return true; }
106 self.retryable_codes.iter().any(|c| c == code)
107 }
108
109 #[allow(clippy::cast_precision_loss)]
111 #[must_use]
112 pub fn compute_delay(&self, attempt: u32) -> Duration {
113 match &self.backoff {
114 BackoffStrategy::Constant { delay } => *delay,
115 BackoffStrategy::Linear { base, step } => *base + *step * attempt,
116 BackoffStrategy::Exponential {
117 base,
118 multiplier,
119 max_delay,
120 } => {
121 let base_ms = base.as_millis() as f64;
122 let computed = base_ms * multiplier.powi(attempt as i32);
123 let capped = computed.min(max_delay.as_millis() as f64);
124 Duration::from_millis(capped as u64)
125 }
126 }
127 }
128
129 #[must_use]
131 pub fn can_retry(&self, attempts_so_far: u32) -> bool {
132 attempts_so_far < self.max_retries
133 }
134}
135
136impl Default for RetryPolicy {
137 fn default() -> Self {
138 Self::new(3)
139 }
140}
141
142#[derive(Debug, Clone, PartialEq, Eq)]
144pub enum AttemptOutcome {
145 Success,
147 Failed {
149 code: String,
151 message: String,
153 },
154 Timeout,
156 Cancelled,
158}
159
160impl fmt::Display for AttemptOutcome {
161 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
162 match self {
163 Self::Success => write!(f, "Success"),
164 Self::Failed { code, message } => write!(f, "Failed({code}): {message}"),
165 Self::Timeout => write!(f, "Timeout"),
166 Self::Cancelled => write!(f, "Cancelled"),
167 }
168 }
169}
170
171#[derive(Debug, Clone)]
173pub struct AttemptRecord {
174 pub attempt: u32,
176 pub outcome: AttemptOutcome,
178 pub duration_ms: u64,
180 pub delay_before_ms: u64,
182}
183
184impl AttemptRecord {
185 #[must_use]
187 pub fn new(
188 attempt: u32,
189 outcome: AttemptOutcome,
190 duration_ms: u64,
191 delay_before_ms: u64,
192 ) -> Self {
193 Self {
194 attempt,
195 outcome,
196 duration_ms,
197 delay_before_ms,
198 }
199 }
200
201 #[must_use]
203 pub fn is_success(&self) -> bool {
204 self.outcome == AttemptOutcome::Success
205 }
206}
207
208#[derive(Debug, Clone)]
210pub struct RetryTracker {
211 pub task_id: String,
213 pub policy: RetryPolicy,
215 pub history: Vec<AttemptRecord>,
217 pub exhausted: bool,
219 pub succeeded: bool,
221}
222
223impl RetryTracker {
224 #[must_use]
226 pub fn new(task_id: &str, policy: RetryPolicy) -> Self {
227 Self {
228 task_id: task_id.to_string(),
229 policy,
230 history: Vec::new(),
231 exhausted: false,
232 succeeded: false,
233 }
234 }
235
236 pub fn record_attempt(&mut self, outcome: AttemptOutcome, duration_ms: u64) -> RetryDecision {
238 let attempt_num = self.history.len() as u32;
239 let delay_before = if attempt_num == 0 {
240 0
241 } else {
242 self.policy.compute_delay(attempt_num - 1).as_millis() as u64
243 };
244
245 self.history.push(AttemptRecord::new(
246 attempt_num,
247 outcome.clone(),
248 duration_ms,
249 delay_before,
250 ));
251
252 if outcome == AttemptOutcome::Success {
253 self.succeeded = true;
254 return RetryDecision::Done;
255 }
256
257 if outcome == AttemptOutcome::Cancelled {
258 self.exhausted = true;
259 return RetryDecision::Abort("Cancelled by user".to_string());
260 }
261
262 if let AttemptOutcome::Failed { ref code, .. } = outcome {
264 if !self.policy.is_retryable(code) {
265 self.exhausted = true;
266 return RetryDecision::Abort(format!("Error code '{code}' is not retryable"));
267 }
268 }
269
270 if self.policy.can_retry(attempt_num + 1) {
272 let delay = self.policy.compute_delay(attempt_num);
273 RetryDecision::RetryAfter(delay)
274 } else {
275 self.exhausted = true;
276 RetryDecision::Exhausted
277 }
278 }
279
280 #[must_use]
282 pub fn attempt_count(&self) -> usize {
283 self.history.len()
284 }
285
286 #[must_use]
288 pub fn total_attempt_duration_ms(&self) -> u64 {
289 self.history.iter().map(|r| r.duration_ms).sum()
290 }
291
292 #[must_use]
294 pub fn total_delay_ms(&self) -> u64 {
295 self.history.iter().map(|r| r.delay_before_ms).sum()
296 }
297}
298
299#[derive(Debug, Clone, PartialEq)]
301pub enum RetryDecision {
302 Done,
304 RetryAfter(Duration),
306 Exhausted,
308 Abort(String),
310}
311
312impl fmt::Display for RetryDecision {
313 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
314 match self {
315 Self::Done => write!(f, "Done"),
316 Self::RetryAfter(d) => write!(f, "RetryAfter({d:?})"),
317 Self::Exhausted => write!(f, "Exhausted"),
318 Self::Abort(reason) => write!(f, "Abort({reason})"),
319 }
320 }
321}
322
323#[cfg(test)]
324mod tests {
325 use super::*;
326
327 #[test]
328 fn test_constant_backoff() {
329 let policy = RetryPolicy::new(3).with_backoff(BackoffStrategy::Constant {
330 delay: Duration::from_millis(500),
331 });
332 assert_eq!(policy.compute_delay(0), Duration::from_millis(500));
333 assert_eq!(policy.compute_delay(1), Duration::from_millis(500));
334 assert_eq!(policy.compute_delay(5), Duration::from_millis(500));
335 }
336
337 #[test]
338 fn test_linear_backoff() {
339 let policy = RetryPolicy::new(5).with_backoff(BackoffStrategy::Linear {
340 base: Duration::from_millis(100),
341 step: Duration::from_millis(200),
342 });
343 assert_eq!(policy.compute_delay(0), Duration::from_millis(100));
344 assert_eq!(policy.compute_delay(1), Duration::from_millis(300));
345 assert_eq!(policy.compute_delay(2), Duration::from_millis(500));
346 }
347
348 #[test]
349 fn test_exponential_backoff() {
350 let policy = RetryPolicy::new(5).with_backoff(BackoffStrategy::Exponential {
351 base: Duration::from_millis(100),
352 multiplier: 2.0,
353 max_delay: Duration::from_secs(10),
354 });
355 assert_eq!(policy.compute_delay(0), Duration::from_millis(100));
356 assert_eq!(policy.compute_delay(1), Duration::from_millis(200));
357 assert_eq!(policy.compute_delay(2), Duration::from_millis(400));
358 assert_eq!(policy.compute_delay(3), Duration::from_millis(800));
359 }
360
361 #[test]
362 fn test_exponential_backoff_cap() {
363 let policy = RetryPolicy::new(10).with_backoff(BackoffStrategy::Exponential {
364 base: Duration::from_secs(1),
365 multiplier: 3.0,
366 max_delay: Duration::from_secs(5),
367 });
368 assert_eq!(policy.compute_delay(5), Duration::from_secs(5));
370 }
371
372 #[test]
373 fn test_can_retry() {
374 let policy = RetryPolicy::new(3);
375 assert!(policy.can_retry(0));
376 assert!(policy.can_retry(1));
377 assert!(policy.can_retry(2));
378 assert!(!policy.can_retry(3));
379 assert!(!policy.can_retry(4));
380 }
381
382 #[test]
383 fn test_retryable_codes_default_all() {
384 let policy = RetryPolicy::new(3);
385 assert!(policy.is_retryable("any_code"));
386 assert!(policy.is_retryable("another_code"));
387 }
388
389 #[test]
390 fn test_retryable_codes_specific() {
391 let mut policy = RetryPolicy::new(3);
392 policy.add_retryable_code("TIMEOUT");
393 policy.add_retryable_code("UNAVAILABLE");
394 assert!(policy.is_retryable("TIMEOUT"));
395 assert!(policy.is_retryable("UNAVAILABLE"));
396 assert!(!policy.is_retryable("PERMISSION_DENIED"));
397 }
398
399 #[test]
400 fn test_retry_tracker_success_first_attempt() {
401 let policy = RetryPolicy::new(3);
402 let mut tracker = RetryTracker::new("task-1", policy);
403 let decision = tracker.record_attempt(AttemptOutcome::Success, 100);
404 assert_eq!(decision, RetryDecision::Done);
405 assert!(tracker.succeeded);
406 assert!(!tracker.exhausted);
407 assert_eq!(tracker.attempt_count(), 1);
408 }
409
410 #[test]
411 fn test_retry_tracker_fail_then_succeed() {
412 let policy = RetryPolicy::new(3);
413 let mut tracker = RetryTracker::new("task-2", policy);
414
415 let d1 = tracker.record_attempt(
416 AttemptOutcome::Failed {
417 code: "ERR".to_string(),
418 message: "boom".to_string(),
419 },
420 50,
421 );
422 assert!(matches!(d1, RetryDecision::RetryAfter(_)));
423
424 let d2 = tracker.record_attempt(AttemptOutcome::Success, 80);
425 assert_eq!(d2, RetryDecision::Done);
426 assert!(tracker.succeeded);
427 assert_eq!(tracker.attempt_count(), 2);
428 }
429
430 #[test]
431 fn test_retry_tracker_exhausted() {
432 let policy = RetryPolicy::new(2);
433 let mut tracker = RetryTracker::new("task-3", policy);
434
435 let fail = AttemptOutcome::Failed {
436 code: "ERR".to_string(),
437 message: "fail".to_string(),
438 };
439 tracker.record_attempt(fail.clone(), 10);
440 tracker.record_attempt(fail.clone(), 10);
441 let d = tracker.record_attempt(fail, 10);
442 assert_eq!(d, RetryDecision::Exhausted);
443 assert!(tracker.exhausted);
444 assert!(!tracker.succeeded);
445 }
446
447 #[test]
448 fn test_retry_tracker_cancelled() {
449 let policy = RetryPolicy::new(5);
450 let mut tracker = RetryTracker::new("task-4", policy);
451 let d = tracker.record_attempt(AttemptOutcome::Cancelled, 0);
452 assert!(matches!(d, RetryDecision::Abort(_)));
453 assert!(tracker.exhausted);
454 }
455
456 #[test]
457 fn test_retry_tracker_non_retryable_code() {
458 let mut policy = RetryPolicy::new(5);
459 policy.add_retryable_code("TIMEOUT");
460
461 let mut tracker = RetryTracker::new("task-5", policy);
462 let d = tracker.record_attempt(
463 AttemptOutcome::Failed {
464 code: "PERMISSION_DENIED".to_string(),
465 message: "no access".to_string(),
466 },
467 20,
468 );
469 assert!(matches!(d, RetryDecision::Abort(_)));
470 }
471
472 #[test]
473 fn test_retry_tracker_total_durations() {
474 let policy = RetryPolicy::new(3);
475 let mut tracker = RetryTracker::new("task-6", policy);
476 tracker.record_attempt(
477 AttemptOutcome::Failed {
478 code: "E".to_string(),
479 message: "".to_string(),
480 },
481 100,
482 );
483 tracker.record_attempt(AttemptOutcome::Success, 200);
484 assert_eq!(tracker.total_attempt_duration_ms(), 300);
485 }
486
487 #[test]
488 fn test_attempt_outcome_display() {
489 assert_eq!(AttemptOutcome::Success.to_string(), "Success");
490 assert_eq!(AttemptOutcome::Timeout.to_string(), "Timeout");
491 assert_eq!(AttemptOutcome::Cancelled.to_string(), "Cancelled");
492 }
493
494 #[test]
495 fn test_backoff_strategy_display() {
496 let c = BackoffStrategy::Constant {
497 delay: Duration::from_millis(100),
498 };
499 assert!(c.to_string().contains("Constant"));
500
501 let l = BackoffStrategy::Linear {
502 base: Duration::from_millis(50),
503 step: Duration::from_millis(100),
504 };
505 assert!(l.to_string().contains("Linear"));
506 }
507
508 #[test]
509 fn test_retry_decision_display() {
510 assert_eq!(RetryDecision::Done.to_string(), "Done");
511 assert_eq!(RetryDecision::Exhausted.to_string(), "Exhausted");
512 }
513
514 #[test]
515 fn test_retry_policy_default() {
516 let policy = RetryPolicy::default();
517 assert_eq!(policy.max_retries, 3);
518 }
519
520 #[test]
521 fn test_attempt_record_is_success() {
522 let success = AttemptRecord::new(0, AttemptOutcome::Success, 50, 0);
523 assert!(success.is_success());
524 let fail = AttemptRecord::new(0, AttemptOutcome::Timeout, 50, 0);
525 assert!(!fail.is_success());
526 }
527
528 #[test]
529 fn test_jitter_config() {
530 let policy = RetryPolicy::new(3).with_jitter(30);
531 assert!(policy.jitter);
532 assert_eq!(policy.jitter_percent, 30);
533 }
534
535 #[test]
536 fn test_jitter_capped_at_100() {
537 let policy = RetryPolicy::new(3).with_jitter(200);
538 assert_eq!(policy.jitter_percent, 100);
539 }
540}