Skip to main content

oximedia_workflow/
workflow_retry.rs

1#![allow(dead_code)]
2//! Advanced retry orchestration for workflow tasks.
3//!
4//! This module provides sophisticated retry strategies beyond simple count-based
5//! retries, including exponential backoff, circuit breakers, and retry budgets
6//! that prevent cascading failures in large workflow DAGs.
7
8use std::collections::HashMap;
9use std::time::{Duration, Instant};
10
11/// Backoff strategy for retry delays.
12#[derive(Debug, Clone, PartialEq)]
13pub enum BackoffStrategy {
14    /// Constant delay between retries.
15    Constant(Duration),
16    /// Linearly increasing delay: `base * attempt`.
17    Linear {
18        /// Base delay multiplied by the attempt number.
19        base: Duration,
20    },
21    /// Exponentially increasing delay: `base * 2^attempt`, capped at `max`.
22    Exponential {
23        /// Initial delay.
24        base: Duration,
25        /// Maximum delay cap.
26        max: Duration,
27    },
28    /// Fibonacci-sequence based delay: fib(attempt) * base.
29    Fibonacci {
30        /// Base unit of delay.
31        base: Duration,
32    },
33}
34
35impl BackoffStrategy {
36    /// Compute the delay for a given attempt number (0-indexed).
37    #[allow(clippy::cast_precision_loss)]
38    pub fn delay_for_attempt(&self, attempt: u32) -> Duration {
39        match self {
40            Self::Constant(d) => *d,
41            Self::Linear { base } => *base * attempt.max(1),
42            Self::Exponential { base, max } => {
43                let multiplier = 2u64.saturating_pow(attempt);
44                let delay = base.saturating_mul(multiplier as u32);
45                if delay > *max {
46                    *max
47                } else {
48                    delay
49                }
50            }
51            Self::Fibonacci { base } => {
52                let fib = fibonacci(attempt);
53                base.saturating_mul(fib)
54            }
55        }
56    }
57}
58
59/// Compute the n-th Fibonacci number (capped to avoid overflow).
60fn fibonacci(n: u32) -> u32 {
61    if n == 0 {
62        return 1;
63    }
64    let mut a: u32 = 1;
65    let mut b: u32 = 1;
66    for _ in 1..n {
67        let next = a.saturating_add(b);
68        a = b;
69        b = next;
70    }
71    b
72}
73
74/// Circuit breaker state for a task or task group.
75#[derive(Debug, Clone, PartialEq)]
76pub enum CircuitState {
77    /// Normal operation; requests pass through.
78    Closed,
79    /// Failures exceeded threshold; all requests are rejected.
80    Open {
81        /// When the circuit was opened.
82        opened_at: Instant,
83        /// How long to wait before transitioning to half-open.
84        cooldown: Duration,
85    },
86    /// Testing phase; limited requests allowed.
87    HalfOpen {
88        /// Number of successes required to close the circuit.
89        successes_needed: u32,
90        /// Current number of consecutive successes.
91        current_successes: u32,
92    },
93}
94
95/// Circuit breaker configuration and state.
96#[derive(Debug, Clone)]
97pub struct CircuitBreaker {
98    /// Failure threshold before opening the circuit.
99    pub failure_threshold: u32,
100    /// Duration to wait before half-opening.
101    pub cooldown: Duration,
102    /// Number of successes in half-open state to close.
103    pub recovery_threshold: u32,
104    /// Current consecutive failure count.
105    pub consecutive_failures: u32,
106    /// Current state.
107    pub state: CircuitState,
108}
109
110impl CircuitBreaker {
111    /// Create a new circuit breaker with the given thresholds.
112    pub fn new(failure_threshold: u32, cooldown: Duration, recovery_threshold: u32) -> Self {
113        Self {
114            failure_threshold,
115            cooldown,
116            recovery_threshold,
117            consecutive_failures: 0,
118            state: CircuitState::Closed,
119        }
120    }
121
122    /// Check whether a request should be allowed.
123    pub fn allow_request(&mut self) -> bool {
124        match &self.state {
125            CircuitState::Closed => true,
126            CircuitState::Open {
127                opened_at,
128                cooldown,
129            } => {
130                if opened_at.elapsed() >= *cooldown {
131                    self.state = CircuitState::HalfOpen {
132                        successes_needed: self.recovery_threshold,
133                        current_successes: 0,
134                    };
135                    true
136                } else {
137                    false
138                }
139            }
140            CircuitState::HalfOpen { .. } => true,
141        }
142    }
143
144    /// Record a successful request.
145    pub fn record_success(&mut self) {
146        self.consecutive_failures = 0;
147        match &self.state {
148            CircuitState::HalfOpen {
149                successes_needed,
150                current_successes,
151            } => {
152                let next = current_successes + 1;
153                if next >= *successes_needed {
154                    self.state = CircuitState::Closed;
155                } else {
156                    self.state = CircuitState::HalfOpen {
157                        successes_needed: *successes_needed,
158                        current_successes: next,
159                    };
160                }
161            }
162            _ => {
163                self.state = CircuitState::Closed;
164            }
165        }
166    }
167
168    /// Record a failed request.
169    pub fn record_failure(&mut self) {
170        self.consecutive_failures += 1;
171        match &self.state {
172            CircuitState::Closed => {
173                if self.consecutive_failures >= self.failure_threshold {
174                    self.state = CircuitState::Open {
175                        opened_at: Instant::now(),
176                        cooldown: self.cooldown,
177                    };
178                }
179            }
180            CircuitState::HalfOpen { .. } => {
181                // Any failure in half-open re-opens the circuit.
182                self.state = CircuitState::Open {
183                    opened_at: Instant::now(),
184                    cooldown: self.cooldown,
185                };
186            }
187            CircuitState::Open { .. } => {}
188        }
189    }
190}
191
192/// A budget that limits how many retries can occur within a time window.
193#[derive(Debug, Clone)]
194pub struct RetryBudget {
195    /// Maximum retries allowed within the window.
196    pub max_retries: u32,
197    /// The time window length.
198    pub window: Duration,
199    /// Timestamps of retries within the window.
200    entries: Vec<Instant>,
201}
202
203impl RetryBudget {
204    /// Create a new retry budget.
205    pub fn new(max_retries: u32, window: Duration) -> Self {
206        Self {
207            max_retries,
208            window,
209            entries: Vec::new(),
210        }
211    }
212
213    /// Check if a retry is allowed and, if so, record it.
214    pub fn try_acquire(&mut self) -> bool {
215        let now = Instant::now();
216        self.entries
217            .retain(|t| now.duration_since(*t) < self.window);
218        if self.entries.len() < self.max_retries as usize {
219            self.entries.push(now);
220            true
221        } else {
222            false
223        }
224    }
225
226    /// Return how many retries remain in the current window.
227    pub fn remaining(&mut self) -> u32 {
228        let now = Instant::now();
229        self.entries
230            .retain(|t| now.duration_since(*t) < self.window);
231        self.max_retries.saturating_sub(self.entries.len() as u32)
232    }
233}
234
235/// Outcome of a single retry attempt.
236#[derive(Debug, Clone, PartialEq)]
237pub enum RetryOutcome {
238    /// The task succeeded.
239    Success,
240    /// The task failed but should be retried.
241    RetryableFailure(String),
242    /// The task failed and should not be retried.
243    PermanentFailure(String),
244}
245
246/// An orchestrator that combines backoff, circuit breaker, and retry budget.
247#[derive(Debug)]
248pub struct RetryOrchestrator {
249    /// Backoff strategy.
250    pub backoff: BackoffStrategy,
251    /// Maximum number of attempts.
252    pub max_attempts: u32,
253    /// Optional circuit breaker per task key.
254    breakers: HashMap<String, CircuitBreaker>,
255    /// Default circuit breaker config.
256    breaker_config: Option<(u32, Duration, u32)>,
257    /// Global retry budget.
258    pub budget: Option<RetryBudget>,
259    /// Current attempt count per task.
260    attempts: HashMap<String, u32>,
261}
262
263impl RetryOrchestrator {
264    /// Create a new retry orchestrator with the given backoff and max attempts.
265    pub fn new(backoff: BackoffStrategy, max_attempts: u32) -> Self {
266        Self {
267            backoff,
268            max_attempts,
269            breakers: HashMap::new(),
270            breaker_config: None,
271            budget: None,
272            attempts: HashMap::new(),
273        }
274    }
275
276    /// Enable circuit breakers with the given configuration.
277    pub fn with_circuit_breaker(
278        mut self,
279        failure_threshold: u32,
280        cooldown: Duration,
281        recovery_threshold: u32,
282    ) -> Self {
283        self.breaker_config = Some((failure_threshold, cooldown, recovery_threshold));
284        self
285    }
286
287    /// Enable a global retry budget.
288    pub fn with_budget(mut self, max_retries: u32, window: Duration) -> Self {
289        self.budget = Some(RetryBudget::new(max_retries, window));
290        self
291    }
292
293    /// Get or create a circuit breaker for the given task key.
294    fn get_breaker(&mut self, key: &str) -> Option<&mut CircuitBreaker> {
295        if let Some((ft, cd, rt)) = self.breaker_config {
296            if !self.breakers.contains_key(key) {
297                self.breakers
298                    .insert(key.to_string(), CircuitBreaker::new(ft, cd, rt));
299            }
300            self.breakers.get_mut(key)
301        } else {
302            None
303        }
304    }
305
306    /// Determine if a retry should be attempted for the given task.
307    ///
308    /// Returns `Some(delay)` if a retry is allowed, or `None` if retries
309    /// are exhausted or blocked.
310    pub fn should_retry(&mut self, task_key: &str, outcome: &RetryOutcome) -> Option<Duration> {
311        if *outcome == RetryOutcome::Success {
312            if let Some(breaker) = self.get_breaker(task_key) {
313                breaker.record_success();
314            }
315            self.attempts.remove(task_key);
316            return None;
317        }
318
319        if let RetryOutcome::PermanentFailure(_) = outcome {
320            if let Some(breaker) = self.get_breaker(task_key) {
321                breaker.record_failure();
322            }
323            self.attempts.remove(task_key);
324            return None;
325        }
326
327        // Check circuit breaker.
328        if let Some(breaker) = self.get_breaker(task_key) {
329            breaker.record_failure();
330            if !breaker.allow_request() {
331                return None;
332            }
333        }
334
335        // Check attempt count.
336        let attempt = self.attempts.entry(task_key.to_string()).or_insert(0);
337        *attempt += 1;
338        if *attempt >= self.max_attempts {
339            return None;
340        }
341
342        // Check budget.
343        if let Some(ref mut budget) = self.budget {
344            if !budget.try_acquire() {
345                return None;
346            }
347        }
348
349        Some(self.backoff.delay_for_attempt(*attempt))
350    }
351
352    /// Reset all state for a task key.
353    pub fn reset(&mut self, task_key: &str) {
354        self.attempts.remove(task_key);
355        self.breakers.remove(task_key);
356    }
357
358    /// Get the current attempt count for a task.
359    pub fn attempt_count(&self, task_key: &str) -> u32 {
360        self.attempts.get(task_key).copied().unwrap_or(0)
361    }
362}
363
364#[cfg(test)]
365mod tests {
366    use super::*;
367
368    #[test]
369    fn test_constant_backoff() {
370        let strategy = BackoffStrategy::Constant(Duration::from_secs(5));
371        assert_eq!(strategy.delay_for_attempt(0), Duration::from_secs(5));
372        assert_eq!(strategy.delay_for_attempt(3), Duration::from_secs(5));
373        assert_eq!(strategy.delay_for_attempt(100), Duration::from_secs(5));
374    }
375
376    #[test]
377    fn test_linear_backoff() {
378        let strategy = BackoffStrategy::Linear {
379            base: Duration::from_secs(2),
380        };
381        assert_eq!(strategy.delay_for_attempt(0), Duration::from_secs(2));
382        assert_eq!(strategy.delay_for_attempt(1), Duration::from_secs(2));
383        assert_eq!(strategy.delay_for_attempt(3), Duration::from_secs(6));
384        assert_eq!(strategy.delay_for_attempt(5), Duration::from_secs(10));
385    }
386
387    #[test]
388    fn test_exponential_backoff() {
389        let strategy = BackoffStrategy::Exponential {
390            base: Duration::from_millis(100),
391            max: Duration::from_secs(10),
392        };
393        assert_eq!(strategy.delay_for_attempt(0), Duration::from_millis(100));
394        assert_eq!(strategy.delay_for_attempt(1), Duration::from_millis(200));
395        assert_eq!(strategy.delay_for_attempt(2), Duration::from_millis(400));
396        // Should be capped at max
397        assert!(strategy.delay_for_attempt(30) <= Duration::from_secs(10));
398    }
399
400    #[test]
401    fn test_fibonacci_backoff() {
402        let strategy = BackoffStrategy::Fibonacci {
403            base: Duration::from_millis(100),
404        };
405        // fib: 1, 1, 2, 3, 5, 8, 13, ...
406        assert_eq!(strategy.delay_for_attempt(0), Duration::from_millis(100));
407        assert_eq!(strategy.delay_for_attempt(1), Duration::from_millis(100));
408        assert_eq!(strategy.delay_for_attempt(2), Duration::from_millis(200));
409        assert_eq!(strategy.delay_for_attempt(3), Duration::from_millis(300));
410        assert_eq!(strategy.delay_for_attempt(4), Duration::from_millis(500));
411    }
412
413    #[test]
414    fn test_fibonacci_function() {
415        assert_eq!(fibonacci(0), 1);
416        assert_eq!(fibonacci(1), 1);
417        assert_eq!(fibonacci(2), 2);
418        assert_eq!(fibonacci(3), 3);
419        assert_eq!(fibonacci(4), 5);
420        assert_eq!(fibonacci(5), 8);
421        assert_eq!(fibonacci(6), 13);
422    }
423
424    #[test]
425    fn test_circuit_breaker_closed() {
426        let mut cb = CircuitBreaker::new(3, Duration::from_secs(10), 2);
427        assert!(cb.allow_request());
428        cb.record_failure();
429        assert!(cb.allow_request());
430        cb.record_failure();
431        assert!(cb.allow_request());
432        // Third failure should open the circuit.
433        cb.record_failure();
434        assert!(!cb.allow_request());
435    }
436
437    #[test]
438    fn test_circuit_breaker_recovery() {
439        let mut cb = CircuitBreaker::new(2, Duration::from_millis(1), 1);
440        cb.record_failure();
441        cb.record_failure();
442        // Circuit is open.
443        assert!(!cb.allow_request());
444        // Wait for cooldown.
445        std::thread::sleep(Duration::from_millis(5));
446        // Should transition to half-open.
447        assert!(cb.allow_request());
448        cb.record_success();
449        assert_eq!(cb.state, CircuitState::Closed);
450    }
451
452    #[test]
453    fn test_circuit_breaker_half_open_failure() {
454        let mut cb = CircuitBreaker::new(2, Duration::from_millis(1), 2);
455        cb.record_failure();
456        cb.record_failure();
457        std::thread::sleep(Duration::from_millis(5));
458        assert!(cb.allow_request()); // half-open
459        cb.record_failure(); // failure in half-open -> re-open
460        assert!(!cb.allow_request());
461    }
462
463    #[test]
464    fn test_retry_budget_allows() {
465        let mut budget = RetryBudget::new(3, Duration::from_secs(60));
466        assert!(budget.try_acquire());
467        assert!(budget.try_acquire());
468        assert!(budget.try_acquire());
469        assert!(!budget.try_acquire());
470    }
471
472    #[test]
473    fn test_retry_budget_remaining() {
474        let mut budget = RetryBudget::new(5, Duration::from_secs(60));
475        assert_eq!(budget.remaining(), 5);
476        budget.try_acquire();
477        budget.try_acquire();
478        assert_eq!(budget.remaining(), 3);
479    }
480
481    #[test]
482    fn test_orchestrator_basic_retry() {
483        let mut orch =
484            RetryOrchestrator::new(BackoffStrategy::Constant(Duration::from_millis(100)), 3);
485        let fail = RetryOutcome::RetryableFailure("err".into());
486        // First retry.
487        let delay = orch.should_retry("task-1", &fail);
488        assert!(delay.is_some());
489        // Second retry.
490        let delay = orch.should_retry("task-1", &fail);
491        assert!(delay.is_some());
492        // Third retry should be denied (max 3 attempts).
493        let delay = orch.should_retry("task-1", &fail);
494        assert!(delay.is_none());
495    }
496
497    #[test]
498    fn test_orchestrator_success_resets() {
499        let mut orch =
500            RetryOrchestrator::new(BackoffStrategy::Constant(Duration::from_millis(50)), 3);
501        let fail = RetryOutcome::RetryableFailure("err".into());
502        orch.should_retry("t1", &fail);
503        assert_eq!(orch.attempt_count("t1"), 1);
504        orch.should_retry("t1", &RetryOutcome::Success);
505        assert_eq!(orch.attempt_count("t1"), 0);
506    }
507
508    #[test]
509    fn test_orchestrator_permanent_failure() {
510        let mut orch =
511            RetryOrchestrator::new(BackoffStrategy::Constant(Duration::from_millis(50)), 5);
512        let result = orch.should_retry("t1", &RetryOutcome::PermanentFailure("fatal".into()));
513        assert!(result.is_none());
514    }
515
516    #[test]
517    fn test_orchestrator_with_budget() {
518        let mut orch =
519            RetryOrchestrator::new(BackoffStrategy::Constant(Duration::from_millis(50)), 100)
520                .with_budget(2, Duration::from_secs(60));
521        let fail = RetryOutcome::RetryableFailure("err".into());
522        // Budget allows 2 retries.
523        assert!(orch.should_retry("t1", &fail).is_some());
524        assert!(orch.should_retry("t2", &fail).is_some());
525        // Budget exhausted.
526        assert!(orch.should_retry("t3", &fail).is_none());
527    }
528
529    #[test]
530    fn test_orchestrator_reset() {
531        let mut orch =
532            RetryOrchestrator::new(BackoffStrategy::Constant(Duration::from_millis(50)), 3)
533                .with_circuit_breaker(3, Duration::from_secs(10), 1);
534        let fail = RetryOutcome::RetryableFailure("err".into());
535        orch.should_retry("t1", &fail);
536        orch.should_retry("t1", &fail);
537        assert_eq!(orch.attempt_count("t1"), 2);
538        orch.reset("t1");
539        assert_eq!(orch.attempt_count("t1"), 0);
540    }
541}