Skip to main content

anodizer_core/retry/
policy.rs

1use crate::log::StageLogger;
2use std::fmt;
3use std::time::Duration;
4
5/// Names the operation a retry engine is driving and carries the logger that
6/// surfaces per-attempt failures.
7///
8/// A required parameter on every retry engine — not an optional builder — so
9/// a silent retry is unrepresentable: a backoff ladder can sleep for many
10/// minutes (10 attempts × 5m cap), and an operator watching a run must be
11/// able to tell "waiting on a transient failure" from "hung".
12#[derive(Clone, Copy)]
13pub struct RetryLog<'a> {
14    desc: &'a str,
15    log: &'a StageLogger,
16}
17
18impl<'a> RetryLog<'a> {
19    /// `desc` is a short human description of the operation being retried
20    /// (e.g. `"chocolatey push"`, `"mastodon announce"`); it prefixes every
21    /// per-attempt warn line.
22    pub fn new(desc: &'a str, log: &'a StageLogger) -> Self {
23        Self { desc, log }
24    }
25
26    /// The operation description supplied at construction.
27    pub fn desc(&self) -> &str {
28        self.desc
29    }
30
31    pub(super) fn warn_retry(
32        &self,
33        attempt: u32,
34        max: u32,
35        cause: &dyn fmt::Display,
36        delay: Duration,
37    ) {
38        // Spelled through the tool's one duration format (`45s`, `2m15s`) so a
39        // retry line and an adjacent heartbeat line read the same way.
40        self.log.warn(&format!(
41            "{} attempt {}/{} failed ({}); retrying in {}",
42            self.desc,
43            attempt,
44            max,
45            cause,
46            crate::progress::format_elapsed(delay)
47        ));
48    }
49
50    /// Warn that the ladder exhausted its attempts (or wall-clock budget) and is
51    /// giving up after `attempts` tries. Paired with the error the engine then
52    /// returns: the error names *what* failed, this line records that the
53    /// retries themselves are spent so a watcher does not wait for more.
54    pub(super) fn warn_giving_up(&self, attempts: u32) {
55        self.log.warn(&format!(
56            "{} failed after {} attempt(s), giving up",
57            self.desc, attempts
58        ));
59    }
60
61    /// Note (default-visible) that the operation recovered after `attempts`
62    /// tries — the transient failure cleared. Only emitted once at least one
63    /// retry has happened, so a clean first attempt stays silent.
64    pub(super) fn note_succeeded(&self, attempts: u32) {
65        // status, not warn: a recovered transient is a positive per-operation
66        // result an operator wants at default verbosity, mirroring the
67        // rollback/dry-run default events — not a command echo.
68        self.log.status(&format!(
69            "{} succeeded after {} attempt(s)",
70            self.desc, attempts
71        )); // status-ok: recovered-after-retry is a per-operation result event
72    }
73}
74
75/// Retry policy used by `retry_sync` / `retry_async`.
76#[derive(Debug, Clone, Copy)]
77pub struct RetryPolicy {
78    /// Total attempts, including the first.
79    ///
80    /// Invariant: must be `>= 1`. The clamp is enforced at two layers so
81    /// every construction path is safe:
82    ///
83    /// 1. [`crate::config::RetryConfig::to_policy`] clamps user YAML
84    ///    (`attempts: 0` -> `1`) at the config-surface boundary.
85    /// 2. `retry_sync` / `retry_async` clamp again at the loop boundary
86    ///    to protect direct `RetryPolicy { max_attempts: 0, .. }`
87    ///    constructions (e.g. test fixtures).
88    ///
89    /// Callers therefore do NOT need to clamp `max_attempts` again at the
90    /// call site.
91    pub max_attempts: u32,
92    /// Delay before the second attempt (no wait before the first).
93    pub base_delay: Duration,
94    /// Upper bound on any individual sleep between attempts.
95    pub max_delay: Duration,
96}
97
98impl RetryPolicy {
99    /// Canonical upload policy: 10 attempts, 50ms
100    /// base, 30s cap.
101    pub const UPLOAD: RetryPolicy = RetryPolicy {
102        max_attempts: 10,
103        base_delay: Duration::from_millis(50),
104        max_delay: Duration::from_secs(30),
105    };
106
107    /// Shallow policy for best-effort pre-publish probes: 3 attempts, 200ms
108    /// base, 1s cap.
109    ///
110    /// Pre-publish probes (token `whoami`, registry index GET, GitHub repo
111    /// scope, npm duplicate-version) are an advisory warning gate, not a
112    /// write that must land. They run sequentially across every configured
113    /// publisher, so the production write-ladder (10 attempts / 10s base /
114    /// 5m cap) would let a single wedged endpoint stall the gate for tens of
115    /// minutes. A shallow bound keeps the probe responsive while still
116    /// absorbing a transient blip; the per-request HTTP timeout still bounds
117    /// each individual attempt.
118    pub const PREFLIGHT: RetryPolicy = RetryPolicy {
119        max_attempts: 3,
120        base_delay: Duration::from_millis(200),
121        max_delay: Duration::from_secs(1),
122    };
123
124    /// Shallow policy for burn-detection guard probes (published-state
125    /// registry lookups made before a destructive rollback): 3 attempts, 1s
126    /// base, 30s cap.
127    ///
128    /// A guard consults one registry endpoint per crate/package, and a
129    /// multi-crate workspace probes many of them in one pass, so the
130    /// production write-ladder (up to ~25 minutes of backoff per operation)
131    /// would let a registry outage stall the guard for hours before it can
132    /// classify the outcome. A shallow, capped ladder keeps the whole probe
133    /// pass bounded while still absorbing a transient blip; the guard's own
134    /// fail-closed / fail-open classification handles genuine outages.
135    pub const GUARD_PROBE: RetryPolicy = RetryPolicy {
136        max_attempts: 3,
137        base_delay: Duration::from_secs(1),
138        max_delay: Duration::from_secs(30),
139    };
140
141    pub fn delay_for(&self, next_attempt: u32) -> Duration {
142        // `next_attempt` is the attempt we're about to run (≥2). The wait
143        // before attempt 2 uses base_delay; before attempt 3 uses base_delay*2;
144        // i.e. multiplier = 2^(next_attempt - 2).
145        let exp = next_attempt.saturating_sub(2);
146        let mult = 1u64.checked_shl(exp).unwrap_or(u64::MAX);
147        let ms = (self.base_delay.as_millis() as u64).saturating_mul(mult);
148        std::cmp::min(Duration::from_millis(ms), self.max_delay)
149    }
150
151    /// Raise this policy's `max_attempts` to at least [`IDEMPOTENT_PUT_ATTEMPTS`]
152    /// without disturbing its backoff shape, returning the adjusted policy.
153    ///
154    /// An idempotent PUT/POST to a fixed target (an Artifactory/generic upload,
155    /// a GemFury push, a Snap Store upload, a bucket blob PUT, a GitHub asset
156    /// upload) lands the same bytes at the same path on every re-issue, so a
157    /// transient 5xx/429 or dropped connection must retry a bounded number of
158    /// times even when a stateful mode (`--publish-only`) resolves the
159    /// configured policy down to `attempts: 1`. The floor is a `max()` — it
160    /// only widens the bound for the retriable classes and never lowers an
161    /// operator-set higher value. 4xx responses still fast-fail inside the
162    /// per-attempt classifier regardless of this floor.
163    pub fn with_idempotent_floor(self) -> RetryPolicy {
164        self.with_floor(IDEMPOTENT_PUT_ATTEMPTS)
165    }
166
167    /// Raise this policy's `max_attempts` to at least `min`, leaving the backoff
168    /// shape untouched. A `max()` floor, never a clamp that lowers a higher
169    /// operator-set value.
170    pub fn with_floor(self, min: u32) -> RetryPolicy {
171        RetryPolicy {
172            max_attempts: self.max_attempts.max(min),
173            ..self
174        }
175    }
176
177    /// Whether the wait before `next_attempt` would carry total wall-time past
178    /// `deadline`. Checked before each backoff so a long registry storm exits
179    /// cleanly (the last error is returned, and an idempotent write recovers on
180    /// re-run) instead of being SIGKILLed mid-publish by the outer job timeout.
181    /// Shared by the sync and async ladders so both bound identically.
182    ///
183    /// A saturating check: an uncapped policy (`max_delay: Duration::MAX`) can
184    /// project a backoff so large that `Instant::now() + delay` would overflow;
185    /// an overflowing projection is treated as past any real deadline (the ladder
186    /// stops) rather than panicking.
187    pub fn budget_exhausted(&self, next_attempt: u32, deadline: std::time::Instant) -> bool {
188        match std::time::Instant::now().checked_add(self.delay_for(next_attempt)) {
189            Some(projected) => projected > deadline,
190            None => true,
191        }
192    }
193}
194
195/// Total attempt floor for an idempotent PUT/POST, single-sourcing the
196/// "3 total attempts" guarantee shared by every idempotent-upload publisher
197/// (HTTP upload, GemFury, Snapcraft, GitHub asset, blob). Applied via
198/// [`RetryPolicy::with_idempotent_floor`] as a `max()` so a stateful mode
199/// (`--publish-only`) that resolves `attempts: 1` still keeps a bounded
200/// transient retry, while an operator-set higher cap is preserved.
201pub const IDEMPOTENT_PUT_ATTEMPTS: u32 = 3;
202
203/// Default wall-clock budget for a retry ladder when `retry.max_elapsed` is not
204/// set. Resolved into an absolute deadline by [`crate::context::Context::retry_deadline`]
205/// and threaded into the engine by publishers, so a ladder bounded only by
206/// attempt count cannot run unbounded on a slow-but-not-failing endpoint. It is
207/// a *default*, not a hard ceiling: an operator raises (or lowers) it with
208/// `retry.max_elapsed`, and a caller that threads `None` is still unbounded.
209pub const DEFAULT_MAX_ELAPSED: Duration = Duration::from_secs(15 * 60);
210
211/// Wall-clock time slept in retry backoff so far this run, in milliseconds.
212///
213/// A release runs as one process, so a single process-global accumulator
214/// captures every stage's backoff without threading a handle through the many
215/// independent per-stage retry loops — several of which sleep via an injected
216/// callback that has no path to carry a handle. Parallel upload workers add
217/// concurrently through the atomic. Read once at summary time via
218/// [`total_retry_backoff`]; the run surfaces it as a `retry_backoff_secs`
219/// field and an operator status line.
220static RETRY_BACKOFF_MILLIS: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
221
222/// Per-scope retry tally (backoff sleeps + summed wait), keyed by the label of
223/// the enclosing [`RetryScope`]. Backoff recorded with no active scope lands
224/// under [`UNATTRIBUTED_SCOPE`] so the per-scope rows always sum to the global
225/// total. A `Mutex` (not a lock-free map) is ample: retry sleeps are seconds
226/// apart, so contention among the parallel upload workers is negligible.
227static PER_SCOPE_RETRY: std::sync::Mutex<std::collections::BTreeMap<String, ScopeRetry>> =
228    std::sync::Mutex::new(std::collections::BTreeMap::new());
229
230/// The label backoff is attributed to while a [`RetryScope`] is active. Stages
231/// run serially and each installs one scope, so a single global cell suffices:
232/// the release stage's parallel upload tasks all read the same constant value
233/// ("release") for the stage's duration, and the serial publish loop swaps it
234/// per publisher. No task-local is needed because the value never differs
235/// between two concurrently-running sleeps.
236static CURRENT_SCOPE: std::sync::Mutex<Option<String>> = std::sync::Mutex::new(None);
237
238/// Bucket key for backoff recorded outside any [`RetryScope`].
239const UNATTRIBUTED_SCOPE: &str = "(unattributed)";
240
241#[derive(Clone, Copy, Default)]
242struct ScopeRetry {
243    /// Number of backoff sleeps (i.e. retries) recorded against this scope.
244    retries: u32,
245    /// Summed backoff wait for this scope, in milliseconds.
246    backoff_ms: u64,
247}
248
249/// RAII scope that attributes every backoff sleep recorded during its lifetime
250/// to `name` (a publisher or stage label). Restores the previous scope on drop,
251/// so nested/sequential scopes compose. Install one around each publisher's
252/// `run` and around a stage's whole retrying section.
253#[must_use = "the scope only applies while the guard is alive"]
254pub struct RetryScope {
255    prev: Option<String>,
256}
257
258impl RetryScope {
259    /// Enter a retry-attribution scope named `name`.
260    pub fn enter(name: impl Into<String>) -> Self {
261        let mut cur = CURRENT_SCOPE.lock().unwrap_or_else(|e| e.into_inner());
262        let prev = cur.replace(name.into());
263        RetryScope { prev }
264    }
265}
266
267impl Drop for RetryScope {
268    fn drop(&mut self) {
269        *CURRENT_SCOPE.lock().unwrap_or_else(|e| e.into_inner()) = self.prev.take();
270    }
271}
272
273thread_local! {
274    /// Wall-clock anchor of the publisher invocation in progress on THIS
275    /// thread, installed by [`PublisherRetryScope`] and read by
276    /// [`crate::Context::retry_deadline`], which adds the configured
277    /// `retry.max_elapsed` to it.
278    ///
279    /// Thread-local, unlike the process-global [`CURRENT_SCOPE`] next to it,
280    /// and the difference is deliberate. Backoff attribution has to be global
281    /// because the sleeps it counts happen in worker threads a publisher fans
282    /// out to. The budget anchor has the opposite constraint: it is read only
283    /// through `&Context`, which a publisher receives as `&mut` on the
284    /// dispatching thread and never shares, so the anchor is read on exactly
285    /// the thread that installed it. Keeping it per-thread makes the guard's
286    /// save/restore genuinely LIFO — a process-global cell could be entered by
287    /// two threads at once and have the outer guard's drop strand the inner
288    /// one's anchor, wedging every later deadline into the already-elapsed past.
289    static CURRENT_BUDGET_ANCHOR: std::cell::Cell<Option<std::time::Instant>> =
290        const { std::cell::Cell::new(None) };
291}
292
293/// The wall-clock anchor of the enclosing [`PublisherRetryScope`], or `None`
294/// outside one (a stage-level caller, or a unit test calling a publisher
295/// helper directly).
296pub fn current_budget_anchor() -> Option<std::time::Instant> {
297    CURRENT_BUDGET_ANCHOR.with(std::cell::Cell::get)
298}
299
300/// RAII scope for ONE [`crate::Publisher`] invocation: it installs the backoff
301/// attribution label (a [`RetryScope`]) and anchors the wall-clock retry budget
302/// that [`crate::context::Context::retry_deadline`] reports for the whole invocation.
303///
304/// Install exactly one at every seam that calls into a publisher —
305/// `run`, `reconcile`, `rollback`, `preflight`. The budget belongs to the
306/// invocation, not to the call that happens to ask for it: a publisher that
307/// resolves the deadline at three seams (an auth exchange, the publish request,
308/// a cleanup) gets one budget covering all three, so a wedged registry cannot
309/// spend `retry.max_elapsed` once per seam.
310///
311/// Entering while a budget is already open INHERITS it rather than replacing
312/// it, so nothing reachable from inside a publisher — including a nested guard
313/// of this same type — can widen the invocation's budget. A later, genuinely
314/// distinct invocation (the rollback pass after `run` returned) enters its own
315/// guard once the previous one has dropped, and correctly gets a fresh budget.
316#[must_use = "the budget only applies while the guard is alive"]
317pub struct PublisherRetryScope {
318    _label: RetryScope,
319    prev_anchor: Option<std::time::Instant>,
320}
321
322impl PublisherRetryScope {
323    /// Enter the retry scope for a publisher invocation labelled `name`,
324    /// anchoring its wall-clock budget at now (or inheriting the enclosing
325    /// one, if this call is already inside a publisher invocation).
326    pub fn enter(name: impl Into<String>) -> Self {
327        let label = RetryScope::enter(name);
328        let prev_anchor = CURRENT_BUDGET_ANCHOR.with(|cell| {
329            let prev = cell.get();
330            if prev.is_none() {
331                cell.set(Some(std::time::Instant::now()));
332            }
333            prev
334        });
335        PublisherRetryScope {
336            _label: label,
337            prev_anchor,
338        }
339    }
340}
341
342impl Drop for PublisherRetryScope {
343    fn drop(&mut self) {
344        CURRENT_BUDGET_ANCHOR.with(|cell| cell.set(self.prev_anchor));
345    }
346}
347
348/// Record a backoff sleep of `d` against this run's total and the active scope.
349/// Callers that sleep for retry should prefer [`sleep_backoff_blocking`] /
350/// [`sleep_backoff_async`] (which record and sleep together); use this directly
351/// only when the sleep is performed elsewhere (e.g. an injected `sleep`
352/// callback in stage-sign).
353pub fn record_retry_backoff(d: Duration) {
354    let ms = u64::try_from(d.as_millis()).unwrap_or(u64::MAX);
355    RETRY_BACKOFF_MILLIS.fetch_add(ms, std::sync::atomic::Ordering::Relaxed);
356
357    let key = CURRENT_SCOPE
358        .lock()
359        .unwrap_or_else(|e| e.into_inner())
360        .clone()
361        .unwrap_or_else(|| UNATTRIBUTED_SCOPE.to_string());
362    let mut map = PER_SCOPE_RETRY.lock().unwrap_or_else(|e| e.into_inner());
363    let entry = map.entry(key).or_default();
364    entry.retries = entry.retries.saturating_add(1);
365    entry.backoff_ms = entry.backoff_ms.saturating_add(ms);
366}
367
368/// Sleep `d` (blocking) and record it as retry backoff.
369///
370/// A zero `d` is a no-op: it neither sleeps nor records. A caller that owns its
371/// own wait (a rate-limit reset probe that already blocked until quota returned)
372/// passes `Duration::ZERO` to re-attempt immediately, and such a re-attempt must
373/// not inflate the per-scope "backoff sleeps" counter with a sleep that never
374/// happened.
375pub fn sleep_backoff_blocking(d: Duration) {
376    if d.is_zero() {
377        return;
378    }
379    record_retry_backoff(d);
380    std::thread::sleep(d);
381}
382
383/// Sleep `d` (async) and record it as retry backoff. A zero `d` is a no-op —
384/// see [`sleep_backoff_blocking`].
385pub async fn sleep_backoff_async(d: Duration) {
386    if d.is_zero() {
387        return;
388    }
389    record_retry_backoff(d);
390    tokio::time::sleep(d).await;
391}
392
393/// Total wall-clock time slept in retry backoff so far this run.
394pub fn total_retry_backoff() -> Duration {
395    Duration::from_millis(RETRY_BACKOFF_MILLIS.load(std::sync::atomic::Ordering::Relaxed))
396}
397
398/// Per-scope retry breakdown so far this run: `(scope, retries, backoff)` per
399/// publisher/stage that backed off, sorted by backoff descending (biggest
400/// offender first). Their backoff sums to [`total_retry_backoff`].
401pub fn retry_scope_breakdown() -> Vec<(String, u32, Duration)> {
402    let map = PER_SCOPE_RETRY.lock().unwrap_or_else(|e| e.into_inner());
403    let mut rows: Vec<(String, u32, Duration)> = map
404        .iter()
405        .map(|(k, v)| (k.clone(), v.retries, Duration::from_millis(v.backoff_ms)))
406        .collect();
407    // Descending by backoff, then name for a stable tie-break.
408    rows.sort_by(|a, b| b.2.cmp(&a.2).then_with(|| a.0.cmp(&b.0)));
409    rows
410}