1use std::sync::Arc;
2use std::time::Duration;
3
4use rskit_errors::{AppError, AppResult};
5
6#[derive(Debug)]
8pub struct RetryError {
9 pub attempts: usize,
11 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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
33pub struct ConstantBackoff {
34 pub delay: Duration,
36}
37
38impl ConstantBackoff {
39 #[must_use]
41 pub fn new(delay: Duration) -> Self {
42 Self { delay }
43 }
44}
45
46#[derive(Debug, Clone, Copy, PartialEq, Eq)]
48pub struct LinearBackoff {
49 pub initial_backoff: Duration,
51 pub increment: Duration,
53 pub max_backoff: Duration,
55}
56
57impl LinearBackoff {
58 #[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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
71#[non_exhaustive]
72pub enum BackoffKind {
73 Exponential,
75 Constant,
77 Linear,
79}
80
81#[derive(Debug, Clone, Copy, PartialEq, Eq)]
83#[non_exhaustive]
84pub enum RetryPreset {
85 Fast,
87 Standard,
89 ExternalService,
91}
92
93pub struct RetryPolicy {
110 pub max_attempts: usize,
112 pub initial_backoff: Duration,
114 pub max_backoff: Duration,
116 pub max_elapsed_time: Duration,
118 pub backoff_factor: f64,
120 pub jitter: bool,
122 pub backoff_kind: BackoffKind,
124 pub linear_increment: Duration,
126 #[allow(clippy::type_complexity)]
129 pub retry_if: Option<Arc<dyn Fn(&AppError) -> bool + Send + Sync>>,
130 #[allow(clippy::type_complexity)]
133 pub on_retry: Option<Arc<dyn Fn(u32, &AppError) + Send + Sync>>,
134 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 #[must_use]
195 pub fn new() -> Self {
196 Self::default()
197 }
198
199 #[must_use]
201 pub fn from_preset(preset: RetryPreset) -> Self {
202 preset.policy()
203 }
204
205 #[must_use]
207 pub fn fast() -> Self {
208 RetryPreset::Fast.policy()
209 }
210
211 #[must_use]
213 pub fn standard() -> Self {
214 RetryPreset::Standard.policy()
215 }
216
217 #[must_use]
219 pub fn external_service() -> Self {
220 RetryPreset::ExternalService.policy()
221 }
222
223 #[must_use]
225 pub fn with_max_attempts(mut self, n: usize) -> Self {
226 self.max_attempts = n;
227 self
228 }
229
230 #[must_use]
232 pub fn with_initial_backoff(mut self, d: Duration) -> Self {
233 self.initial_backoff = d;
234 self
235 }
236
237 #[must_use]
239 pub fn with_max_backoff(mut self, d: Duration) -> Self {
240 self.max_backoff = d;
241 self
242 }
243
244 #[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 #[must_use]
253 pub fn with_backoff_factor(mut self, f: f64) -> Self {
254 self.backoff_factor = f;
255 self
256 }
257
258 #[must_use]
260 pub fn with_jitter(mut self, enabled: bool) -> Self {
261 self.jitter = enabled;
262 self
263 }
264
265 #[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 #[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 #[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 #[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 #[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 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 #[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 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 #[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}