Skip to main content

oximedia_distributed/
task_retry.rs

1#![allow(dead_code)]
2//! Task retry logic with configurable backoff strategies for distributed jobs.
3//!
4//! Provides exponential backoff, linear backoff, and constant-interval retry
5//! policies. Tracks attempt history and enforces maximum retry limits.
6
7use std::fmt;
8use std::time::Duration;
9
10/// Backoff strategy for retries.
11#[derive(Debug, Clone, PartialEq)]
12pub enum BackoffStrategy {
13    /// Constant delay between retries.
14    Constant {
15        /// The fixed delay duration.
16        delay: Duration,
17    },
18    /// Linear increase: delay = base + attempt * step.
19    Linear {
20        /// Base delay for the first retry.
21        base: Duration,
22        /// Additive step per attempt.
23        step: Duration,
24    },
25    /// Exponential increase: delay = base * multiplier^attempt, capped at max.
26    Exponential {
27        /// Base delay for the first retry.
28        base: Duration,
29        /// Multiplier per attempt.
30        multiplier: f64,
31        /// Maximum delay cap.
32        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/// Configuration for retry behavior.
49#[derive(Debug, Clone)]
50pub struct RetryPolicy {
51    /// Maximum number of retry attempts (0 = no retries).
52    pub max_retries: u32,
53    /// Backoff strategy.
54    pub backoff: BackoffStrategy,
55    /// Whether to add jitter to computed delays.
56    pub jitter: bool,
57    /// Maximum jitter percentage (0..100).
58    pub jitter_percent: u32,
59    /// Set of error codes that are retryable (empty = all errors are retryable).
60    pub retryable_codes: Vec<String>,
61}
62
63impl RetryPolicy {
64    /// Create a new retry policy with exponential backoff defaults.
65    #[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    /// Set the backoff strategy.
81    #[must_use]
82    pub fn with_backoff(mut self, backoff: BackoffStrategy) -> Self {
83        self.backoff = backoff;
84        self
85    }
86
87    /// Enable jitter with the given percentage.
88    #[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    /// Add a retryable error code.
96    pub fn add_retryable_code(&mut self, code: &str) {
97        self.retryable_codes.push(code.to_string());
98    }
99
100    /// Check if an error code is retryable.
101    #[must_use]
102    pub fn is_retryable(&self, code: &str) -> bool {
103        if self.retryable_codes.is_empty() {
104            return true; // all errors retryable by default
105        }
106        self.retryable_codes.iter().any(|c| c == code)
107    }
108
109    /// Compute the delay for the given attempt number (0-based).
110    #[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    /// Check if another retry attempt is allowed.
130    #[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/// Outcome of a single task execution attempt.
143#[derive(Debug, Clone, PartialEq, Eq)]
144pub enum AttemptOutcome {
145    /// The attempt succeeded.
146    Success,
147    /// The attempt failed with the given error code and message.
148    Failed {
149        /// Error code.
150        code: String,
151        /// Error message.
152        message: String,
153    },
154    /// The attempt timed out.
155    Timeout,
156    /// The attempt was cancelled.
157    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/// Record of a single retry attempt.
172#[derive(Debug, Clone)]
173pub struct AttemptRecord {
174    /// Attempt number (0-based).
175    pub attempt: u32,
176    /// Outcome of this attempt.
177    pub outcome: AttemptOutcome,
178    /// Duration of this attempt.
179    pub duration_ms: u64,
180    /// The backoff delay that was waited before this attempt (0 for first attempt).
181    pub delay_before_ms: u64,
182}
183
184impl AttemptRecord {
185    /// Create a new attempt record.
186    #[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    /// Whether this attempt succeeded.
202    #[must_use]
203    pub fn is_success(&self) -> bool {
204        self.outcome == AttemptOutcome::Success
205    }
206}
207
208/// Tracks the retry state for a single task.
209#[derive(Debug, Clone)]
210pub struct RetryTracker {
211    /// The task identifier.
212    pub task_id: String,
213    /// Retry policy in use.
214    pub policy: RetryPolicy,
215    /// History of attempts.
216    pub history: Vec<AttemptRecord>,
217    /// Whether the task has been exhausted (no more retries).
218    pub exhausted: bool,
219    /// Whether the task ultimately succeeded.
220    pub succeeded: bool,
221}
222
223impl RetryTracker {
224    /// Create a new retry tracker for a task.
225    #[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    /// Record an attempt and determine the next action.
237    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        // Check retryable
263        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        // Check if we can retry
271        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    /// Number of attempts made.
281    #[must_use]
282    pub fn attempt_count(&self) -> usize {
283        self.history.len()
284    }
285
286    /// Total duration across all attempts (excluding delays).
287    #[must_use]
288    pub fn total_attempt_duration_ms(&self) -> u64 {
289        self.history.iter().map(|r| r.duration_ms).sum()
290    }
291
292    /// Total delay time waited across all attempts.
293    #[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/// Decision returned after recording an attempt.
300#[derive(Debug, Clone, PartialEq)]
301pub enum RetryDecision {
302    /// Task completed successfully, no retry needed.
303    Done,
304    /// Retry after the given delay.
305    RetryAfter(Duration),
306    /// All retries exhausted, task permanently failed.
307    Exhausted,
308    /// Task aborted due to non-retryable error or cancellation.
309    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        // 1000 * 3^5 = 243000 ms, but capped at 5000ms
369        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}