1use crate::error::FaucetError;
5use crate::resilience::classify::classify;
6use crate::resilience::policy::RetryPolicy;
7use std::future::Future;
8use tokio_util::sync::CancellationToken;
9
10#[derive(Debug, Clone)]
16pub struct RetryMetrics {
17 pub pipeline: String,
19 pub row: String,
21 pub op: &'static str,
23}
24
25pub async fn execute_with_policy<F, Fut, T>(
35 policy: &RetryPolicy,
36 cancel: Option<&CancellationToken>,
37 op: F,
38) -> Result<T, FaucetError>
39where
40 F: FnMut() -> Fut,
41 Fut: Future<Output = Result<T, FaucetError>>,
42{
43 run_with_policy(policy, cancel, None, op).await
44}
45
46pub async fn execute_with_policy_metered<F, Fut, T>(
53 policy: &RetryPolicy,
54 cancel: Option<&CancellationToken>,
55 metrics: &RetryMetrics,
56 op: F,
57) -> Result<T, FaucetError>
58where
59 F: FnMut() -> Fut,
60 Fut: Future<Output = Result<T, FaucetError>>,
61{
62 run_with_policy(policy, cancel, Some(metrics), op).await
63}
64
65async fn run_with_policy<F, Fut, T>(
70 policy: &RetryPolicy,
71 cancel: Option<&CancellationToken>,
72 metrics: Option<&RetryMetrics>,
73 mut op: F,
74) -> Result<T, FaucetError>
75where
76 F: FnMut() -> Fut,
77 Fut: Future<Output = Result<T, FaucetError>>,
78{
79 let max_attempts = policy.max_attempts.max(1);
86 let mut attempt = 0u32;
87 loop {
88 match op().await {
89 Ok(val) => return Ok(val),
90 Err(e) if policy.is_retriable(&e) && attempt + 1 < max_attempts => {
91 let base = policy.backoff.delay(policy.base, policy.max, attempt);
92 let wait = if policy.jitter {
93 crate::retry::apply_jitter(base)
94 } else {
95 base
96 };
97 tracing::warn!(
98 "operation failed (attempt {}/{}), retrying in {wait:?}: {e}",
99 attempt + 1,
100 max_attempts
101 );
102 if let Some(m) = metrics {
103 let class = classify(&e).map(|c| c.as_str()).unwrap_or("unknown");
105 crate::observability::resilience::retry(&m.pipeline, &m.row, m.op, class);
106 crate::observability::resilience::retry_sleep(
107 &m.pipeline,
108 &m.row,
109 m.op,
110 wait.as_secs_f64(),
111 );
112 }
113 if !wait.is_zero() {
114 match cancel {
115 Some(token) => {
116 tokio::select! {
117 biased;
118 _ = token.cancelled() => {
119 if let Some(m) = metrics {
122 crate::observability::resilience::giveup(
123 &m.pipeline, &m.row, m.op,
124 );
125 }
126 return Err(e);
127 }
128 _ = tokio::time::sleep(wait) => {}
129 }
130 }
131 None => tokio::time::sleep(wait).await,
132 }
133 }
134 attempt += 1;
135 }
136 Err(e) => {
137 if attempt > 0
141 && let Some(m) = metrics
142 {
143 crate::observability::resilience::giveup(&m.pipeline, &m.row, m.op);
144 }
145 return Err(e);
146 }
147 }
148 }
149}
150
151#[cfg(test)]
152mod tests {
153 use super::*;
154 use crate::resilience::policy::BackoffKind;
155 use std::sync::Arc;
156 use std::sync::atomic::{AtomicU32, Ordering};
157 use std::time::Duration;
158
159 fn fast_policy(max_attempts: u32) -> RetryPolicy {
160 RetryPolicy {
161 max_attempts,
162 backoff: BackoffKind::None,
163 base: Duration::from_millis(0),
164 max: Duration::from_millis(0),
165 jitter: false,
166 ..RetryPolicy::default()
167 }
168 }
169
170 #[tokio::test]
171 async fn retries_then_succeeds() {
172 let calls = Arc::new(AtomicU32::new(0));
173 let c = calls.clone();
174 let r = execute_with_policy(&fast_policy(5), None, move || {
175 let n = c.fetch_add(1, Ordering::SeqCst);
176 async move {
177 if n < 2 {
178 Err::<i32, _>(FaucetError::HttpStatus {
179 status: 503,
180 url: "u".into(),
181 body: "".into(),
182 })
183 } else {
184 Ok(42)
185 }
186 }
187 })
188 .await;
189 assert_eq!(r.unwrap(), 42);
190 assert_eq!(calls.load(Ordering::SeqCst), 3);
191 }
192
193 #[tokio::test]
194 async fn gives_up_after_max_attempts() {
195 let calls = Arc::new(AtomicU32::new(0));
196 let c = calls.clone();
197 let r = execute_with_policy(&fast_policy(3), None, move || {
198 c.fetch_add(1, Ordering::SeqCst);
199 async {
200 Err::<i32, _>(FaucetError::HttpStatus {
201 status: 503,
202 url: "u".into(),
203 body: "".into(),
204 })
205 }
206 })
207 .await;
208 assert!(r.is_err());
209 assert_eq!(calls.load(Ordering::SeqCst), 3, "1 initial + 2 retries");
210 }
211
212 #[tokio::test]
213 async fn max_attempts_zero_runs_op_exactly_once() {
214 let calls = Arc::new(AtomicU32::new(0));
219 let c = calls.clone();
220 let r = execute_with_policy(&fast_policy(0), None, move || {
221 c.fetch_add(1, Ordering::SeqCst);
222 async {
223 Err::<i32, _>(FaucetError::HttpStatus {
224 status: 503,
225 url: "u".into(),
226 body: "".into(),
227 })
228 }
229 })
230 .await;
231 assert!(r.is_err());
232 assert_eq!(
233 calls.load(Ordering::SeqCst),
234 1,
235 "max_attempts=0 clamps to 1: one execution, no retry"
236 );
237 }
238
239 #[tokio::test]
240 async fn non_retriable_fails_immediately() {
241 let calls = Arc::new(AtomicU32::new(0));
242 let c = calls.clone();
243 let r = execute_with_policy(&fast_policy(5), None, move || {
244 c.fetch_add(1, Ordering::SeqCst);
245 async { Err::<i32, _>(FaucetError::Auth("nope".into())) }
246 })
247 .await;
248 assert!(r.is_err());
249 assert_eq!(calls.load(Ordering::SeqCst), 1);
250 }
251
252 #[tokio::test]
253 async fn jittered_nonzero_backoff_with_no_cancel_retries() {
254 let policy = RetryPolicy {
258 max_attempts: 3,
259 backoff: BackoffKind::Fixed,
260 base: Duration::from_millis(2),
261 max: Duration::from_millis(2),
262 jitter: true,
263 ..RetryPolicy::default()
264 };
265 let calls = Arc::new(AtomicU32::new(0));
266 let c = calls.clone();
267 let r = execute_with_policy(&policy, None, move || {
268 let n = c.fetch_add(1, Ordering::SeqCst);
269 async move {
270 if n < 1 {
271 Err::<i32, _>(FaucetError::HttpStatus {
272 status: 503,
273 url: "u".into(),
274 body: "".into(),
275 })
276 } else {
277 Ok(9)
278 }
279 }
280 })
281 .await;
282 assert_eq!(r.unwrap(), 9);
283 assert_eq!(calls.load(Ordering::SeqCst), 2);
284 }
285
286 #[tokio::test]
287 async fn cancel_during_backoff_returns_last_error_promptly() {
288 let policy = RetryPolicy {
289 max_attempts: 10,
290 backoff: BackoffKind::Fixed,
291 base: Duration::from_secs(30),
292 max: Duration::from_secs(30),
293 jitter: false,
294 ..RetryPolicy::default()
295 };
296 let token = CancellationToken::new();
297 token.cancel(); let r = execute_with_policy(&policy, Some(&token), || async {
299 Err::<i32, _>(FaucetError::HttpStatus {
300 status: 503,
301 url: "u".into(),
302 body: "".into(),
303 })
304 })
305 .await;
306 assert!(r.is_err());
307 }
308
309 fn metrics() -> RetryMetrics {
310 RetryMetrics {
311 pipeline: "p".into(),
312 row: "r".into(),
313 op: "sink_write",
314 }
315 }
316
317 #[tokio::test]
322 async fn metered_retries_then_succeeds_same_as_bare() {
323 let calls = Arc::new(AtomicU32::new(0));
324 let c = calls.clone();
325 let m = metrics();
326 let r = execute_with_policy_metered(&fast_policy(5), None, &m, move || {
327 let n = c.fetch_add(1, Ordering::SeqCst);
328 async move {
329 if n < 2 {
330 Err::<i32, _>(FaucetError::HttpStatus {
331 status: 503,
332 url: "u".into(),
333 body: "".into(),
334 })
335 } else {
336 Ok(42)
337 }
338 }
339 })
340 .await;
341 assert_eq!(r.unwrap(), 42);
342 assert_eq!(calls.load(Ordering::SeqCst), 3);
343 }
344
345 #[tokio::test]
346 async fn metered_gives_up_after_max_attempts_same_as_bare() {
347 let calls = Arc::new(AtomicU32::new(0));
348 let c = calls.clone();
349 let m = metrics();
350 let r = execute_with_policy_metered(&fast_policy(3), None, &m, move || {
351 c.fetch_add(1, Ordering::SeqCst);
352 async {
353 Err::<i32, _>(FaucetError::HttpStatus {
354 status: 503,
355 url: "u".into(),
356 body: "".into(),
357 })
358 }
359 })
360 .await;
361 assert!(r.is_err());
362 assert_eq!(calls.load(Ordering::SeqCst), 3, "1 initial + 2 retries");
363 }
364
365 #[tokio::test]
366 async fn metered_non_retriable_fails_immediately_same_as_bare() {
367 let calls = Arc::new(AtomicU32::new(0));
368 let c = calls.clone();
369 let m = metrics();
370 let r = execute_with_policy_metered(&fast_policy(5), None, &m, move || {
371 c.fetch_add(1, Ordering::SeqCst);
372 async { Err::<i32, _>(FaucetError::Auth("nope".into())) }
373 })
374 .await;
375 assert!(r.is_err());
376 assert_eq!(calls.load(Ordering::SeqCst), 1);
377 }
378
379 #[tokio::test]
380 async fn metered_cancel_during_backoff_returns_promptly() {
381 let policy = RetryPolicy {
382 max_attempts: 10,
383 backoff: BackoffKind::Fixed,
384 base: Duration::from_secs(30),
385 max: Duration::from_secs(30),
386 jitter: false,
387 ..RetryPolicy::default()
388 };
389 let token = CancellationToken::new();
390 token.cancel();
391 let m = metrics();
392 let r = execute_with_policy_metered(&policy, Some(&token), &m, || async {
393 Err::<i32, _>(FaucetError::HttpStatus {
394 status: 503,
395 url: "u".into(),
396 body: "".into(),
397 })
398 })
399 .await;
400 assert!(r.is_err());
401 }
402}