1use crate::config::RetryConfig;
4use crate::traits::IsRetryable;
5
6pub async fn with_backoff<F, Fut, T, E>(config: &RetryConfig, mut f: F) -> Result<T, RetryError<E>>
22where
23 F: FnMut() -> Fut,
24 Fut: std::future::Future<Output = Result<T, E>>,
25 E: IsRetryable + std::fmt::Display,
26{
27 for attempt in 0..=config.max_retries {
28 match f().await {
29 Ok(value) => return Ok(value),
30 Err(err) => {
31 if !err.is_retryable() || attempt == config.max_retries {
32 #[cfg(feature = "tracing")]
33 tracing::warn!(
34 attempt,
35 max_retries = config.max_retries,
36 error = %err,
37 "all retries exhausted"
38 );
39 return Err(RetryError::FinalError(err, attempt));
40 }
41
42 let delay = config.delay_for_attempt(attempt);
43 #[cfg(feature = "tracing")]
44 tracing::error!(
45 attempt,
46 max_retries = config.max_retries,
47 error = %err,
48 "error encountered, will retry"
49 );
50 #[cfg(feature = "tracing")]
51 tracing::debug!(
52 attempt,
53 max_retries = config.max_retries,
54 delay_ms = delay.as_millis() as u64,
55 error = %err,
56 "retrying after error"
57 );
58 tokio::time::sleep(delay).await;
59 }
60 }
61 }
62
63 #[cfg(feature = "tracing")]
64 tracing::warn!(
65 max_retries = config.max_retries,
66 "all retries exhausted (unreachable path)"
67 );
68 unreachable!("retry loop always returns on its final iteration");
72}
73
74#[derive(Debug, thiserror::Error)]
76pub enum RetryError<E> {
77 #[error("all {1} retries exhausted: {0}")]
79 FinalError(E, u32),
80}
81
82impl<E: IsRetryable + std::fmt::Display> RetryError<E> {
83 pub fn into_inner(self) -> E {
85 match self {
86 Self::FinalError(e, _) => e,
87 }
88 }
89
90 pub fn retries(&self) -> u32 {
92 match self {
93 Self::FinalError(_, r) => *r,
94 }
95 }
96}
97
98#[cfg(test)]
99#[allow(clippy::unwrap_used, clippy::expect_used)]
101mod tests {
102 use super::*;
103 use std::sync::Arc;
104 use std::sync::atomic::{AtomicU32, Ordering};
105 use std::time::Duration;
106
107 #[derive(Debug)]
108 struct TestError(bool);
109
110 impl std::fmt::Display for TestError {
111 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
112 write!(f, "test error (retryable={})", self.0)
113 }
114 }
115
116 impl IsRetryable for TestError {
117 fn is_retryable(&self) -> bool {
118 self.0
119 }
120 }
121
122 #[tokio::test]
123 async fn succeeds_on_first_try() {
124 let config = RetryConfig::no_retries();
125 let result = with_backoff(&config, || async { Ok::<_, TestError>(42) }).await;
126 assert_eq!(result.unwrap(), 42);
127 }
128
129 #[tokio::test]
130 async fn retries_then_succeeds() {
131 let attempts = Arc::new(AtomicU32::new(0));
132 let attempts_clone = attempts.clone();
133 let config = RetryConfig {
134 initial_delay: Duration::from_millis(1),
135 max_retries: 3,
136 jitter: false,
137 ..Default::default()
138 };
139
140 let result = with_backoff(&config, || {
141 let a = attempts_clone.clone();
142 async move {
143 let count = a.fetch_add(1, Ordering::Relaxed);
144 if count < 2 {
145 Err(TestError(true))
146 } else {
147 Ok(42)
148 }
149 }
150 })
151 .await;
152
153 assert_eq!(result.unwrap(), 42);
154 assert_eq!(attempts.load(Ordering::Relaxed), 3);
155 }
156
157 #[tokio::test]
158 async fn non_retryable_error_returns_immediately() {
159 let config = RetryConfig::aggressive();
160 let result = with_backoff(&config, || async { Err::<i32, _>(TestError(false)) }).await;
161 assert!(result.is_err());
162 match result.unwrap_err() {
163 RetryError::FinalError(_, retries) => assert_eq!(retries, 0),
164 }
165 }
166
167 #[tokio::test]
168 async fn max_retries_exhausted() {
169 let config = RetryConfig {
170 initial_delay: Duration::from_millis(1),
171 max_retries: 2,
172 jitter: false,
173 ..Default::default()
174 };
175
176 let result = with_backoff(&config, || async { Err::<i32, _>(TestError(true)) }).await;
177 assert!(result.is_err());
178 match result.unwrap_err() {
179 RetryError::FinalError(_, retries) => assert_eq!(retries, 2),
180 }
181 }
182}