anodizer_core/retry/driver.rs
1use super::*;
2use std::fmt;
3use std::ops::ControlFlow;
4use std::time::Duration;
5
6/// Retry a synchronous operation according to `policy`.
7///
8/// `op` returns:
9/// - `Ok(T)` on success (no retry).
10/// - `Err(ControlFlow::Continue(e))` to retry if attempts remain.
11/// - `Err(ControlFlow::Break(e))` to stop immediately (4xx-style fast-fail).
12///
13/// Returns the last error if all attempts are exhausted.
14///
15/// This variant is attempt-count-bounded only; a caller that wants a wall-clock
16/// budget (a shorter or operator-raised [`DEFAULT_MAX_ELAPSED`]) uses
17/// [`retry_sync_deadline`] with the deadline from
18/// [`crate::Context::retry_deadline`].
19///
20/// Every failed attempt that will be retried emits a default-visible warn
21/// (`<desc> attempt n/max failed (<cause>); retrying in <delay>`) via `rlog`
22/// before the backoff sleep, so a multi-minute ladder is never silent.
23pub fn retry_sync<T, E, F>(rlog: RetryLog<'_>, policy: &RetryPolicy, op: F) -> Result<T, E>
24where
25 E: fmt::Display,
26 F: FnMut(u32) -> Result<T, ControlFlow<E, E>>,
27{
28 retry_sync_deadline(rlog, policy, None, op)
29}
30
31/// Like [`retry_sync`], but stops retrying once the next backoff sleep would
32/// push total wall-time past `deadline`. On budget exhaustion it returns the
33/// last error observed before the budget was hit, so a caller whose write is
34/// idempotent recovers on re-run instead of being killed mid-attempt by an
35/// outer timeout. `deadline: None` is byte-for-byte the attempt-count-only
36/// behavior of [`retry_sync`].
37pub fn retry_sync_deadline<T, E, F>(
38 rlog: RetryLog<'_>,
39 policy: &RetryPolicy,
40 deadline: Option<std::time::Instant>,
41 mut op: F,
42) -> Result<T, E>
43where
44 E: fmt::Display,
45 F: FnMut(u32) -> Result<T, ControlFlow<E, E>>,
46{
47 retry_steps_sync(rlog, policy.max_attempts, deadline, |attempt| {
48 controlflow_to_step(policy, attempt, op(attempt))
49 })
50}
51
52/// Adapt a [`ControlFlow`]-classified result into a [`RetryStep`], using
53/// `policy` for the backoff shape: `Ok` → `Done`, `Break` → `Fail` (fast-fail),
54/// `Continue(e)` → `Retry` sleeping `policy.delay_for(attempt + 1)` with `e`'s
55/// `Display` as the per-attempt cause. Single-sources the mapping the sync and
56/// async [`ControlFlow`] adapters share.
57fn controlflow_to_step<T, E: fmt::Display>(
58 policy: &RetryPolicy,
59 attempt: u32,
60 result: Result<T, ControlFlow<E, E>>,
61) -> RetryStep<T, E> {
62 match result {
63 Ok(v) => RetryStep::Done(v),
64 Err(ControlFlow::Break(e)) => RetryStep::Fail(e),
65 Err(ControlFlow::Continue(e)) => {
66 let cause = e.to_string();
67 RetryStep::Retry {
68 error: e,
69 delay: policy.delay_for(attempt + 1),
70 cause,
71 }
72 }
73 }
74}
75
76/// Retry an asynchronous operation according to `policy`.
77///
78/// Same semantics as `retry_sync` but awaits `op` and uses `tokio::time::sleep`.
79pub async fn retry_async<T, E, F, Fut>(
80 rlog: RetryLog<'_>,
81 policy: &RetryPolicy,
82 op: F,
83) -> Result<T, E>
84where
85 E: fmt::Display,
86 F: FnMut(u32) -> Fut,
87 Fut: std::future::Future<Output = Result<T, ControlFlow<E, E>>>,
88{
89 retry_async_deadline(rlog, policy, None, op).await
90}
91
92/// Like [`retry_async`], but stops once the next backoff would push total
93/// wall-time past `deadline` — the async counterpart of [`retry_sync_deadline`],
94/// so async publishers (release-asset uploads, GitLab/Gitea API calls layered on
95/// [`retry_http_async`]) can honor the same [`crate::Context::retry_deadline`]
96/// budget. `deadline: None` is byte-for-byte the attempt-count-only behavior.
97pub async fn retry_async_deadline<T, E, F, Fut>(
98 rlog: RetryLog<'_>,
99 policy: &RetryPolicy,
100 deadline: Option<std::time::Instant>,
101 mut op: F,
102) -> Result<T, E>
103where
104 E: fmt::Display,
105 F: FnMut(u32) -> Fut,
106 Fut: std::future::Future<Output = Result<T, ControlFlow<E, E>>>,
107{
108 retry_steps_async(rlog, policy.max_attempts, deadline, |attempt| {
109 let fut = op(attempt);
110 async move { controlflow_to_step(policy, attempt, fut.await) }
111 })
112 .await
113}
114
115/// One attempt's outcome for the step-based retry engines
116/// ([`retry_steps_sync`] / [`retry_steps_async`]).
117///
118/// The operation closure owns *both* classification and the backoff duration;
119/// the engine owns everything a hand-rolled loop repeatedly gets wrong — the
120/// attempt cap, the wall-clock deadline, backoff accounting, and the full
121/// warn / giving-up / succeeded log lifecycle. This is the single primitive
122/// every retry ladder in the tree routes through: a publisher that needs a
123/// bespoke delay (unjittered exponential, a linear `5·attempt` ladder, a
124/// rate-limit reset window) or a bespoke classifier (transient-output markers,
125/// index-propagation lag, a partial-upload probe) expresses it in the closure
126/// instead of re-implementing the loop and drifting from the others.
127///
128/// The [`ControlFlow`]-based [`retry_sync`] / [`retry_async`] adapters and the
129/// HTTP wrappers are themselves thin layers over these engines, so a fixed
130/// [`RetryPolicy`] and a caller-owned delay share one loop, one deadline check,
131/// and one set of log lines.
132pub enum RetryStep<T, E> {
133 /// Stop and succeed with this value. When at least one retry preceded it,
134 /// the engine emits the recovery ("succeeded after N attempt(s)") line —
135 /// so reserve `Done` for a *clean* success the operator wants confirmed.
136 Done(T),
137 /// Stop and succeed with this value, but suppress the recovery line. For a
138 /// terminal outcome that is success-valued yet carries its own narrative —
139 /// an idempotent skip, a tolerated degraded disposition (a kept-stale
140 /// asset) — where a "succeeded after N attempt(s)" note would contradict
141 /// the closure's own log line rather than confirm a recovery.
142 DoneQuiet(T),
143 /// Stop and fail with this non-retriable error (a 4xx-style fast-fail).
144 /// The engine emits no giving-up line: the operation already classified
145 /// this as terminal and knows why.
146 Fail(E),
147 /// A retriable failure. If an attempt and the wall-clock budget both
148 /// remain, the engine sleeps `delay` (recorded as run backoff) and re-runs
149 /// the closure; otherwise it stops and returns `error`. `cause` is the
150 /// compact, human-readable reason rendered in the per-attempt warn line
151 /// (e.g. `"status=503"`, `"sparse-index propagation lag"`).
152 Retry {
153 error: E,
154 delay: Duration,
155 cause: String,
156 },
157}
158
159/// Retry a synchronous operation whose closure owns classification and backoff.
160///
161/// `max_attempts` bounds the attempt count (clamped to ≥1). `deadline`
162/// optionally bounds wall-clock time: before each backoff sleep the engine
163/// checks whether `now + delay` would pass it and, if so, stops with the last
164/// error (an idempotent write then recovers on re-run instead of being killed
165/// mid-attempt by an outer timeout). Every retriable failure emits a
166/// default-visible warn before its sleep, an exhausted ladder emits a
167/// giving-up warn, and a recovery after ≥1 retry emits a succeeded line — the
168/// one retry-log lifecycle shared by every ladder.
169pub fn retry_steps_sync<T, E, F>(
170 rlog: RetryLog<'_>,
171 max_attempts: u32,
172 deadline: Option<std::time::Instant>,
173 mut op: F,
174) -> Result<T, E>
175where
176 F: FnMut(u32) -> RetryStep<T, E>,
177{
178 let max = max_attempts.max(1);
179 let mut attempt: u32 = 1;
180 loop {
181 match op(attempt) {
182 RetryStep::Done(v) => {
183 if attempt > 1 {
184 rlog.note_succeeded(attempt);
185 }
186 return Ok(v);
187 }
188 RetryStep::DoneQuiet(v) => return Ok(v),
189 RetryStep::Fail(e) => return Err(e),
190 RetryStep::Retry {
191 error,
192 delay,
193 cause,
194 } => {
195 if attempt >= max || deadline_exhausted(deadline, delay) {
196 rlog.warn_giving_up(attempt);
197 return Err(error);
198 }
199 rlog.warn_retry(attempt, max, &cause, delay);
200 sleep_backoff_blocking(delay);
201 }
202 }
203 attempt += 1;
204 }
205}
206
207/// Async counterpart of [`retry_steps_sync`]; sleeps via [`sleep_backoff_async`]
208/// so async ladders honor the same deadline and backoff accounting.
209pub async fn retry_steps_async<T, E, F, Fut>(
210 rlog: RetryLog<'_>,
211 max_attempts: u32,
212 deadline: Option<std::time::Instant>,
213 mut op: F,
214) -> Result<T, E>
215where
216 F: FnMut(u32) -> Fut,
217 Fut: std::future::Future<Output = RetryStep<T, E>>,
218{
219 let max = max_attempts.max(1);
220 let mut attempt: u32 = 1;
221 loop {
222 match op(attempt).await {
223 RetryStep::Done(v) => {
224 if attempt > 1 {
225 rlog.note_succeeded(attempt);
226 }
227 return Ok(v);
228 }
229 RetryStep::DoneQuiet(v) => return Ok(v),
230 RetryStep::Fail(e) => return Err(e),
231 RetryStep::Retry {
232 error,
233 delay,
234 cause,
235 } => {
236 if attempt >= max || deadline_exhausted(deadline, delay) {
237 rlog.warn_giving_up(attempt);
238 return Err(error);
239 }
240 rlog.warn_retry(attempt, max, &cause, delay);
241 sleep_backoff_async(delay).await;
242 }
243 }
244 attempt += 1;
245 }
246}
247
248/// Whether sleeping `delay` now would carry total wall-time past `deadline`.
249/// A saturating check: a projection that overflows `Instant` is treated as
250/// past any real deadline (stop) rather than panicking. `None` deadline is
251/// never exhausted (attempt-count-only bound).
252fn deadline_exhausted(deadline: Option<std::time::Instant>, delay: Duration) -> bool {
253 deadline.is_some_and(|d| {
254 std::time::Instant::now()
255 .checked_add(delay)
256 .is_none_or(|projected| projected > d)
257 })
258}