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}