Skip to main content

mailrs_outbound_queue/
retry.rs

1//! Exponential-backoff retry helpers for SMTP outbound delivery.
2//!
3//! Backed by [`mailrs_backoff::Backoff::smtp_outbound`]. Replaces the
4//! previous hardcoded 8-slot schedule with a clean parametric curve;
5//! gains Full jitter to avoid thundering-herd on shared MX downtime.
6
7use mailrs_backoff::Backoff;
8
9/// Retry delay in seconds for attempt `n` (0-indexed), **deterministic
10/// schedule** (no jitter). For jittered scheduling use [`retry_delay_secs_jittered`].
11///
12/// Backed by `Backoff::smtp_outbound`: initial 60s, 2.5× growth, capped
13/// at 8 hours. Compared to the pre-1.1 hardcoded schedule
14/// `[60, 300, 900, 1800, 3600, 7200, 14400, 28800]`, this curve grows
15/// slightly faster in the early attempts but converges at the same
16/// 8-hour cap.
17pub fn retry_delay_secs(attempt: u32) -> u64 {
18    Backoff::smtp_outbound().base_delay(attempt).as_secs()
19}
20
21/// Retry delay in seconds with Full jitter applied. Caller supplies a
22/// random seed (e.g. `Instant::now().elapsed().as_nanos() as u64` or
23/// `rand::random::<u64>()`). The same `(attempt, seed)` pair always
24/// yields the same delay, so tests can reproduce.
25///
26/// Use this in production schedulers to spread retry traffic and
27/// avoid synchronized retry bursts across queue rows that all failed
28/// at the same time (MX outage, DNS hiccup).
29pub fn retry_delay_secs_jittered(attempt: u32, seed: u64) -> u64 {
30    Backoff::smtp_outbound().delay(attempt, seed).as_secs()
31}
32
33/// Should this attempt give up and bounce the message? Returns `true`
34/// when `attempt >= max_attempts`. Equivalent to
35/// `mailrs_backoff::Backoff::should_give_up`; kept for back-compat.
36pub fn should_bounce(attempt: u32, max_attempts: u32) -> bool {
37    Backoff::should_give_up(attempt, max_attempts)
38}
39
40#[cfg(test)]
41mod tests {
42    use super::*;
43
44    /// The new schedule under `Backoff::smtp_outbound` (initial 60s,
45    /// 2.5× growth, capped at 8h = 28800s).
46    ///
47    /// Computed by hand: 60 × 2.5^n, capped at 28800.
48    ///   n=0: 60
49    ///   n=1: 150
50    ///   n=2: 375
51    ///   n=3: 937 (937.5 floored)
52    ///   n=4: 2343 (2343.75)
53    ///   n=5: 5859 (5859.375)
54    ///   n=6: 14648 (14648.4375)
55    ///   n=7: 28800 (would be 36621, capped)
56    #[test]
57    fn delay_schedule_new_curve() {
58        assert_eq!(retry_delay_secs(0), 60);
59        assert_eq!(retry_delay_secs(1), 150);
60        assert_eq!(retry_delay_secs(2), 375);
61        // Floating point: 60 * 2.5^3 = 937.5 → 937 as u64
62        let d3 = retry_delay_secs(3);
63        assert!((937..=938).contains(&d3), "expected ~937, got {d3}");
64        let d4 = retry_delay_secs(4);
65        assert!((2343..=2344).contains(&d4), "expected ~2343, got {d4}");
66    }
67
68    #[test]
69    fn cap_at_8h() {
70        // Anything past attempt ~6 hits the 8h cap.
71        assert_eq!(retry_delay_secs(7), 28800);
72        assert_eq!(retry_delay_secs(8), 28800);
73        assert_eq!(retry_delay_secs(100), 28800);
74    }
75
76    #[test]
77    fn bounce_at_max() {
78        assert!(should_bounce(5, 5));
79        assert!(should_bounce(10, 5));
80    }
81
82    #[test]
83    fn no_bounce_below_max() {
84        assert!(!should_bounce(0, 5));
85        assert!(!should_bounce(4, 5));
86    }
87
88    #[test]
89    fn delay_is_monotonically_increasing() {
90        let mut prev = 0u64;
91        for i in 0..8u32 {
92            let d = retry_delay_secs(i);
93            assert!(
94                d > prev,
95                "delay at slot {i} ({d}) is not greater than previous ({prev})"
96            );
97            prev = d;
98        }
99    }
100
101    #[test]
102    fn should_bounce_boundary_exact() {
103        assert!(should_bounce(1, 1));
104        assert!(should_bounce(0, 0));
105    }
106
107    #[test]
108    fn should_bounce_large_values() {
109        assert!(!should_bounce(u32::MAX - 1, u32::MAX));
110        assert!(should_bounce(u32::MAX, u32::MAX));
111    }
112
113    #[test]
114    fn delay_first_attempt_is_one_minute() {
115        assert_eq!(retry_delay_secs(0), 60);
116    }
117
118    #[test]
119    fn delay_cap_at_various_overflow_attempts() {
120        for attempt in [8, 9, 10, 50, 100, 255, 1000, u32::MAX] {
121            assert_eq!(
122                retry_delay_secs(attempt),
123                28800,
124                "attempt {attempt} should be capped at 8h"
125            );
126        }
127    }
128
129    #[test]
130    fn delay_each_step_grows() {
131        for i in 1..7u32 {
132            let prev = retry_delay_secs(i - 1);
133            let curr = retry_delay_secs(i);
134            assert!(
135                curr >= prev * 2,
136                "step {i}: {curr} should be at least 2× {prev}"
137            );
138        }
139    }
140
141    #[test]
142    fn delay_minimum_is_60_seconds() {
143        assert_eq!(retry_delay_secs(0), 60);
144        // No call to retry_delay_secs should ever return less than 60.
145        for n in 0..8u32 {
146            assert!(retry_delay_secs(n) >= 60, "attempt {n} returned < 60");
147        }
148    }
149
150    // ===== jitter variant tests =====
151
152    #[test]
153    fn jittered_delay_within_full_range() {
154        let base = retry_delay_secs(3);
155        for seed in 0..50u64 {
156            let d = retry_delay_secs_jittered(3, seed);
157            // Full jitter: 0 <= d < base
158            assert!(d < base, "seed {seed}: jittered {d} >= base {base}");
159        }
160    }
161
162    #[test]
163    fn jittered_deterministic_with_same_seed() {
164        assert_eq!(
165            retry_delay_secs_jittered(3, 42),
166            retry_delay_secs_jittered(3, 42),
167        );
168    }
169
170    #[test]
171    fn jittered_attempt_zero_bounded() {
172        // base for attempt=0 is 60s. Full jitter: result in [0, 60).
173        for seed in 0..20u64 {
174            let d = retry_delay_secs_jittered(0, seed);
175            assert!(d < 60, "seed {seed}: {d} >= 60");
176        }
177    }
178
179    #[test]
180    fn should_bounce_one_attempt_max() {
181        assert!(!should_bounce(0, 1));
182        assert!(should_bounce(1, 1));
183        assert!(should_bounce(2, 1));
184    }
185
186    #[test]
187    fn should_bounce_high_max_attempts() {
188        let max = 100;
189        assert!(!should_bounce(99, max));
190        assert!(should_bounce(100, max));
191        assert!(should_bounce(101, max));
192    }
193
194    #[test]
195    fn delay_returns_u64() {
196        let d: u64 = retry_delay_secs(0);
197        assert_eq!(d, 60u64);
198    }
199}