Skip to main content

cac_webhook/
retry.rs

1//! Minimal retry helpers (replaces git-only `resilient-call` for crates.io).
2
3use std::future::Future;
4use std::time::Duration;
5
6/// Simple retry policy: max attempts with exponential backoff + jitter-ish delay.
7#[derive(Debug, Clone, Copy)]
8pub struct RetryPolicy {
9    pub max_attempts: u32,
10    pub base_delay: Duration,
11}
12
13impl RetryPolicy {
14    pub fn with_max_attempts(max_attempts: u32) -> Self {
15        Self {
16            max_attempts: max_attempts.max(1),
17            base_delay: Duration::from_millis(100),
18        }
19    }
20}
21
22/// Error wrapper that can carry a source error or a timeout.
23#[derive(Debug)]
24pub struct RetryError<E> {
25    source: Option<E>,
26    timed_out: bool,
27}
28
29impl<E> RetryError<E> {
30    pub fn into_source(self) -> Option<E> {
31        self.source
32    }
33
34    pub fn timed_out(&self) -> bool {
35        self.timed_out
36    }
37}
38
39/// Run `fut` with an outer timeout. On timeout returns `RetryError` with no source.
40pub async fn with_timeout<T, E, F>(fut: F, timeout: Duration) -> Result<T, RetryError<E>>
41where
42    F: Future<Output = Result<T, E>>,
43{
44    match tokio::time::timeout(timeout, fut).await {
45        Ok(Ok(v)) => Ok(v),
46        Ok(Err(e)) => Err(RetryError {
47            source: Some(e),
48            timed_out: false,
49        }),
50        Err(_) => Err(RetryError {
51            source: None,
52            timed_out: true,
53        }),
54    }
55}
56
57/// Retry `make_fut` up to `policy.max_attempts` when `is_retryable` says so.
58pub async fn retry<T, E, Fut, Make, Pred>(
59    mut make_fut: Make,
60    policy: &RetryPolicy,
61    is_retryable: Pred,
62) -> Result<T, RetryError<E>>
63where
64    Make: FnMut() -> Fut,
65    Fut: Future<Output = Result<T, E>>,
66    Pred: Fn(&E) -> bool,
67{
68    let mut attempt = 0u32;
69    loop {
70        attempt += 1;
71        match make_fut().await {
72            Ok(v) => return Ok(v),
73            Err(e) => {
74                if attempt >= policy.max_attempts || !is_retryable(&e) {
75                    return Err(RetryError {
76                        source: Some(e),
77                        timed_out: false,
78                    });
79                }
80                let delay = policy.base_delay.saturating_mul(1 << (attempt - 1).min(4));
81                tokio::time::sleep(delay).await;
82            }
83        }
84    }
85}