Skip to main content

shipper_retry/
lib.rs

1//! Retry strategies and backoff policies for distributed systems.
2//!
3//! This crate provides configurable retry strategies with support for:
4//! - Multiple backoff strategies (immediate, exponential, linear, constant)
5//! - Jitter for avoiding thundering herd problems
6//! - Per-error-type configuration
7//! - Predefined policies for common use cases
8//!
9//! # Example
10//!
11//! ```
12//! use shipper_retry::{RetryPolicy, RetryStrategyConfig, calculate_delay};
13//! use std::time::Duration;
14//!
15//! // Use a predefined policy
16//! let config = RetryPolicy::Default.to_config();
17//! let delay = calculate_delay(&config, 2);
18//! println!("Retry after: {:?}", delay);
19//!
20//! // Custom configuration
21//! let custom = RetryStrategyConfig {
22//!     max_attempts: 5,
23//!     base_delay: Duration::from_secs(1),
24//!     max_delay: Duration::from_secs(30),
25//!     ..Default::default()
26//! };
27//! ```
28
29use std::time::Duration;
30
31use serde::{Deserialize, Serialize};
32
33/// Strategy type for retry behavior.
34#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
35#[serde(rename_all = "snake_case")]
36pub enum RetryStrategyType {
37    /// No delay between retries - retry immediately
38    Immediate,
39    /// Exponential backoff: delay doubles each attempt (default)
40    #[default]
41    Exponential,
42    /// Linear backoff: delay increases linearly each attempt
43    Linear,
44    /// Constant delay: same delay every attempt
45    Constant,
46}
47
48/// Predefined retry policies with sensible defaults for different use cases.
49#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
50#[serde(rename_all = "snake_case")]
51pub enum RetryPolicy {
52    /// Default balanced retry behavior - good for most scenarios
53    #[default]
54    Default,
55    /// Aggressive retries - more attempts, faster recovery
56    Aggressive,
57    /// Conservative retries - fewer attempts, longer delays
58    Conservative,
59    /// Fully custom configuration via retry.strategy settings
60    Custom,
61}
62
63impl RetryPolicy {
64    /// Get the default retry configuration for this policy.
65    ///
66    /// # Examples
67    ///
68    /// ```
69    /// use shipper_retry::RetryPolicy;
70    /// use std::time::Duration;
71    ///
72    /// let config = RetryPolicy::Default.to_config();
73    /// assert_eq!(config.max_attempts, 6);
74    /// assert_eq!(config.base_delay, Duration::from_secs(2));
75    /// ```
76    pub fn to_config(&self) -> RetryStrategyConfig {
77        match self {
78            RetryPolicy::Default => RetryStrategyConfig {
79                strategy: RetryStrategyType::Exponential,
80                max_attempts: 6,
81                base_delay: Duration::from_secs(2),
82                max_delay: Duration::from_secs(120),
83                jitter: 0.5,
84            },
85            RetryPolicy::Aggressive => RetryStrategyConfig {
86                strategy: RetryStrategyType::Exponential,
87                max_attempts: 10,
88                base_delay: Duration::from_millis(500),
89                max_delay: Duration::from_secs(30),
90                jitter: 0.3,
91            },
92            RetryPolicy::Conservative => RetryStrategyConfig {
93                strategy: RetryStrategyType::Linear,
94                max_attempts: 3,
95                base_delay: Duration::from_secs(5),
96                max_delay: Duration::from_secs(60),
97                jitter: 0.1,
98            },
99            RetryPolicy::Custom => {
100                // Custom uses the explicitly configured values
101                RetryStrategyConfig::default()
102            }
103        }
104    }
105}
106
107/// Configuration for a retry strategy.
108#[derive(Debug, Clone, Serialize, Deserialize)]
109pub struct RetryStrategyConfig {
110    /// Strategy type for calculating delay between retries.
111    #[serde(default)]
112    pub strategy: RetryStrategyType,
113    /// Maximum number of retry attempts.
114    #[serde(default)]
115    pub max_attempts: u32,
116    /// Base delay for backoff calculations.
117    #[serde(default = "default_base_delay")]
118    #[serde(with = "humantime_serde")]
119    pub base_delay: Duration,
120    /// Maximum delay cap for backoff.
121    #[serde(default = "default_max_delay")]
122    #[serde(with = "humantime_serde")]
123    pub max_delay: Duration,
124    /// Jitter factor for randomized delays (0.0 = no jitter, 1.0 = full jitter).
125    #[serde(default = "default_jitter")]
126    pub jitter: f64,
127}
128
129fn default_base_delay() -> Duration {
130    Duration::from_secs(2)
131}
132
133fn default_max_delay() -> Duration {
134    Duration::from_secs(120)
135}
136
137fn default_jitter() -> f64 {
138    0.5
139}
140
141impl Default for RetryStrategyConfig {
142    fn default() -> Self {
143        Self {
144            strategy: RetryStrategyType::Exponential,
145            max_attempts: 6,
146            base_delay: Duration::from_secs(2),
147            max_delay: Duration::from_secs(120),
148            jitter: 0.5,
149        }
150    }
151}
152
153/// Error classification for retry decisions.
154#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
155#[serde(rename_all = "snake_case")]
156pub enum ErrorClass {
157    /// Error is transient and should be retried
158    #[default]
159    Retryable,
160    /// Error outcome is unknown (may have succeeded)
161    Ambiguous,
162    /// Error is permanent and should not be retried
163    Permanent,
164}
165
166/// Per-error-type retry configuration.
167#[derive(Debug, Clone, Default, Serialize, Deserialize)]
168pub struct PerErrorConfig {
169    /// Retry configuration for retryable errors (e.g., network issues, rate limiting).
170    #[serde(default, rename = "retryable")]
171    pub retryable: Option<RetryStrategyConfig>,
172    /// Retry configuration for ambiguous errors (e.g., unknown if publish succeeded).
173    #[serde(default, rename = "ambiguous")]
174    pub ambiguous: Option<RetryStrategyConfig>,
175    /// Retry configuration for permanent errors (e.g., authentication failure).
176    /// Permanent errors are typically not retried, but this can be customized.
177    #[serde(default, rename = "permanent")]
178    pub permanent: Option<RetryStrategyConfig>,
179}
180
181/// Calculate the delay for the next retry attempt based on the strategy configuration.
182///
183/// # Arguments
184///
185/// * `config` - The retry strategy configuration
186/// * `attempt` - The current attempt number (1-indexed)
187///
188/// # Returns
189///
190/// The duration to wait before the next retry attempt.
191///
192/// # Example
193///
194/// ```
195/// use shipper_retry::{RetryStrategyConfig, RetryStrategyType, calculate_delay};
196/// use std::time::Duration;
197///
198/// let config = RetryStrategyConfig {
199///     strategy: RetryStrategyType::Exponential,
200///     base_delay: Duration::from_secs(1),
201///     max_delay: Duration::from_secs(60),
202///     jitter: 0.0,
203///     max_attempts: 10,
204/// };
205///
206/// let delay = calculate_delay(&config, 1);
207/// assert_eq!(delay, Duration::from_secs(1));
208///
209/// let delay = calculate_delay(&config, 2);
210/// assert_eq!(delay, Duration::from_secs(2));
211/// ```
212pub fn calculate_delay(config: &RetryStrategyConfig, attempt: u32) -> Duration {
213    let delay = match config.strategy {
214        RetryStrategyType::Immediate => Duration::ZERO,
215        RetryStrategyType::Exponential => {
216            let pow = attempt.saturating_sub(1).min(16);
217            config.base_delay.saturating_mul(2_u32.saturating_pow(pow))
218        }
219        RetryStrategyType::Linear => config.base_delay.saturating_mul(attempt),
220        RetryStrategyType::Constant => config.base_delay,
221    };
222
223    // Cap at max_delay
224    let capped = delay.min(config.max_delay);
225
226    // Apply jitter if enabled
227    if config.jitter > 0.0 {
228        apply_jitter(capped, config.jitter)
229    } else {
230        capped
231    }
232}
233
234/// Apply jitter to a delay value.
235/// Jitter factor of 0.5 means delay * (0.5 to 1.5).
236fn apply_jitter(delay: Duration, jitter: f64) -> Duration {
237    // Generate a random factor between (1 - jitter) and (1 + jitter)
238    let jitter_range = 2.0 * jitter;
239    let random_value: f64 = rand::random();
240    let random_factor = 1.0 - jitter + (random_value * jitter_range);
241    let millis = (delay.as_millis() as f64 * random_factor).round() as u64;
242    Duration::from_millis(millis)
243}
244
245/// Get the retry configuration for a specific error class.
246/// Falls back to the default config if no per-error config is specified.
247///
248/// # Arguments
249///
250/// * `default_config` - The default retry configuration
251/// * `per_error_config` - Optional per-error-type configuration
252/// * `error_class` - The classification of the error
253///
254/// # Returns
255///
256/// The appropriate retry configuration for the error class.
257///
258/// # Examples
259///
260/// ```
261/// use shipper_retry::{RetryStrategyConfig, RetryStrategyType, ErrorClass, PerErrorConfig, config_for_error};
262///
263/// let default = RetryStrategyConfig::default();
264/// let per_error = PerErrorConfig {
265///     retryable: Some(RetryStrategyConfig {
266///         strategy: RetryStrategyType::Immediate,
267///         max_attempts: 10,
268///         ..Default::default()
269///     }),
270///     ..Default::default()
271/// };
272///
273/// // Uses per-error config for retryable errors
274/// let config = config_for_error(&default, Some(&per_error), ErrorClass::Retryable);
275/// assert_eq!(config.strategy, RetryStrategyType::Immediate);
276///
277/// // Falls back to default for ambiguous errors
278/// let config = config_for_error(&default, Some(&per_error), ErrorClass::Ambiguous);
279/// assert_eq!(config.strategy, RetryStrategyType::Exponential);
280/// ```
281pub fn config_for_error(
282    default_config: &RetryStrategyConfig,
283    per_error_config: Option<&PerErrorConfig>,
284    error_class: ErrorClass,
285) -> RetryStrategyConfig {
286    if let Some(per_error) = per_error_config {
287        match error_class {
288            ErrorClass::Retryable => {
289                if let Some(config) = &per_error.retryable {
290                    return config.clone();
291                }
292            }
293            ErrorClass::Ambiguous => {
294                if let Some(config) = &per_error.ambiguous {
295                    return config.clone();
296                }
297            }
298            ErrorClass::Permanent => {
299                if let Some(config) = &per_error.permanent {
300                    return config.clone();
301                }
302            }
303        }
304    }
305    default_config.clone()
306}
307
308/// A retry executor that runs a fallible operation with configured retry behavior.
309pub struct RetryExecutor {
310    config: RetryStrategyConfig,
311}
312
313impl RetryExecutor {
314    /// Create a new retry executor with the given configuration.
315    pub fn new(config: RetryStrategyConfig) -> Self {
316        Self { config }
317    }
318
319    /// Create a retry executor from a predefined policy.
320    pub fn from_policy(policy: RetryPolicy) -> Self {
321        Self::new(policy.to_config())
322    }
323
324    /// Execute a fallible operation with retry behavior.
325    ///
326    /// The operation receives the current attempt number (starting at 1).
327    /// Return `Ok(T)` on success, `Err(E)` on failure.
328    ///
329    /// # Example
330    ///
331    /// ```no_run
332    /// use shipper_retry::{RetryExecutor, RetryPolicy};
333    ///
334    /// let executor = RetryExecutor::from_policy(RetryPolicy::Default);
335    /// let result = executor.run(|attempt| {
336    ///     // Your fallible operation here
337    ///     if attempt < 3 {
338    ///         Err("transient error")
339    ///     } else {
340    ///         Ok("success")
341    ///     }
342    /// });
343    /// ```
344    pub fn run<T, E, F>(&self, mut operation: F) -> Result<T, E>
345    where
346        F: FnMut(u32) -> Result<T, E>,
347    {
348        let mut attempt = 1;
349
350        loop {
351            match operation(attempt) {
352                Ok(result) => return Ok(result),
353                Err(e) => {
354                    if attempt >= self.config.max_attempts {
355                        return Err(e);
356                    }
357
358                    let delay = calculate_delay(&self.config, attempt);
359                    std::thread::sleep(delay);
360                    attempt += 1;
361                }
362            }
363        }
364    }
365
366    /// Execute a fallible operation with retry behavior and custom error classification.
367    ///
368    /// The operation returns a tuple of (result, should_retry).
369    /// This allows the operation to indicate whether an error is retryable.
370    pub fn run_with_classification<T, E, F>(&self, mut operation: F) -> Result<T, E>
371    where
372        F: FnMut(u32) -> Result<(T, bool), E>,
373    {
374        let mut attempt = 1;
375
376        loop {
377            match operation(attempt) {
378                Ok((result, _)) => return Ok(result),
379                Err(e) => {
380                    if attempt >= self.config.max_attempts {
381                        return Err(e);
382                    }
383
384                    let delay = calculate_delay(&self.config, attempt);
385                    std::thread::sleep(delay);
386                    attempt += 1;
387                }
388            }
389        }
390    }
391}
392
393#[cfg(test)]
394mod tests {
395    use super::*;
396
397    #[test]
398    fn test_retry_policy_to_config_default() {
399        let config = RetryPolicy::Default.to_config();
400        assert_eq!(config.strategy, RetryStrategyType::Exponential);
401        assert_eq!(config.max_attempts, 6);
402        assert_eq!(config.base_delay, Duration::from_secs(2));
403        assert_eq!(config.max_delay, Duration::from_secs(120));
404    }
405
406    #[test]
407    fn test_retry_policy_to_config_aggressive() {
408        let config = RetryPolicy::Aggressive.to_config();
409        assert_eq!(config.strategy, RetryStrategyType::Exponential);
410        assert_eq!(config.max_attempts, 10);
411        assert_eq!(config.base_delay, Duration::from_millis(500));
412        assert_eq!(config.max_delay, Duration::from_secs(30));
413    }
414
415    #[test]
416    fn test_retry_policy_to_config_conservative() {
417        let config = RetryPolicy::Conservative.to_config();
418        assert_eq!(config.strategy, RetryStrategyType::Linear);
419        assert_eq!(config.max_attempts, 3);
420        assert_eq!(config.base_delay, Duration::from_secs(5));
421        assert_eq!(config.max_delay, Duration::from_secs(60));
422    }
423
424    #[test]
425    fn test_calculate_delay_immediate() {
426        let config = RetryStrategyConfig {
427            strategy: RetryStrategyType::Immediate,
428            base_delay: Duration::from_secs(1),
429            max_delay: Duration::from_secs(60),
430            jitter: 0.0,
431            max_attempts: 3,
432        };
433
434        assert_eq!(calculate_delay(&config, 1), Duration::ZERO);
435        assert_eq!(calculate_delay(&config, 5), Duration::ZERO);
436    }
437
438    #[test]
439    fn test_calculate_delay_exponential() {
440        let config = RetryStrategyConfig {
441            strategy: RetryStrategyType::Exponential,
442            base_delay: Duration::from_secs(1),
443            max_delay: Duration::from_secs(60),
444            jitter: 0.0,
445            max_attempts: 10,
446        };
447
448        // Attempt 1: base_delay * 2^0 = 1s
449        assert_eq!(calculate_delay(&config, 1), Duration::from_secs(1));
450
451        // Attempt 2: base_delay * 2^1 = 2s
452        assert_eq!(calculate_delay(&config, 2), Duration::from_secs(2));
453
454        // Attempt 3: base_delay * 2^2 = 4s
455        assert_eq!(calculate_delay(&config, 3), Duration::from_secs(4));
456
457        // Attempt 10: should be capped at max_delay
458        assert_eq!(calculate_delay(&config, 10), Duration::from_secs(60));
459    }
460
461    #[test]
462    fn test_calculate_delay_linear() {
463        let config = RetryStrategyConfig {
464            strategy: RetryStrategyType::Linear,
465            base_delay: Duration::from_secs(1),
466            max_delay: Duration::from_secs(10),
467            jitter: 0.0,
468            max_attempts: 10,
469        };
470
471        assert_eq!(calculate_delay(&config, 1), Duration::from_secs(1));
472        assert_eq!(calculate_delay(&config, 2), Duration::from_secs(2));
473        assert_eq!(calculate_delay(&config, 5), Duration::from_secs(5));
474        assert_eq!(calculate_delay(&config, 15), Duration::from_secs(10));
475    }
476
477    #[test]
478    fn test_calculate_delay_constant() {
479        let config = RetryStrategyConfig {
480            strategy: RetryStrategyType::Constant,
481            base_delay: Duration::from_secs(2),
482            max_delay: Duration::from_secs(10),
483            jitter: 0.0,
484            max_attempts: 10,
485        };
486
487        assert_eq!(calculate_delay(&config, 1), Duration::from_secs(2));
488        assert_eq!(calculate_delay(&config, 5), Duration::from_secs(2));
489        assert_eq!(calculate_delay(&config, 10), Duration::from_secs(2));
490    }
491
492    #[test]
493    fn test_calculate_delay_capped_at_max() {
494        let config = RetryStrategyConfig {
495            strategy: RetryStrategyType::Exponential,
496            base_delay: Duration::from_secs(10),
497            max_delay: Duration::from_secs(30),
498            jitter: 0.0,
499            max_attempts: 10,
500        };
501
502        assert_eq!(calculate_delay(&config, 1), Duration::from_secs(10));
503        assert_eq!(calculate_delay(&config, 2), Duration::from_secs(20));
504        assert_eq!(calculate_delay(&config, 3), Duration::from_secs(30));
505        assert_eq!(calculate_delay(&config, 10), Duration::from_secs(30));
506    }
507
508    #[test]
509    fn test_config_for_error_uses_defaults() {
510        let default_config = RetryStrategyConfig {
511            strategy: RetryStrategyType::Exponential,
512            max_attempts: 5,
513            base_delay: Duration::from_secs(1),
514            max_delay: Duration::from_secs(30),
515            jitter: 0.5,
516        };
517
518        let result = config_for_error(&default_config, None, ErrorClass::Retryable);
519        assert_eq!(result.max_attempts, 5);
520
521        let result = config_for_error(&default_config, None, ErrorClass::Permanent);
522        assert_eq!(result.max_attempts, 5);
523    }
524
525    #[test]
526    fn test_config_for_error_uses_per_error() {
527        let default_config = RetryStrategyConfig::default();
528
529        let per_error = PerErrorConfig {
530            retryable: Some(RetryStrategyConfig {
531                strategy: RetryStrategyType::Immediate,
532                max_attempts: 10,
533                base_delay: Duration::ZERO,
534                max_delay: Duration::ZERO,
535                jitter: 0.0,
536            }),
537            ambiguous: None,
538            permanent: None,
539        };
540
541        // Should use per-error config for retryable
542        let result = config_for_error(&default_config, Some(&per_error), ErrorClass::Retryable);
543        assert_eq!(result.strategy, RetryStrategyType::Immediate);
544
545        // Should fall back to default for ambiguous
546        let result = config_for_error(&default_config, Some(&per_error), ErrorClass::Ambiguous);
547        assert_eq!(result.strategy, RetryStrategyType::Exponential);
548    }
549
550    #[test]
551    fn test_retry_executor_success_on_first_try() {
552        let executor = RetryExecutor::from_policy(RetryPolicy::Aggressive);
553        let result = executor.run(|_attempt| Ok::<_, &str>("success"));
554        assert_eq!(result, Ok("success"));
555    }
556
557    #[test]
558    fn test_retry_executor_success_after_retries() {
559        let executor = RetryExecutor::new(RetryStrategyConfig {
560            strategy: RetryStrategyType::Immediate,
561            max_attempts: 5,
562            base_delay: Duration::ZERO,
563            max_delay: Duration::ZERO,
564            jitter: 0.0,
565        });
566
567        let mut attempts = 0;
568        let result = executor.run(|attempt| {
569            attempts = attempt;
570            if attempt < 3 {
571                Err("transient error")
572            } else {
573                Ok("success")
574            }
575        });
576
577        assert_eq!(result, Ok("success"));
578        assert_eq!(attempts, 3);
579    }
580
581    #[test]
582    fn test_retry_executor_fails_after_max_attempts() {
583        let executor = RetryExecutor::new(RetryStrategyConfig {
584            strategy: RetryStrategyType::Immediate,
585            max_attempts: 3,
586            base_delay: Duration::ZERO,
587            max_delay: Duration::ZERO,
588            jitter: 0.0,
589        });
590
591        let result = executor.run(|_attempt| Err::<&str, _>("permanent error"));
592        assert_eq!(result, Err("permanent error"));
593    }
594
595    #[test]
596    fn test_jitter_applied_correctly() {
597        let config = RetryStrategyConfig {
598            strategy: RetryStrategyType::Constant,
599            base_delay: Duration::from_secs(10),
600            max_delay: Duration::from_secs(60),
601            jitter: 0.5,
602            max_attempts: 10,
603        };
604
605        // With jitter of 0.5, delay should be between 5s and 15s
606        for _ in 0..100 {
607            let delay = calculate_delay(&config, 1);
608            assert!(delay >= Duration::from_millis(5000));
609            assert!(delay <= Duration::from_millis(15000));
610        }
611    }
612
613    // --- Edge-case tests ---
614
615    #[test]
616    fn test_zero_max_retries_does_not_retry() {
617        let executor = RetryExecutor::new(RetryStrategyConfig {
618            strategy: RetryStrategyType::Immediate,
619            max_attempts: 0,
620            base_delay: Duration::ZERO,
621            max_delay: Duration::ZERO,
622            jitter: 0.0,
623        });
624
625        let mut call_count = 0u32;
626        let result = executor.run(|_attempt| {
627            call_count += 1;
628            Err::<(), _>("fail")
629        });
630
631        assert_eq!(result, Err("fail"));
632        // With max_attempts=0 the operation is called once but never retried
633        assert_eq!(call_count, 1);
634    }
635
636    #[test]
637    fn test_zero_max_retries_succeeds_on_first_try() {
638        let executor = RetryExecutor::new(RetryStrategyConfig {
639            strategy: RetryStrategyType::Exponential,
640            max_attempts: 0,
641            base_delay: Duration::from_secs(1),
642            max_delay: Duration::from_secs(60),
643            jitter: 0.0,
644        });
645
646        let result = executor.run(|_| Ok::<_, &str>("ok"));
647        assert_eq!(result, Ok("ok"));
648    }
649
650    #[test]
651    fn test_very_large_max_retries_immediate_success() {
652        let executor = RetryExecutor::new(RetryStrategyConfig {
653            strategy: RetryStrategyType::Exponential,
654            max_attempts: 100,
655            base_delay: Duration::from_secs(60),
656            max_delay: Duration::from_secs(3600),
657            jitter: 0.5,
658        });
659
660        let mut call_count = 0u32;
661        let result = executor.run(|_attempt| {
662            call_count += 1;
663            Ok::<_, &str>("first try")
664        });
665
666        assert_eq!(result, Ok("first try"));
667        assert_eq!(call_count, 1);
668    }
669
670    #[test]
671    fn test_backoff_overflow_exponential_saturates() {
672        // Use a large base delay so exponential computation would overflow
673        let config = RetryStrategyConfig {
674            strategy: RetryStrategyType::Exponential,
675            base_delay: Duration::from_secs(u64::MAX / 2),
676            max_delay: Duration::from_secs(u64::MAX / 2),
677            jitter: 0.0,
678            max_attempts: 100,
679        };
680
681        // High attempt number: 2^16 * huge base would overflow without saturating_mul
682        let delay = calculate_delay(&config, 17);
683        // Must not panic; should be capped at max_delay
684        assert!(delay <= config.max_delay);
685    }
686
687    #[test]
688    fn test_backoff_overflow_linear_saturates() {
689        let config = RetryStrategyConfig {
690            strategy: RetryStrategyType::Linear,
691            base_delay: Duration::from_secs(u64::MAX / 2),
692            max_delay: Duration::from_secs(100),
693            jitter: 0.0,
694            max_attempts: 100,
695        };
696
697        let delay = calculate_delay(&config, u32::MAX);
698        assert!(delay <= config.max_delay);
699    }
700
701    #[test]
702    fn test_jitter_zero_produces_exact_delays() {
703        let config = RetryStrategyConfig {
704            strategy: RetryStrategyType::Exponential,
705            base_delay: Duration::from_millis(100),
706            max_delay: Duration::from_secs(60),
707            jitter: 0.0,
708            max_attempts: 10,
709        };
710
711        // With jitter=0.0, repeated calls must return the exact same value
712        for attempt in 1..=5 {
713            let first = calculate_delay(&config, attempt);
714            for _ in 0..50 {
715                assert_eq!(calculate_delay(&config, attempt), first);
716            }
717        }
718    }
719
720    #[test]
721    fn test_jitter_one_full_range() {
722        let config = RetryStrategyConfig {
723            strategy: RetryStrategyType::Constant,
724            base_delay: Duration::from_secs(10),
725            max_delay: Duration::from_secs(60),
726            jitter: 1.0,
727            max_attempts: 10,
728        };
729
730        // With jitter=1.0, delay = base * random_factor where random_factor in [0.0, 2.0]
731        // So delay should be in [0ms, 20_000ms]
732        for _ in 0..200 {
733            let delay = calculate_delay(&config, 1);
734            assert!(delay <= Duration::from_millis(20_000));
735        }
736    }
737
738    #[test]
739    fn test_initial_delay_zero() {
740        // base_delay=ZERO means all computed delays are zero for every strategy
741        for strategy in [
742            RetryStrategyType::Exponential,
743            RetryStrategyType::Linear,
744            RetryStrategyType::Constant,
745            RetryStrategyType::Immediate,
746        ] {
747            let config = RetryStrategyConfig {
748                strategy,
749                base_delay: Duration::ZERO,
750                max_delay: Duration::from_secs(60),
751                jitter: 0.0,
752                max_attempts: 10,
753            };
754
755            for attempt in 1..=5 {
756                assert_eq!(
757                    calculate_delay(&config, attempt),
758                    Duration::ZERO,
759                    "strategy {:?} attempt {} should be zero with base_delay=ZERO",
760                    strategy,
761                    attempt
762                );
763            }
764        }
765    }
766
767    #[test]
768    fn test_max_delay_zero_caps_everything() {
769        let config = RetryStrategyConfig {
770            strategy: RetryStrategyType::Exponential,
771            base_delay: Duration::from_secs(10),
772            max_delay: Duration::ZERO,
773            jitter: 0.0,
774            max_attempts: 10,
775        };
776
777        for attempt in 1..=10 {
778            assert_eq!(
779                calculate_delay(&config, attempt),
780                Duration::ZERO,
781                "attempt {} should be capped to zero by max_delay=ZERO",
782                attempt
783            );
784        }
785    }
786
787    #[test]
788    fn test_max_delay_less_than_initial_delay() {
789        let config = RetryStrategyConfig {
790            strategy: RetryStrategyType::Exponential,
791            base_delay: Duration::from_secs(10),
792            max_delay: Duration::from_secs(5),
793            jitter: 0.0,
794            max_attempts: 10,
795        };
796
797        // Every attempt should be capped at max_delay
798        for attempt in 1..=5 {
799            assert_eq!(
800                calculate_delay(&config, attempt),
801                Duration::from_secs(5),
802                "attempt {} should be capped at max_delay when max_delay < base_delay",
803                attempt
804            );
805        }
806
807        // Also test with linear and constant strategies
808        let linear = RetryStrategyConfig {
809            strategy: RetryStrategyType::Linear,
810            ..config.clone()
811        };
812        assert_eq!(calculate_delay(&linear, 1), Duration::from_secs(5));
813
814        let constant = RetryStrategyConfig {
815            strategy: RetryStrategyType::Constant,
816            ..config
817        };
818        assert_eq!(calculate_delay(&constant, 1), Duration::from_secs(5));
819    }
820
821    // --- Backoff curve validation ---
822
823    #[test]
824    fn test_exponential_growth_doubles_each_attempt() {
825        let config = RetryStrategyConfig {
826            strategy: RetryStrategyType::Exponential,
827            base_delay: Duration::from_millis(100),
828            max_delay: Duration::from_secs(3600),
829            jitter: 0.0,
830            max_attempts: 20,
831        };
832        for attempt in 2..=10 {
833            let prev = calculate_delay(&config, attempt - 1);
834            let curr = calculate_delay(&config, attempt);
835            assert_eq!(
836                curr,
837                prev * 2,
838                "attempt {} should be double attempt {}",
839                attempt,
840                attempt - 1
841            );
842        }
843    }
844
845    #[test]
846    fn test_exponential_pow_clamped_at_16() {
847        // The exponent is clamped at 16, so attempts 18 and 19 produce the same
848        // uncapped delay as attempt 17 (base * 2^16).
849        let config = RetryStrategyConfig {
850            strategy: RetryStrategyType::Exponential,
851            base_delay: Duration::from_millis(1),
852            max_delay: Duration::from_secs(3600),
853            jitter: 0.0,
854            max_attempts: 30,
855        };
856        let at_17 = calculate_delay(&config, 17);
857        let at_18 = calculate_delay(&config, 18);
858        let at_25 = calculate_delay(&config, 25);
859        assert_eq!(at_17, at_18);
860        assert_eq!(at_17, at_25);
861        // 2^16 ms = 65536 ms
862        assert_eq!(at_17, Duration::from_millis(65536));
863    }
864
865    // --- Strategy selection ---
866
867    #[test]
868    fn test_strategy_selection_produces_distinct_delays() {
869        let base = Duration::from_secs(2);
870        let max = Duration::from_secs(3600);
871        let make = |s| RetryStrategyConfig {
872            strategy: s,
873            base_delay: base,
874            max_delay: max,
875            jitter: 0.0,
876            max_attempts: 10,
877        };
878        let attempt = 3;
879        let imm = calculate_delay(&make(RetryStrategyType::Immediate), attempt);
880        let exp = calculate_delay(&make(RetryStrategyType::Exponential), attempt);
881        let lin = calculate_delay(&make(RetryStrategyType::Linear), attempt);
882        let con = calculate_delay(&make(RetryStrategyType::Constant), attempt);
883
884        // immediate = 0, exponential = 8s, linear = 6s, constant = 2s — all distinct
885        assert_eq!(imm, Duration::ZERO);
886        assert_eq!(exp, Duration::from_secs(8));
887        assert_eq!(lin, Duration::from_secs(6));
888        assert_eq!(con, Duration::from_secs(2));
889        // All four are distinct
890        let vals = [imm, exp, lin, con];
891        for i in 0..vals.len() {
892            for j in (i + 1)..vals.len() {
893                assert_ne!(vals[i], vals[j], "strategies {} and {} collided", i, j);
894            }
895        }
896    }
897
898    #[test]
899    fn test_immediate_always_zero_regardless_of_config() {
900        let config = RetryStrategyConfig {
901            strategy: RetryStrategyType::Immediate,
902            base_delay: Duration::from_secs(999),
903            max_delay: Duration::from_secs(9999),
904            jitter: 0.0,
905            max_attempts: 50,
906        };
907        for attempt in [1, 5, 10, 50, u32::MAX] {
908            assert_eq!(calculate_delay(&config, attempt), Duration::ZERO);
909        }
910    }
911
912    #[test]
913    fn test_constant_ignores_attempt_number() {
914        let config = RetryStrategyConfig {
915            strategy: RetryStrategyType::Constant,
916            base_delay: Duration::from_millis(750),
917            max_delay: Duration::from_secs(60),
918            jitter: 0.0,
919            max_attempts: 100,
920        };
921        let first = calculate_delay(&config, 1);
922        for attempt in 2..=20 {
923            assert_eq!(
924                calculate_delay(&config, attempt),
925                first,
926                "constant delay should not change with attempt number"
927            );
928        }
929    }
930
931    // --- Edge cases ---
932
933    #[test]
934    fn test_attempt_zero_does_not_panic() {
935        for strategy in [
936            RetryStrategyType::Immediate,
937            RetryStrategyType::Exponential,
938            RetryStrategyType::Linear,
939            RetryStrategyType::Constant,
940        ] {
941            let config = RetryStrategyConfig {
942                strategy,
943                base_delay: Duration::from_secs(1),
944                max_delay: Duration::from_secs(60),
945                jitter: 0.0,
946                max_attempts: 10,
947            };
948            // Should not panic
949            let _ = calculate_delay(&config, 0);
950        }
951    }
952
953    #[test]
954    fn test_executor_max_attempts_one_no_retry() {
955        let executor = RetryExecutor::new(RetryStrategyConfig {
956            strategy: RetryStrategyType::Immediate,
957            max_attempts: 1,
958            base_delay: Duration::ZERO,
959            max_delay: Duration::ZERO,
960            jitter: 0.0,
961        });
962        let mut call_count = 0u32;
963        let result = executor.run(|_| {
964            call_count += 1;
965            Err::<(), _>("fail")
966        });
967        assert_eq!(result, Err("fail"));
968        assert_eq!(call_count, 1, "max_attempts=1 should call exactly once");
969    }
970
971    #[test]
972    fn test_executor_run_with_classification_success() {
973        let executor = RetryExecutor::new(RetryStrategyConfig {
974            strategy: RetryStrategyType::Immediate,
975            max_attempts: 5,
976            base_delay: Duration::ZERO,
977            max_delay: Duration::ZERO,
978            jitter: 0.0,
979        });
980        let result = executor.run_with_classification(|attempt| {
981            if attempt < 3 {
982                Err("transient")
983            } else {
984                Ok(("done", true))
985            }
986        });
987        assert_eq!(result, Ok("done"));
988    }
989
990    #[test]
991    fn test_executor_run_with_classification_exhausted() {
992        let executor = RetryExecutor::new(RetryStrategyConfig {
993            strategy: RetryStrategyType::Immediate,
994            max_attempts: 2,
995            base_delay: Duration::ZERO,
996            max_delay: Duration::ZERO,
997            jitter: 0.0,
998        });
999        let result =
1000            executor.run_with_classification(|_| Err::<(&str, bool), _>("permanent failure"));
1001        assert_eq!(result, Err("permanent failure"));
1002    }
1003
1004    #[test]
1005    fn test_config_for_error_all_three_overrides() {
1006        let default = RetryStrategyConfig::default();
1007        let per_error = PerErrorConfig {
1008            retryable: Some(RetryStrategyConfig {
1009                max_attempts: 10,
1010                ..Default::default()
1011            }),
1012            ambiguous: Some(RetryStrategyConfig {
1013                max_attempts: 20,
1014                ..Default::default()
1015            }),
1016            permanent: Some(RetryStrategyConfig {
1017                max_attempts: 30,
1018                ..Default::default()
1019            }),
1020        };
1021        assert_eq!(
1022            config_for_error(&default, Some(&per_error), ErrorClass::Retryable).max_attempts,
1023            10
1024        );
1025        assert_eq!(
1026            config_for_error(&default, Some(&per_error), ErrorClass::Ambiguous).max_attempts,
1027            20
1028        );
1029        assert_eq!(
1030            config_for_error(&default, Some(&per_error), ErrorClass::Permanent).max_attempts,
1031            30
1032        );
1033    }
1034
1035    #[test]
1036    fn test_default_config_matches_default_policy() {
1037        let from_default = RetryStrategyConfig::default();
1038        let from_policy = RetryPolicy::Default.to_config();
1039        assert_eq!(from_default.strategy, from_policy.strategy);
1040        assert_eq!(from_default.max_attempts, from_policy.max_attempts);
1041        assert_eq!(from_default.base_delay, from_policy.base_delay);
1042        assert_eq!(from_default.max_delay, from_policy.max_delay);
1043        assert!(
1044            (from_default.jitter - from_policy.jitter).abs() < f64::EPSILON,
1045            "jitter should match"
1046        );
1047    }
1048
1049    #[test]
1050    fn test_jitter_bounds_with_exponential() {
1051        let config = RetryStrategyConfig {
1052            strategy: RetryStrategyType::Exponential,
1053            base_delay: Duration::from_secs(1),
1054            max_delay: Duration::from_secs(3600),
1055            jitter: 0.3,
1056            max_attempts: 10,
1057        };
1058        // attempt 3 => base * 2^2 = 4s, jitter 0.3 => [2800ms, 5200ms]
1059        for _ in 0..200 {
1060            let delay = calculate_delay(&config, 3);
1061            assert!(
1062                delay >= Duration::from_millis(2800),
1063                "delay {:?} below lower bound",
1064                delay
1065            );
1066            assert!(
1067                delay <= Duration::from_millis(5200),
1068                "delay {:?} above upper bound",
1069                delay
1070            );
1071        }
1072    }
1073
1074    // --- Determinism ---
1075
1076    #[test]
1077    fn test_jitter_zero_is_deterministic_across_strategies() {
1078        // Zero jitter must produce identical results across repeated calls
1079        for strategy in [
1080            RetryStrategyType::Exponential,
1081            RetryStrategyType::Linear,
1082            RetryStrategyType::Constant,
1083        ] {
1084            let config = RetryStrategyConfig {
1085                strategy,
1086                base_delay: Duration::from_millis(500),
1087                max_delay: Duration::from_secs(120),
1088                jitter: 0.0,
1089                max_attempts: 20,
1090            };
1091            for attempt in 1..=10 {
1092                let a = calculate_delay(&config, attempt);
1093                let b = calculate_delay(&config, attempt);
1094                assert_eq!(a, b, "{:?} attempt {} not deterministic", strategy, attempt);
1095            }
1096        }
1097    }
1098
1099    // --- Additional edge-case coverage ---
1100
1101    #[test]
1102    fn test_default_strategy_config_field_values() {
1103        let cfg = RetryStrategyConfig::default();
1104        assert_eq!(cfg.strategy, RetryStrategyType::Exponential);
1105        assert_eq!(cfg.max_attempts, 6);
1106        assert_eq!(cfg.base_delay, Duration::from_secs(2));
1107        assert_eq!(cfg.max_delay, Duration::from_secs(120));
1108        assert_eq!(cfg.jitter, 0.5);
1109    }
1110
1111    #[test]
1112    fn test_retry_policy_custom_matches_default_strategy_config() {
1113        // Custom policy is documented to use the explicitly configured values;
1114        // the to_config() implementation returns RetryStrategyConfig::default().
1115        let custom = RetryPolicy::Custom.to_config();
1116        let default = RetryStrategyConfig::default();
1117
1118        assert_eq!(custom.strategy, default.strategy);
1119        assert_eq!(custom.max_attempts, default.max_attempts);
1120        assert_eq!(custom.base_delay, default.base_delay);
1121        assert_eq!(custom.max_delay, default.max_delay);
1122        assert_eq!(custom.jitter, default.jitter);
1123    }
1124
1125    #[test]
1126    fn test_retry_policy_default_via_derive_macro_is_default_variant() {
1127        let policy: RetryPolicy = RetryPolicy::default();
1128        assert_eq!(policy, RetryPolicy::Default);
1129    }
1130
1131    #[test]
1132    fn test_retry_strategy_type_default_via_derive_is_exponential() {
1133        let strategy: RetryStrategyType = RetryStrategyType::default();
1134        assert_eq!(strategy, RetryStrategyType::Exponential);
1135    }
1136
1137    #[test]
1138    fn test_error_class_default_is_retryable() {
1139        let class: ErrorClass = ErrorClass::default();
1140        assert_eq!(class, ErrorClass::Retryable);
1141    }
1142
1143    #[test]
1144    fn test_per_error_config_default_all_variants_none() {
1145        let cfg = PerErrorConfig::default();
1146        assert!(cfg.retryable.is_none());
1147        assert!(cfg.ambiguous.is_none());
1148        assert!(cfg.permanent.is_none());
1149    }
1150
1151    #[test]
1152    fn test_exponential_large_attempts_saturate_at_max_delay() {
1153        let config = RetryStrategyConfig {
1154            strategy: RetryStrategyType::Exponential,
1155            base_delay: Duration::from_secs(1),
1156            max_delay: Duration::from_secs(120),
1157            jitter: 0.0,
1158            max_attempts: 200,
1159        };
1160
1161        for attempt in [17, 100, u32::MAX] {
1162            assert_eq!(
1163                calculate_delay(&config, attempt),
1164                config.max_delay,
1165                "large exponential attempt should saturate at max_delay"
1166            );
1167        }
1168    }
1169
1170    #[test]
1171    fn test_jitter_greater_than_one_does_not_panic_and_stays_bounded() {
1172        // jitter > 1.0 makes the lower bound `1 - jitter` go negative.
1173        // The implementation casts to u64 with `as u64`, which saturates negative
1174        // floats to 0. We assert no panic and that the upper bound (1 + jitter)
1175        // is respected.
1176        let config = RetryStrategyConfig {
1177            strategy: RetryStrategyType::Constant,
1178            base_delay: Duration::from_millis(100),
1179            max_delay: Duration::from_secs(60),
1180            jitter: 1.5,
1181            max_attempts: 10,
1182        };
1183
1184        // factor in [-0.5, 2.5] -> delay in [0ms, 250ms] after saturation.
1185        for _ in 0..200 {
1186            let delay = calculate_delay(&config, 1);
1187            assert!(
1188                delay <= Duration::from_millis(250),
1189                "delay {:?} exceeded upper bound for jitter=1.5",
1190                delay
1191            );
1192        }
1193    }
1194
1195    #[test]
1196    fn test_jitter_negative_does_not_panic_and_returns_a_delay() {
1197        // Negative jitter is a config error, but the function should not panic
1198        // and the result should be a finite Duration.
1199        let config = RetryStrategyConfig {
1200            strategy: RetryStrategyType::Constant,
1201            base_delay: Duration::from_millis(100),
1202            max_delay: Duration::from_secs(60),
1203            jitter: -0.5,
1204            max_attempts: 10,
1205        };
1206
1207        // The jitter > 0.0 guard in calculate_delay sends negative jitter through
1208        // the no-jitter path, so the delay should equal the capped base delay.
1209        let delay = calculate_delay(&config, 1);
1210        assert_eq!(delay, Duration::from_millis(100));
1211    }
1212
1213    #[test]
1214    fn test_from_policy_constructs_executor_with_policy_config() {
1215        for policy in [
1216            RetryPolicy::Default,
1217            RetryPolicy::Aggressive,
1218            RetryPolicy::Conservative,
1219            RetryPolicy::Custom,
1220        ] {
1221            let executor = RetryExecutor::from_policy(policy);
1222            let expected = policy.to_config();
1223            assert_eq!(executor.config.strategy, expected.strategy);
1224            assert_eq!(executor.config.max_attempts, expected.max_attempts);
1225            assert_eq!(executor.config.base_delay, expected.base_delay);
1226            assert_eq!(executor.config.max_delay, expected.max_delay);
1227            assert_eq!(executor.config.jitter, expected.jitter);
1228        }
1229    }
1230
1231    #[test]
1232    fn test_run_with_classification_first_try_success_no_sleep() {
1233        // run_with_classification on first-call Ok must not call into the
1234        // delay/sleep path. We exercise this by giving an absurdly long
1235        // base_delay and asserting the call returns instantly via Ok.
1236        let executor = RetryExecutor::new(RetryStrategyConfig {
1237            strategy: RetryStrategyType::Constant,
1238            max_attempts: 100,
1239            base_delay: Duration::from_secs(3600),
1240            max_delay: Duration::from_secs(3600),
1241            jitter: 0.0,
1242        });
1243
1244        let mut calls = 0;
1245        let result = executor.run_with_classification(|attempt| {
1246            calls += 1;
1247            assert_eq!(attempt, 1);
1248            Ok::<_, &str>(("done", true))
1249        });
1250
1251        assert_eq!(result, Ok("done"));
1252        assert_eq!(calls, 1, "first-call success must not retry");
1253    }
1254
1255    #[test]
1256    fn test_config_for_error_each_class_picks_its_override() {
1257        let default = RetryStrategyConfig::default();
1258
1259        let per_error_retryable = RetryStrategyConfig {
1260            strategy: RetryStrategyType::Immediate,
1261            max_attempts: 11,
1262            base_delay: Duration::from_millis(11),
1263            max_delay: Duration::from_millis(1100),
1264            jitter: 0.1,
1265        };
1266        let per_error_ambiguous = RetryStrategyConfig {
1267            strategy: RetryStrategyType::Linear,
1268            max_attempts: 22,
1269            base_delay: Duration::from_millis(22),
1270            max_delay: Duration::from_millis(2200),
1271            jitter: 0.2,
1272        };
1273        let per_error_permanent = RetryStrategyConfig {
1274            strategy: RetryStrategyType::Constant,
1275            max_attempts: 33,
1276            base_delay: Duration::from_millis(33),
1277            max_delay: Duration::from_millis(3300),
1278            jitter: 0.3,
1279        };
1280
1281        fn assert_config_eq(actual: &RetryStrategyConfig, expected: &RetryStrategyConfig) {
1282            assert_eq!(actual.strategy, expected.strategy);
1283            assert_eq!(actual.max_attempts, expected.max_attempts);
1284            assert_eq!(actual.base_delay, expected.base_delay);
1285            assert_eq!(actual.max_delay, expected.max_delay);
1286            assert_eq!(actual.jitter, expected.jitter);
1287        }
1288
1289        let per_error = PerErrorConfig {
1290            retryable: Some(per_error_retryable.clone()),
1291            ambiguous: Some(per_error_ambiguous.clone()),
1292            permanent: Some(per_error_permanent.clone()),
1293        };
1294
1295        let r = config_for_error(&default, Some(&per_error), ErrorClass::Retryable);
1296        assert_config_eq(&r, &per_error_retryable);
1297
1298        let a = config_for_error(&default, Some(&per_error), ErrorClass::Ambiguous);
1299        assert_config_eq(&a, &per_error_ambiguous);
1300
1301        let p = config_for_error(&default, Some(&per_error), ErrorClass::Permanent);
1302        assert_config_eq(&p, &per_error_permanent);
1303    }
1304
1305    // Note: serde roundtrip coverage for RetryPolicy, RetryStrategyType, and
1306    // ErrorClass is provided by the snapshot_tests module below using
1307    // insta's assert_yaml_snapshot. This module avoids adding serde_json as
1308    // a dev-dependency.
1309}
1310
1311#[cfg(test)]
1312mod property_tests {
1313    use super::*;
1314    use proptest::prelude::*;
1315
1316    fn expected_exponential(base: Duration, max: Duration, attempt: u32) -> Duration {
1317        let delay = base.saturating_mul(2_u32.saturating_pow(attempt.saturating_sub(1).min(16)));
1318        delay.min(max)
1319    }
1320
1321    fn expected_linear(base: Duration, max: Duration, attempt: u32) -> Duration {
1322        base.saturating_mul(attempt).min(max)
1323    }
1324
1325    proptest! {
1326        #[test]
1327        fn exponential_delay_matches_formula_with_no_jitter(
1328            base_ms in 1u64..10_000,
1329            extra_ms in 0u64..290_000,
1330            attempt in 1u32..40,
1331        ) {
1332            let base_delay = Duration::from_millis(base_ms);
1333            let max_delay = Duration::from_millis(base_ms.saturating_add(extra_ms).min(300_000));
1334
1335            let config = RetryStrategyConfig {
1336                strategy: RetryStrategyType::Exponential,
1337                max_attempts: 100,
1338                base_delay,
1339                max_delay,
1340                jitter: 0.0,
1341            };
1342
1343            let expected = expected_exponential(base_delay, max_delay, attempt);
1344            prop_assert_eq!(calculate_delay(&config, attempt), expected);
1345            prop_assert!(calculate_delay(&config, attempt) <= max_delay);
1346        }
1347
1348        #[test]
1349        fn linear_delay_matches_formula_with_no_jitter(
1350            base_ms in 1u64..5_000,
1351            extra_ms in 0u64..295_000,
1352            attempt in 1u32..60,
1353        ) {
1354            let base_delay = Duration::from_millis(base_ms);
1355            let max_delay = Duration::from_millis(base_ms.saturating_add(extra_ms).min(300_000));
1356
1357            let config = RetryStrategyConfig {
1358                strategy: RetryStrategyType::Linear,
1359                max_attempts: 100,
1360                base_delay,
1361                max_delay,
1362                jitter: 0.0,
1363            };
1364
1365            let expected = expected_linear(base_delay, max_delay, attempt);
1366            prop_assert_eq!(calculate_delay(&config, attempt), expected);
1367            prop_assert!(calculate_delay(&config, attempt) <= max_delay);
1368        }
1369
1370        #[test]
1371        fn constant_and_immediate_hold_edge_invariants(
1372            base_ms in 0u64..20_000,
1373            extra_ms in 0u64..50_000,
1374            jitter_byte in 0u8..=255,
1375        ) {
1376            let base_delay = Duration::from_millis(base_ms);
1377            let max_delay = Duration::from_millis((base_ms + extra_ms).min(300_000));
1378            let jitter = (jitter_byte as f64) / 255.0;
1379
1380            let constant = RetryStrategyConfig {
1381                strategy: RetryStrategyType::Constant,
1382                max_attempts: 100,
1383                base_delay,
1384                max_delay,
1385                jitter,
1386            };
1387            let delay = calculate_delay(&constant, 4);
1388            prop_assert!(delay <= max_delay.saturating_mul(2));
1389
1390            let no_jitter = RetryStrategyConfig {
1391                jitter: 0.0,
1392                ..constant.clone()
1393            };
1394            prop_assert_eq!(calculate_delay(&no_jitter, 5), base_delay.min(max_delay));
1395
1396            let immediate = RetryStrategyConfig {
1397                strategy: RetryStrategyType::Immediate,
1398                ..constant
1399            };
1400            prop_assert_eq!(calculate_delay(&immediate, 9), Duration::ZERO);
1401        }
1402
1403        #[test]
1404        fn backoff_never_exceeds_max_delay(
1405            strategy_idx in 0u8..4,
1406            base_ms in 0u64..100_000,
1407            max_ms in 0u64..300_000,
1408            attempt in 1u32..100,
1409        ) {
1410            let strategy = match strategy_idx {
1411                0 => RetryStrategyType::Immediate,
1412                1 => RetryStrategyType::Exponential,
1413                2 => RetryStrategyType::Linear,
1414                _ => RetryStrategyType::Constant,
1415            };
1416
1417            let max_delay = Duration::from_millis(max_ms);
1418
1419            let config = RetryStrategyConfig {
1420                strategy,
1421                max_attempts: 100,
1422                base_delay: Duration::from_millis(base_ms),
1423                max_delay,
1424                jitter: 0.0,
1425            };
1426
1427            let delay = calculate_delay(&config, attempt);
1428            prop_assert!(
1429                delay <= max_delay,
1430                "strategy={:?} base={}ms max={}ms attempt={} => delay={:?} exceeded max_delay={:?}",
1431                strategy, base_ms, max_ms, attempt, delay, max_delay
1432            );
1433        }
1434
1435        #[test]
1436        fn jitter_delay_within_bounds(
1437            base_ms in 100u64..10_000,
1438            jitter_pct in 1u8..100,
1439            attempt in 1u32..10,
1440        ) {
1441            let jitter = (jitter_pct as f64) / 100.0;
1442            let base_delay = Duration::from_millis(base_ms);
1443            let max_delay = Duration::from_secs(3600);
1444
1445            let config = RetryStrategyConfig {
1446                strategy: RetryStrategyType::Constant,
1447                max_attempts: 100,
1448                base_delay,
1449                max_delay,
1450                jitter,
1451            };
1452
1453            let nominal = base_delay.min(max_delay);
1454            let nominal_ms = nominal.as_millis() as f64;
1455            // epsilon accounts for floating-point rounding in Duration::mul_f64
1456            let eps = 1.0;
1457            let low = Duration::from_millis(((nominal_ms * (1.0 - jitter)) - eps).max(0.0) as u64);
1458            let high = Duration::from_millis((nominal_ms * (1.0 + jitter) + eps).ceil() as u64);
1459
1460            for _ in 0..20 {
1461                let delay = calculate_delay(&config, attempt);
1462                prop_assert!(
1463                    delay >= low && delay <= high,
1464                    "jitter={} base={}ms => delay={:?} outside [{:?}, {:?}]",
1465                    jitter, base_ms, delay, low, high
1466                );
1467            }
1468        }
1469
1470        #[test]
1471        fn exponential_delays_non_decreasing_without_jitter(
1472            base_ms in 1u64..5_000,
1473            max_ms in 1u64..300_000,
1474        ) {
1475            let config = RetryStrategyConfig {
1476                strategy: RetryStrategyType::Exponential,
1477                max_attempts: 100,
1478                base_delay: Duration::from_millis(base_ms),
1479                max_delay: Duration::from_millis(max_ms),
1480                jitter: 0.0,
1481            };
1482
1483            let mut prev = Duration::ZERO;
1484            for attempt in 1..=20 {
1485                let curr = calculate_delay(&config, attempt);
1486                prop_assert!(
1487                    curr >= prev,
1488                    "delay decreased at attempt {}: {:?} < {:?}",
1489                    attempt, curr, prev
1490                );
1491                prev = curr;
1492            }
1493        }
1494
1495        #[test]
1496        fn delays_never_negative_or_panic(
1497            strategy_idx in 0u8..4,
1498            base_ms in 0u64..u64::MAX / 2,
1499            max_ms in 0u64..300_000,
1500            attempt in 0u32..200,
1501        ) {
1502            let strategy = match strategy_idx {
1503                0 => RetryStrategyType::Immediate,
1504                1 => RetryStrategyType::Exponential,
1505                2 => RetryStrategyType::Linear,
1506                _ => RetryStrategyType::Constant,
1507            };
1508
1509            let config = RetryStrategyConfig {
1510                strategy,
1511                max_attempts: 100,
1512                base_delay: Duration::from_millis(base_ms.min(300_000)),
1513                max_delay: Duration::from_millis(max_ms),
1514                jitter: 0.0,
1515            };
1516
1517            // Must not panic
1518            let delay = calculate_delay(&config, attempt);
1519            // Duration can't be negative, but verify it's bounded
1520            prop_assert!(delay <= Duration::from_millis(max_ms));
1521        }
1522    }
1523}
1524
1525#[cfg(test)]
1526mod snapshot_tests {
1527    use super::*;
1528    use insta::assert_yaml_snapshot;
1529
1530    #[test]
1531    fn snapshot_default_policy_config() {
1532        assert_yaml_snapshot!(RetryPolicy::Default.to_config());
1533    }
1534
1535    #[test]
1536    fn snapshot_aggressive_policy_config() {
1537        assert_yaml_snapshot!(RetryPolicy::Aggressive.to_config());
1538    }
1539
1540    #[test]
1541    fn snapshot_conservative_policy_config() {
1542        assert_yaml_snapshot!(RetryPolicy::Conservative.to_config());
1543    }
1544
1545    #[test]
1546    fn snapshot_custom_policy_config() {
1547        assert_yaml_snapshot!(RetryPolicy::Custom.to_config());
1548    }
1549
1550    #[test]
1551    fn snapshot_error_classes() {
1552        assert_yaml_snapshot!("retryable", ErrorClass::Retryable);
1553        assert_yaml_snapshot!("ambiguous", ErrorClass::Ambiguous);
1554        assert_yaml_snapshot!("permanent", ErrorClass::Permanent);
1555    }
1556
1557    #[test]
1558    fn snapshot_per_error_config_empty() {
1559        assert_yaml_snapshot!(PerErrorConfig::default());
1560    }
1561
1562    #[test]
1563    fn snapshot_per_error_config_with_retryable_override() {
1564        let config = PerErrorConfig {
1565            retryable: Some(RetryStrategyConfig {
1566                strategy: RetryStrategyType::Immediate,
1567                max_attempts: 10,
1568                base_delay: Duration::ZERO,
1569                max_delay: Duration::from_secs(5),
1570                jitter: 0.0,
1571            }),
1572            ambiguous: None,
1573            permanent: None,
1574        };
1575        assert_yaml_snapshot!(config);
1576    }
1577
1578    #[test]
1579    fn snapshot_delay_sequence_exponential() {
1580        let config = RetryStrategyConfig {
1581            strategy: RetryStrategyType::Exponential,
1582            base_delay: Duration::from_secs(1),
1583            max_delay: Duration::from_secs(60),
1584            jitter: 0.0,
1585            max_attempts: 10,
1586        };
1587        let delays: Vec<String> = (1..=7)
1588            .map(|a| format!("attempt {}: {:?}", a, calculate_delay(&config, a)))
1589            .collect();
1590        assert_yaml_snapshot!(delays);
1591    }
1592
1593    #[test]
1594    fn snapshot_delay_sequence_linear() {
1595        let config = RetryStrategyConfig {
1596            strategy: RetryStrategyType::Linear,
1597            base_delay: Duration::from_secs(2),
1598            max_delay: Duration::from_secs(20),
1599            jitter: 0.0,
1600            max_attempts: 10,
1601        };
1602        let delays: Vec<String> = (1..=10)
1603            .map(|a| format!("attempt {}: {:?}", a, calculate_delay(&config, a)))
1604            .collect();
1605        assert_yaml_snapshot!(delays);
1606    }
1607
1608    #[test]
1609    fn snapshot_strategy_types() {
1610        assert_yaml_snapshot!("immediate", RetryStrategyType::Immediate);
1611        assert_yaml_snapshot!("exponential", RetryStrategyType::Exponential);
1612        assert_yaml_snapshot!("linear", RetryStrategyType::Linear);
1613        assert_yaml_snapshot!("constant", RetryStrategyType::Constant);
1614    }
1615
1616    #[test]
1617    fn snapshot_debug_default_config() {
1618        insta::assert_debug_snapshot!(RetryStrategyConfig::default());
1619    }
1620
1621    #[test]
1622    fn snapshot_debug_all_policies() {
1623        insta::assert_debug_snapshot!("debug_default", RetryPolicy::Default.to_config());
1624        insta::assert_debug_snapshot!("debug_aggressive", RetryPolicy::Aggressive.to_config());
1625        insta::assert_debug_snapshot!("debug_conservative", RetryPolicy::Conservative.to_config());
1626    }
1627
1628    #[test]
1629    fn snapshot_debug_retry_policy_variants() {
1630        insta::assert_debug_snapshot!(vec![
1631            RetryPolicy::Default,
1632            RetryPolicy::Aggressive,
1633            RetryPolicy::Conservative,
1634            RetryPolicy::Custom,
1635        ]);
1636    }
1637
1638    #[test]
1639    fn snapshot_delay_sequence_constant() {
1640        let config = RetryStrategyConfig {
1641            strategy: RetryStrategyType::Constant,
1642            base_delay: Duration::from_millis(500),
1643            max_delay: Duration::from_secs(60),
1644            jitter: 0.0,
1645            max_attempts: 10,
1646        };
1647        let delays: Vec<String> = (1..=8)
1648            .map(|a| format!("attempt {}: {:?}", a, calculate_delay(&config, a)))
1649            .collect();
1650        assert_yaml_snapshot!(delays);
1651    }
1652
1653    #[test]
1654    fn snapshot_all_strategies_at_attempt_5() {
1655        let make = |s| RetryStrategyConfig {
1656            strategy: s,
1657            base_delay: Duration::from_secs(1),
1658            max_delay: Duration::from_secs(120),
1659            jitter: 0.0,
1660            max_attempts: 10,
1661        };
1662        let result: Vec<String> = [
1663            RetryStrategyType::Immediate,
1664            RetryStrategyType::Exponential,
1665            RetryStrategyType::Linear,
1666            RetryStrategyType::Constant,
1667        ]
1668        .iter()
1669        .map(|&s| format!("{:?}: {:?}", s, calculate_delay(&make(s), 5)))
1670        .collect();
1671        assert_yaml_snapshot!(result);
1672    }
1673}