Skip to main content

rskit_resilience/
retry.rs

1use std::sync::Arc;
2use std::time::Duration;
3
4use rskit_errors::{AppError, AppResult};
5
6/// Error returned when all retry attempts are exhausted.
7#[derive(Debug)]
8pub struct RetryError {
9    /// Total number of attempts made.
10    pub attempts: usize,
11    /// The error returned by the last attempt.
12    pub last_error: AppError,
13}
14
15impl std::fmt::Display for RetryError {
16    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
17        write!(
18            f,
19            "all {} retry attempts failed; last: {}",
20            self.attempts, self.last_error
21        )
22    }
23}
24
25impl std::error::Error for RetryError {
26    fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
27        Some(&self.last_error)
28    }
29}
30
31/// Fixed retry backoff that uses the same delay for every retry attempt.
32#[derive(Debug, Clone, Copy, PartialEq, Eq)]
33pub struct ConstantBackoff {
34    /// Delay applied to every retry attempt.
35    pub delay: Duration,
36}
37
38impl ConstantBackoff {
39    /// Create a new constant backoff strategy.
40    #[must_use]
41    pub fn new(delay: Duration) -> Self {
42        Self { delay }
43    }
44}
45
46/// Linear retry backoff that increases by a constant increment each attempt.
47#[derive(Debug, Clone, Copy, PartialEq, Eq)]
48pub struct LinearBackoff {
49    /// Delay used for the first retry.
50    pub initial_backoff: Duration,
51    /// Increment added for each subsequent retry.
52    pub increment: Duration,
53    /// Upper bound applied to the computed delay.
54    pub max_backoff: Duration,
55}
56
57impl LinearBackoff {
58    /// Create a new linear backoff strategy.
59    #[must_use]
60    pub fn new(initial_backoff: Duration, increment: Duration, max_backoff: Duration) -> Self {
61        Self {
62            initial_backoff,
63            increment,
64            max_backoff,
65        }
66    }
67}
68
69/// Backoff algorithm used by a [`RetryPolicy`].
70#[derive(Debug, Clone, Copy, PartialEq, Eq)]
71#[non_exhaustive]
72pub enum BackoffKind {
73    /// Exponential backoff using `initial_backoff * backoff_factor^(attempt-1)`.
74    Exponential,
75    /// Fixed delay for every retry attempt.
76    Constant,
77    /// Linearly increasing delay using `initial_backoff + increment * (attempt-1)`.
78    Linear,
79}
80
81/// Named retry configurations for common infrastructure integration patterns.
82#[derive(Debug, Clone, Copy, PartialEq, Eq)]
83#[non_exhaustive]
84pub enum RetryPreset {
85    /// Short retry loop for local tests and latency-sensitive operations.
86    Fast,
87    /// Balanced default for general service-to-service calls.
88    Standard,
89    /// More tolerant policy for external network dependencies.
90    ExternalService,
91}
92
93/// Retry policy with configurable backoff and retry predicate.
94///
95/// # Example
96///
97/// ```rust
98/// # use rskit_resilience::RetryPolicy;
99/// # async fn example() {
100/// let policy = RetryPolicy::new()
101///     .with_max_attempts(3)
102///     .with_initial_backoff(std::time::Duration::from_millis(100));
103///
104/// let result = policy.execute(|| async {
105///     Ok::<_, rskit_errors::AppError>(42)
106/// }).await;
107/// # }
108/// ```
109pub struct RetryPolicy {
110    /// Maximum number of attempts before giving up (including the first call).
111    pub max_attempts: usize,
112    /// Delay before the first retry.
113    pub initial_backoff: Duration,
114    /// Upper bound on any single backoff delay.
115    pub max_backoff: Duration,
116    /// Upper bound on total retry elapsed time, including the first attempt.
117    pub max_elapsed_time: Duration,
118    /// Multiplier applied on each successive retry when using exponential backoff.
119    pub backoff_factor: f64,
120    /// Whether to add uniform jitter to each backoff delay.
121    pub jitter: bool,
122    /// Backoff algorithm applied between retry attempts.
123    pub backoff_kind: BackoffKind,
124    /// Linear increment applied when [`BackoffKind::Linear`] is selected.
125    pub linear_increment: Duration,
126    /// Predicate that decides whether a given error is worth retrying.
127    /// When `None`, defaults to `AppError::is_retryable()`.
128    #[allow(clippy::type_complexity)]
129    pub retry_if: Option<Arc<dyn Fn(&AppError) -> bool + Send + Sync>>,
130    /// Called after each failed attempt before the next backoff sleep.
131    /// Arguments: `(attempt_number, error)`.
132    #[allow(clippy::type_complexity)]
133    pub on_retry: Option<Arc<dyn Fn(u32, &AppError) + Send + Sync>>,
134    /// Seed used to make jitter deterministic across runs.
135    pub jitter_seed: Option<u64>,
136}
137
138impl std::fmt::Debug for RetryPolicy {
139    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
140        f.debug_struct("RetryPolicy")
141            .field("max_attempts", &self.max_attempts)
142            .field("initial_backoff", &self.initial_backoff)
143            .field("max_backoff", &self.max_backoff)
144            .field("max_elapsed_time", &self.max_elapsed_time)
145            .field("backoff_factor", &self.backoff_factor)
146            .field("jitter", &self.jitter)
147            .field("backoff_kind", &self.backoff_kind)
148            .field("linear_increment", &self.linear_increment)
149            .field("retry_if", &self.retry_if.as_ref().map(|_| "<fn>"))
150            .field("on_retry", &self.on_retry.as_ref().map(|_| "<fn>"))
151            .field("jitter_seed", &self.jitter_seed)
152            .finish()
153    }
154}
155
156impl Clone for RetryPolicy {
157    fn clone(&self) -> Self {
158        Self {
159            max_attempts: self.max_attempts,
160            initial_backoff: self.initial_backoff,
161            max_backoff: self.max_backoff,
162            max_elapsed_time: self.max_elapsed_time,
163            backoff_factor: self.backoff_factor,
164            jitter: self.jitter,
165            backoff_kind: self.backoff_kind,
166            linear_increment: self.linear_increment,
167            retry_if: self.retry_if.clone(),
168            on_retry: self.on_retry.clone(),
169            jitter_seed: self.jitter_seed,
170        }
171    }
172}
173
174impl Default for RetryPolicy {
175    fn default() -> Self {
176        Self {
177            max_attempts: 3,
178            initial_backoff: Duration::from_millis(100),
179            max_backoff: Duration::from_secs(10),
180            max_elapsed_time: Duration::from_secs(30),
181            backoff_factor: 2.0,
182            jitter: true,
183            backoff_kind: BackoffKind::Exponential,
184            linear_increment: Duration::from_millis(100),
185            retry_if: None,
186            on_retry: None,
187            jitter_seed: None,
188        }
189    }
190}
191
192impl RetryPolicy {
193    /// Create a new [`RetryPolicy`] with default settings.
194    #[must_use]
195    pub fn new() -> Self {
196        Self::default()
197    }
198
199    /// Create a policy from a named retry preset.
200    #[must_use]
201    pub fn from_preset(preset: RetryPreset) -> Self {
202        preset.policy()
203    }
204
205    /// Create a short retry loop for local tests and latency-sensitive operations.
206    #[must_use]
207    pub fn fast() -> Self {
208        RetryPreset::Fast.policy()
209    }
210
211    /// Create a balanced default policy for service-to-service calls.
212    #[must_use]
213    pub fn standard() -> Self {
214        RetryPreset::Standard.policy()
215    }
216
217    /// Create a more tolerant policy for external network dependencies.
218    #[must_use]
219    pub fn external_service() -> Self {
220        RetryPreset::ExternalService.policy()
221    }
222
223    /// Set the maximum number of attempts (including the first call).
224    #[must_use]
225    pub fn with_max_attempts(mut self, n: usize) -> Self {
226        self.max_attempts = n;
227        self
228    }
229
230    /// Set the initial backoff delay before the first retry.
231    #[must_use]
232    pub fn with_initial_backoff(mut self, d: Duration) -> Self {
233        self.initial_backoff = d;
234        self
235    }
236
237    /// Set the upper bound on any single backoff delay.
238    #[must_use]
239    pub fn with_max_backoff(mut self, d: Duration) -> Self {
240        self.max_backoff = d;
241        self
242    }
243
244    /// Set the total elapsed-time cap for all attempts and backoff sleeps.
245    #[must_use]
246    pub fn with_max_elapsed_time(mut self, d: Duration) -> Self {
247        self.max_elapsed_time = d;
248        self
249    }
250
251    /// Set the exponential backoff multiplication factor.
252    #[must_use]
253    pub fn with_backoff_factor(mut self, f: f64) -> Self {
254        self.backoff_factor = f;
255        self
256    }
257
258    /// Enable or disable uniform jitter on each backoff delay.
259    #[must_use]
260    pub fn with_jitter(mut self, enabled: bool) -> Self {
261        self.jitter = enabled;
262        self
263    }
264
265    /// Set a deterministic seed for retry jitter.
266    #[must_use]
267    pub const fn with_jitter_seed(mut self, seed: u64) -> Self {
268        self.jitter_seed = Some(seed);
269        self
270    }
271
272    /// Use a fixed delay for every retry attempt.
273    #[must_use]
274    pub fn with_constant_backoff(mut self, backoff: ConstantBackoff) -> Self {
275        self.backoff_kind = BackoffKind::Constant;
276        self.initial_backoff = backoff.delay;
277        self.max_backoff = backoff.delay;
278        self
279    }
280
281    /// Use a linearly increasing retry delay.
282    #[must_use]
283    pub fn with_linear_backoff(mut self, backoff: LinearBackoff) -> Self {
284        self.backoff_kind = BackoffKind::Linear;
285        self.initial_backoff = backoff.initial_backoff;
286        self.linear_increment = backoff.increment;
287        self.max_backoff = backoff.max_backoff;
288        self
289    }
290
291    /// Override the predicate used to decide whether an error is retryable.
292    #[must_use]
293    pub fn with_retry_if(mut self, f: impl Fn(&AppError) -> bool + Send + Sync + 'static) -> Self {
294        self.retry_if = Some(Arc::new(f));
295        self
296    }
297
298    /// Register a callback called after each failed attempt before the next
299    /// backoff sleep. Arguments passed: `(attempt_number, error)`.
300    #[must_use]
301    pub fn with_on_retry(mut self, f: impl Fn(u32, &AppError) + Send + Sync + 'static) -> Self {
302        self.on_retry = Some(Arc::new(f));
303        self
304    }
305
306    /// Execute `f`, retrying on retryable [`AppError`]s according to this policy.
307    ///
308    /// Returns `Ok(T)` on first success, or `Err(RetryError)` when exhausted.
309    pub async fn execute<F, Fut, T>(&self, mut f: F) -> Result<T, RetryError>
310    where
311        F: FnMut() -> Fut,
312        Fut: std::future::Future<Output = AppResult<T>>,
313    {
314        if let Err(error) = self.validate() {
315            return Err(RetryError {
316                attempts: 0,
317                last_error: error,
318            });
319        }
320
321        let mut attempt = 0usize;
322        let started = tokio::time::Instant::now();
323        loop {
324            let Some(remaining) = self.max_elapsed_time.checked_sub(started.elapsed()) else {
325                return Err(RetryError {
326                    attempts: attempt,
327                    last_error: AppError::timeout("retry elapsed time"),
328                });
329            };
330            if remaining.is_zero() {
331                return Err(RetryError {
332                    attempts: attempt,
333                    last_error: AppError::timeout("retry elapsed time"),
334                });
335            }
336
337            attempt += 1;
338            match tokio::time::timeout(remaining, f()).await {
339                Err(_) => {
340                    return Err(RetryError {
341                        attempts: attempt,
342                        last_error: AppError::timeout("retry elapsed time"),
343                    });
344                }
345                Ok(Ok(v)) => return Ok(v),
346                Ok(Err(e)) => {
347                    let should_retry = self
348                        .retry_if
349                        .as_ref()
350                        .map(|predicate| predicate(&e))
351                        .unwrap_or_else(|| e.is_retryable());
352                    if attempt >= self.max_attempts
353                        || !should_retry
354                        || started.elapsed() >= self.max_elapsed_time
355                    {
356                        return Err(RetryError {
357                            attempts: attempt,
358                            last_error: e,
359                        });
360                    }
361                    if let Some(cb) = &self.on_retry {
362                        cb(attempt as u32, &e);
363                    }
364                    let delay = self.backoff(attempt);
365                    tracing::debug!(
366                        attempt,
367                        delay_ms = delay.as_millis(),
368                        error = %e,
369                        "retrying after delay"
370                    );
371                    if started.elapsed().saturating_add(delay) >= self.max_elapsed_time {
372                        return Err(RetryError {
373                            attempts: attempt,
374                            last_error: e,
375                        });
376                    }
377                    tokio::time::sleep(delay).await;
378                }
379            }
380        }
381    }
382
383    /// Return the retry delay for a one-based failed attempt number.
384    #[must_use]
385    pub fn backoff_delay(&self, attempt: usize) -> Duration {
386        let base_delay = match self.backoff_kind {
387            BackoffKind::Exponential => {
388                let exp = self.backoff_factor.powi(attempt.saturating_sub(1) as i32);
389                let base_ms = (self.initial_backoff.as_millis() as f64 * exp) as u64;
390                Duration::from_millis(base_ms.min(self.max_backoff.as_millis() as u64))
391            }
392            BackoffKind::Constant => self.initial_backoff,
393            BackoffKind::Linear => {
394                let initial = self.initial_backoff.as_millis() as u64;
395                let increment = self.linear_increment.as_millis() as u64;
396                let computed = initial
397                    .saturating_add(increment.saturating_mul(attempt.saturating_sub(1) as u64));
398                Duration::from_millis(computed.min(self.max_backoff.as_millis() as u64))
399            }
400        };
401
402        if self.jitter && !base_delay.is_zero() {
403            let jitter = self
404                .jitter_seed
405                .map(|seed| Self::deterministic_unit(seed, attempt))
406                .unwrap_or_else(rand::random::<f64>);
407            let factor = 0.5 + jitter;
408            Duration::from_millis((base_delay.as_millis() as f64 * factor) as u64)
409        } else {
410            base_delay
411        }
412    }
413
414    /// Validate retry policy limits.
415    ///
416    /// # Errors
417    /// Returns an error when attempts or backoff parameters are invalid.
418    pub fn validate(&self) -> AppResult<()> {
419        if self.max_attempts == 0 {
420            return Err(AppError::invalid_input(
421                "max_attempts",
422                "retry attempts must be greater than zero",
423            ));
424        }
425        if !self.backoff_factor.is_finite() || self.backoff_factor <= 0.0 {
426            return Err(AppError::invalid_input(
427                "backoff_factor",
428                "retry backoff factor must be finite and greater than zero",
429            ));
430        }
431        Ok(())
432    }
433
434    pub(crate) fn backoff(&self, attempt: usize) -> Duration {
435        self.backoff_delay(attempt)
436    }
437
438    fn deterministic_unit(seed: u64, attempt: usize) -> f64 {
439        let mut value = seed ^ ((attempt as u64).wrapping_mul(0x9E37_79B9_7F4A_7C15));
440        value = value.wrapping_add(0x9E37_79B9_7F4A_7C15);
441        value = (value ^ (value >> 30)).wrapping_mul(0xBF58_476D_1CE4_E5B9);
442        value = (value ^ (value >> 27)).wrapping_mul(0x94D0_49BB_1331_11EB);
443        value ^= value >> 31;
444        (value >> 11) as f64 / ((1_u64 << 53) as f64)
445    }
446}
447
448impl RetryPreset {
449    /// Build the retry policy represented by this preset.
450    #[must_use]
451    pub fn policy(self) -> RetryPolicy {
452        match self {
453            Self::Fast => RetryPolicy::new()
454                .with_max_attempts(2)
455                .with_constant_backoff(ConstantBackoff::new(Duration::from_millis(10)))
456                .with_max_elapsed_time(Duration::from_secs(1)),
457            Self::Standard => RetryPolicy::new()
458                .with_max_attempts(3)
459                .with_initial_backoff(Duration::from_millis(100))
460                .with_max_backoff(Duration::from_secs(2))
461                .with_max_elapsed_time(Duration::from_secs(10)),
462            Self::ExternalService => RetryPolicy::new()
463                .with_max_attempts(4)
464                .with_initial_backoff(Duration::from_millis(200))
465                .with_max_backoff(Duration::from_secs(5))
466                .with_max_elapsed_time(Duration::from_secs(30)),
467        }
468    }
469}
470
471#[cfg(test)]
472mod tests {
473    use super::*;
474    use rskit_errors::{AppError, ErrorCode};
475    use std::sync::{
476        Arc,
477        atomic::{AtomicUsize, Ordering},
478    };
479
480    fn make_policy() -> RetryPolicy {
481        RetryPolicy::new()
482            .with_max_attempts(3)
483            .with_initial_backoff(Duration::from_millis(1))
484            .with_jitter(false)
485    }
486
487    #[tokio::test]
488    async fn execute_succeeds_immediately_on_first_success() {
489        let policy = make_policy();
490        let result = policy.execute(|| async { Ok::<i32, AppError>(42) }).await;
491        assert_eq!(result.unwrap(), 42);
492    }
493
494    #[tokio::test]
495    async fn execute_retries_and_succeeds_on_second_attempt() {
496        let counter = Arc::new(AtomicUsize::new(0));
497        let policy = make_policy();
498
499        let result = policy
500            .execute(|| {
501                let counter = counter.clone();
502                async move {
503                    let attempt = counter.fetch_add(1, Ordering::SeqCst);
504                    if attempt == 0 {
505                        Err(AppError::new(ErrorCode::ConnectionFailed, "test"))
506                    } else {
507                        Ok(99)
508                    }
509                }
510            })
511            .await;
512
513        assert_eq!(result.unwrap(), 99);
514        assert_eq!(counter.load(Ordering::SeqCst), 2);
515    }
516
517    #[tokio::test]
518    async fn execute_returns_err_after_exhausting_all_attempts() {
519        let counter = Arc::new(AtomicUsize::new(0));
520        let policy = make_policy();
521
522        let result = policy
523            .execute(|| {
524                let counter = counter.clone();
525                async move {
526                    counter.fetch_add(1, Ordering::SeqCst);
527                    Err::<i32, AppError>(AppError::new(ErrorCode::ConnectionFailed, "test"))
528                }
529            })
530            .await;
531
532        assert!(result.is_err());
533        let retry_err = result.unwrap_err();
534        assert_eq!(retry_err.attempts, 3);
535        assert_eq!(counter.load(Ordering::SeqCst), 3);
536    }
537
538    #[tokio::test]
539    async fn execute_does_not_retry_non_retryable_error() {
540        let counter = Arc::new(AtomicUsize::new(0));
541        let policy = make_policy();
542
543        let result = policy
544            .execute(|| {
545                let counter = counter.clone();
546                async move {
547                    counter.fetch_add(1, Ordering::SeqCst);
548                    Err::<i32, AppError>(AppError::new(ErrorCode::NotFound, "test"))
549                }
550            })
551            .await;
552
553        assert!(result.is_err());
554        assert_eq!(counter.load(Ordering::SeqCst), 1);
555    }
556
557    #[tokio::test]
558    async fn execute_with_max_attempts_one_does_not_retry() {
559        let counter = Arc::new(AtomicUsize::new(0));
560        let policy = RetryPolicy::new()
561            .with_max_attempts(1)
562            .with_initial_backoff(Duration::from_millis(1))
563            .with_jitter(false);
564
565        let result = policy
566            .execute(|| {
567                let counter = counter.clone();
568                async move {
569                    counter.fetch_add(1, Ordering::SeqCst);
570                    Err::<i32, AppError>(AppError::new(ErrorCode::ConnectionFailed, "test"))
571                }
572            })
573            .await;
574
575        assert!(result.is_err());
576        assert_eq!(counter.load(Ordering::SeqCst), 1);
577    }
578
579    #[test]
580    fn constant_backoff_uses_same_delay() {
581        let policy = RetryPolicy::new()
582            .with_constant_backoff(ConstantBackoff::new(Duration::from_millis(25)))
583            .with_jitter(false);
584
585        assert_eq!(policy.backoff(1), Duration::from_millis(25));
586        assert_eq!(policy.backoff(3), Duration::from_millis(25));
587    }
588
589    #[test]
590    fn linear_backoff_increases_until_capped() {
591        let policy = RetryPolicy::new()
592            .with_linear_backoff(LinearBackoff::new(
593                Duration::from_millis(10),
594                Duration::from_millis(5),
595                Duration::from_millis(20),
596            ))
597            .with_jitter(false);
598
599        assert_eq!(policy.backoff(1), Duration::from_millis(10));
600        assert_eq!(policy.backoff(2), Duration::from_millis(15));
601        assert_eq!(policy.backoff(3), Duration::from_millis(20));
602        assert_eq!(policy.backoff(6), Duration::from_millis(20));
603    }
604
605    #[test]
606    fn public_backoff_delay_matches_policy_backoff() {
607        let policy = RetryPolicy::new()
608            .with_initial_backoff(Duration::from_millis(10))
609            .with_max_backoff(Duration::from_millis(30))
610            .with_jitter(false);
611
612        assert_eq!(policy.backoff_delay(3), Duration::from_millis(30));
613    }
614
615    #[test]
616    fn retry_presets_create_expected_policies() {
617        let fast = RetryPolicy::fast().with_jitter(false);
618        assert_eq!(fast.max_attempts, 2);
619        assert_eq!(fast.backoff_delay(1), Duration::from_millis(10));
620
621        let standard = RetryPolicy::from_preset(RetryPreset::Standard);
622        assert_eq!(standard.max_attempts, 3);
623        assert_eq!(standard.max_elapsed_time, Duration::from_secs(10));
624
625        let external = RetryPreset::ExternalService.policy();
626        assert_eq!(external.max_attempts, 4);
627        assert_eq!(external.max_elapsed_time, Duration::from_secs(30));
628    }
629
630    #[test]
631    fn seeded_jitter_is_deterministic() {
632        let policy = RetryPolicy::new()
633            .with_initial_backoff(Duration::from_millis(100))
634            .with_jitter_seed(42);
635
636        assert_eq!(policy.backoff_delay(2), policy.backoff_delay(2));
637    }
638
639    #[test]
640    fn validate_rejects_invalid_retry_limits() {
641        assert!(RetryPolicy::new().with_max_attempts(0).validate().is_err());
642        assert!(
643            RetryPolicy::new()
644                .with_backoff_factor(f64::NAN)
645                .validate()
646                .is_err()
647        );
648        assert!(
649            RetryPolicy::new()
650                .with_backoff_factor(0.0)
651                .validate()
652                .is_err()
653        );
654    }
655
656    #[tokio::test]
657    async fn execute_stops_before_elapsed_time_cap() {
658        let policy = RetryPolicy::new()
659            .with_max_attempts(10)
660            .with_initial_backoff(Duration::from_millis(50))
661            .with_jitter(false)
662            .with_max_elapsed_time(Duration::from_millis(10));
663
664        let result = policy
665            .execute(|| async {
666                Err::<(), AppError>(AppError::new(ErrorCode::ConnectionFailed, "test"))
667            })
668            .await;
669
670        let err = result.unwrap_err();
671        assert_eq!(err.attempts, 1);
672    }
673}