Skip to main content

anodizer_core/
retry.rs

1//! Uniform retry-with-exponential-backoff primitives.
2//!
3//! Replaces six open-coded retry loops in `stage-docker` (3×) and
4//! `stage-release` (3×) that had diverged on backoff formulas —
5//! `2^(n-2)`, `2^(n-1)`, and `500 << (attempt-1)` all coexisted.
6//!
7//! The canonical policy is exponential backoff with multiplier 2 starting at
8//! `base_delay` and capped at `max_delay`:
9//!
10//! ```text
11//! attempt 1:  f() executes immediately
12//! attempt 2:  sleep base_delay
13//! attempt 3:  sleep base_delay * 2
14//! attempt N:  sleep min(base_delay * 2^(N-2), max_delay)
15//! ```
16//!
17//! `ControlFlow<Break, Continue>` lets the operation decide retry policy per
18//! failure (e.g. 4xx → Break, 5xx → Continue) without the helper encoding
19//! protocol-specific predicates.
20//!
21//! Both a sync (`retry_sync`) and async (`retry_async`) variant are provided so
22//! that sites can adopt without crossing a sync/async boundary.
23
24use std::error::Error as StdError;
25use std::fmt;
26use std::io;
27use std::ops::ControlFlow;
28use std::time::Duration;
29
30/// Retry policy used by `retry_sync` / `retry_async`.
31#[derive(Debug, Clone, Copy)]
32pub struct RetryPolicy {
33    /// Total attempts, including the first.
34    ///
35    /// Invariant: must be `>= 1`. The clamp is enforced at two layers so
36    /// every construction path is safe:
37    ///
38    /// 1. [`crate::config::RetryConfig::to_policy`] clamps user YAML
39    ///    (`attempts: 0` -> `1`) at the config-surface boundary.
40    /// 2. [`retry_sync`] / [`retry_async`] clamp again at the loop boundary
41    ///    to protect direct `RetryPolicy { max_attempts: 0, .. }`
42    ///    constructions (e.g. test fixtures).
43    ///
44    /// Callers therefore do NOT need to clamp `max_attempts` again at the
45    /// call site.
46    pub max_attempts: u32,
47    /// Delay before the second attempt (no wait before the first).
48    pub base_delay: Duration,
49    /// Upper bound on any individual sleep between attempts.
50    pub max_delay: Duration,
51}
52
53impl RetryPolicy {
54    /// Canonical upload policy: 10 attempts, 50ms
55    /// base, 30s cap.
56    pub const UPLOAD: RetryPolicy = RetryPolicy {
57        max_attempts: 10,
58        base_delay: Duration::from_millis(50),
59        max_delay: Duration::from_secs(30),
60    };
61
62    /// Shallow policy for best-effort pre-publish probes: 3 attempts, 200ms
63    /// base, 1s cap.
64    ///
65    /// Pre-publish probes (token `whoami`, registry index GET, GitHub repo
66    /// scope, npm duplicate-version) are an advisory warning gate, not a
67    /// write that must land. They run sequentially across every configured
68    /// publisher, so the production write-ladder (10 attempts / 10s base /
69    /// 5m cap) would let a single wedged endpoint stall the gate for tens of
70    /// minutes. A shallow bound keeps the probe responsive while still
71    /// absorbing a transient blip; the per-request HTTP timeout still bounds
72    /// each individual attempt.
73    pub const PREFLIGHT: RetryPolicy = RetryPolicy {
74        max_attempts: 3,
75        base_delay: Duration::from_millis(200),
76        max_delay: Duration::from_secs(1),
77    };
78
79    pub fn delay_for(&self, next_attempt: u32) -> Duration {
80        // `next_attempt` is the attempt we're about to run (≥2). The wait
81        // before attempt 2 uses base_delay; before attempt 3 uses base_delay*2;
82        // i.e. multiplier = 2^(next_attempt - 2).
83        let exp = next_attempt.saturating_sub(2);
84        let mult = 1u64.checked_shl(exp).unwrap_or(u64::MAX);
85        let ms = (self.base_delay.as_millis() as u64).saturating_mul(mult);
86        std::cmp::min(Duration::from_millis(ms), self.max_delay)
87    }
88
89    /// Raise this policy's `max_attempts` to at least [`IDEMPOTENT_PUT_ATTEMPTS`]
90    /// without disturbing its backoff shape, returning the adjusted policy.
91    ///
92    /// An idempotent PUT/POST to a fixed target (an Artifactory/generic upload,
93    /// a GemFury push, a Snap Store upload, a bucket blob PUT, a GitHub asset
94    /// upload) lands the same bytes at the same path on every re-issue, so a
95    /// transient 5xx/429 or dropped connection must retry a bounded number of
96    /// times even when a stateful mode (`--publish-only`) resolves the
97    /// configured policy down to `attempts: 1`. The floor is a `max()` — it
98    /// only widens the bound for the retriable classes and never lowers an
99    /// operator-set higher value. 4xx responses still fast-fail inside the
100    /// per-attempt classifier regardless of this floor.
101    pub fn with_idempotent_floor(self) -> RetryPolicy {
102        self.with_floor(IDEMPOTENT_PUT_ATTEMPTS)
103    }
104
105    /// Raise this policy's `max_attempts` to at least `min`, leaving the backoff
106    /// shape untouched. A `max()` floor, never a clamp that lowers a higher
107    /// operator-set value.
108    pub fn with_floor(self, min: u32) -> RetryPolicy {
109        RetryPolicy {
110            max_attempts: self.max_attempts.max(min),
111            ..self
112        }
113    }
114}
115
116/// Total attempt floor for an idempotent PUT/POST, single-sourcing the
117/// "3 total attempts" guarantee shared by every idempotent-upload publisher
118/// (HTTP upload, GemFury, Snapcraft, GitHub asset, blob). Applied via
119/// [`RetryPolicy::with_idempotent_floor`] as a `max()` so a stateful mode
120/// (`--publish-only`) that resolves `attempts: 1` still keeps a bounded
121/// transient retry, while an operator-set higher cap is preserved.
122pub const IDEMPOTENT_PUT_ATTEMPTS: u32 = 3;
123
124/// Retry a synchronous operation according to `policy`.
125///
126/// `op` returns:
127/// - `Ok(T)` on success (no retry).
128/// - `Err(ControlFlow::Continue(e))` to retry if attempts remain.
129/// - `Err(ControlFlow::Break(e))` to stop immediately (4xx-style fast-fail).
130///
131/// Returns the last error if all attempts are exhausted.
132pub fn retry_sync<T, E, F>(policy: &RetryPolicy, op: F) -> Result<T, E>
133where
134    F: FnMut(u32) -> Result<T, ControlFlow<E, E>>,
135{
136    retry_sync_deadline(policy, None, op)
137}
138
139/// Like [`retry_sync`], but stops retrying once the next backoff sleep would
140/// push total wall-time past `deadline`. On budget exhaustion it returns the
141/// last error observed before the budget was hit, so a caller whose write is
142/// idempotent recovers on re-run instead of being killed mid-attempt by an
143/// outer timeout. `deadline: None` is byte-for-byte the old attempt-count-only
144/// behavior.
145pub fn retry_sync_deadline<T, E, F>(
146    policy: &RetryPolicy,
147    deadline: Option<std::time::Instant>,
148    mut op: F,
149) -> Result<T, E>
150where
151    F: FnMut(u32) -> Result<T, ControlFlow<E, E>>,
152{
153    let max = policy.max_attempts.max(1);
154    let mut attempt: u32 = 1;
155    loop {
156        if attempt > 1 {
157            std::thread::sleep(policy.delay_for(attempt));
158        }
159        match op(attempt) {
160            Ok(v) => return Ok(v),
161            Err(ControlFlow::Break(e)) => return Err(e),
162            Err(ControlFlow::Continue(e)) => {
163                if attempt >= max {
164                    return Err(e);
165                }
166                if let Some(deadline) = deadline {
167                    // Give up before the NEXT backoff would blow the budget, so
168                    // a long registry storm exits cleanly (resumable) rather than
169                    // being SIGKILLed mid-publish by the job timeout.
170                    if std::time::Instant::now() + policy.delay_for(attempt + 1) > deadline {
171                        return Err(e);
172                    }
173                }
174            }
175        }
176        attempt += 1;
177    }
178}
179
180/// Retry an asynchronous operation according to `policy`.
181///
182/// Same semantics as `retry_sync` but awaits `op` and uses `tokio::time::sleep`.
183pub async fn retry_async<T, E, F, Fut>(policy: &RetryPolicy, mut op: F) -> Result<T, E>
184where
185    F: FnMut(u32) -> Fut,
186    Fut: std::future::Future<Output = Result<T, ControlFlow<E, E>>>,
187{
188    let max = policy.max_attempts.max(1);
189    let mut attempt: u32 = 1;
190    loop {
191        if attempt > 1 {
192            tokio::time::sleep(policy.delay_for(attempt)).await;
193        }
194        match op(attempt).await {
195            Ok(v) => return Ok(v),
196            Err(ControlFlow::Break(e)) => return Err(e),
197            Err(ControlFlow::Continue(e)) => {
198                if attempt >= max {
199                    return Err(e);
200                }
201            }
202        }
203        attempt += 1;
204    }
205}
206
207/// Whether to consider 3xx redirects a success outcome (most upload-style
208/// publishers do, since the underlying client follows redirects under the
209/// hood; some callers explicitly want only 2xx).
210#[derive(Debug, Clone, Copy, PartialEq, Eq)]
211pub enum SuccessClass {
212    /// 2xx only. Any 3xx is treated as a non-success status (eligible for
213    /// retry / fast-fail per `is_retriable`).
214    Strict,
215    /// 2xx OR 3xx. Used by upload publishers whose servers may emit a
216    /// 301/302/307 in the success path (artifactory does this for some
217    /// virtual repo configurations).
218    AllowRedirects,
219}
220
221/// Drive a single HTTP call to completion, retrying transient failures via
222/// the shared [`retry_sync`] machinery.
223///
224/// On every attempt, `send` is invoked to construct + dispatch a fresh
225/// request. The closure must rebuild the request from scratch (multipart
226/// `Form`, streamed body, etc. are move-only). The helper:
227///
228/// 1. On `Err` (transport-level): wrap in [`HttpError::from_response`] +
229///    a `<label>: <stage> transport error` context, classify with
230///    [`is_retriable`] (so EOF / connection-reset retry, plain "dial
231///    failed" fast-fails), and dispatch `Continue`/`Break`.
232/// 2. On non-success status: drain the body, format the outer message via
233///    `error_msg`, wrap in [`HttpError::new`] with the upstream status, and
234///    classify (5xx/429 → `Continue`, 4xx → `Break`).
235/// 3. On success status: return `(status, body)`.
236///
237/// The `error_msg` closure receives the response status and body so callers
238/// can format publisher-specific envelopes (e.g. artifactory's
239/// `{"errors":[...]}` JSON).
240///
241/// Replaces three nearly-identical retry loops:
242/// - `stage-publish/cloudsmith.rs::retry_request`
243/// - `stage-publish/artifactory.rs::upload_single_artifact` (inline)
244/// - `stage-announce/helpers.rs::retry_http` (now wraps this helper; see
245///   announce/helpers.rs for the thin adapter that returns the body string
246///   instead of `(StatusCode, String)`).
247pub fn retry_http_blocking<F, M>(
248    label: &str,
249    policy: &RetryPolicy,
250    success_class: SuccessClass,
251    mut send: F,
252    error_msg: M,
253) -> anyhow::Result<(reqwest::StatusCode, String)>
254where
255    F: FnMut(u32) -> Result<reqwest::blocking::Response, reqwest::Error>,
256    M: Fn(reqwest::StatusCode, &str) -> String,
257{
258    use anyhow::Context as _;
259    retry_sync(policy, |attempt| {
260        match send(attempt) {
261            Ok(resp) => {
262                let status = resp.status();
263                let succeeded = match success_class {
264                    SuccessClass::Strict => status.is_success(),
265                    SuccessClass::AllowRedirects => status.is_success() || status.is_redirection(),
266                };
267                let body = resp
268                    .text()
269                    .unwrap_or_else(|e| format!("<failed to read body: {e}>"));
270                if succeeded {
271                    Ok((status, body))
272                } else {
273                    let msg = error_msg(status, &body);
274                    let inner = anyhow::anyhow!("{msg}");
275                    let wrapped = anyhow::Error::new(HttpError::new(
276                        std::io::Error::other(inner.to_string()),
277                        status.as_u16(),
278                    ))
279                    .context(inner);
280                    // `as_ref()` is the head of the chain; `is_retriable` walks
281                    // `.source()` to reach `HttpError`. `root_cause()` would
282                    // unwrap past `HttpError` to the io::Error leaf and miss
283                    // the status. Pinned by
284                    // `classifier_5xx_via_anyhow_chain_uses_as_ref`.
285                    if is_retriable(wrapped.as_ref()) {
286                        Err(ControlFlow::Continue(wrapped))
287                    } else {
288                        Err(ControlFlow::Break(wrapped))
289                    }
290                }
291            }
292            Err(e) => {
293                // Transport-layer failure: always wrap in HttpError(status=0)
294                // so the chain-walking classifier can see network-error
295                // substrings via the inner io::Error message.
296                let err = anyhow::Error::new(HttpError::from_response(e, None))
297                    .context(format!("{label}: HTTP transport error"));
298                if is_retriable(err.as_ref()) {
299                    Err(ControlFlow::Continue(err))
300                } else {
301                    Err(ControlFlow::Break(err))
302                }
303            }
304        }
305    })
306    .with_context(|| format!("{label}: exhausted retry attempts"))
307}
308
309/// Binary-body sibling of [`retry_http_blocking`] for endpoints whose success
310/// payload is not valid UTF-8 (e.g. a gzip-compressed `.crate` tarball).
311///
312/// `resp.text()` runs a lossy UTF-8 conversion that silently rewrites
313/// non-UTF-8 byte sequences to U+FFFD, corrupting a binary payload with no
314/// error raised — a caller hashing the "recovered" bytes would never match
315/// the original digest. This variant reads the success body via
316/// `resp.bytes()` instead, keeping every other behavior (retry classification,
317/// `HttpError` wrapping, `success_class`) identical to the text variant. The
318/// error-path body is still decoded lossily into text purely for the
319/// `error_msg` formatter — error responses are conventionally textual/JSON,
320/// and a few replacement characters in an already-failing message are
321/// harmless.
322pub fn retry_http_blocking_bytes<F, M>(
323    label: &str,
324    policy: &RetryPolicy,
325    success_class: SuccessClass,
326    mut send: F,
327    error_msg: M,
328) -> anyhow::Result<(reqwest::StatusCode, Vec<u8>)>
329where
330    F: FnMut(u32) -> Result<reqwest::blocking::Response, reqwest::Error>,
331    M: Fn(reqwest::StatusCode, &str) -> String,
332{
333    use anyhow::Context as _;
334    retry_sync(policy, |attempt| match send(attempt) {
335        Ok(resp) => {
336            let status = resp.status();
337            let succeeded = match success_class {
338                SuccessClass::Strict => status.is_success(),
339                SuccessClass::AllowRedirects => status.is_success() || status.is_redirection(),
340            };
341            let bytes = resp
342                .bytes()
343                .map(|b| b.to_vec())
344                .unwrap_or_else(|e| format!("<failed to read body: {e}>").into_bytes());
345            if succeeded {
346                Ok((status, bytes))
347            } else {
348                let body_text = String::from_utf8_lossy(&bytes).into_owned();
349                let msg = error_msg(status, &body_text);
350                let inner = anyhow::anyhow!("{msg}");
351                let wrapped = anyhow::Error::new(HttpError::new(
352                    std::io::Error::other(inner.to_string()),
353                    status.as_u16(),
354                ))
355                .context(inner);
356                if is_retriable(wrapped.as_ref()) {
357                    Err(ControlFlow::Continue(wrapped))
358                } else {
359                    Err(ControlFlow::Break(wrapped))
360                }
361            }
362        }
363        Err(e) => {
364            let err = anyhow::Error::new(HttpError::from_response(e, None))
365                .context(format!("{label}: HTTP transport error"));
366            if is_retriable(err.as_ref()) {
367                Err(ControlFlow::Continue(err))
368            } else {
369                Err(ControlFlow::Break(err))
370            }
371        }
372    })
373    .with_context(|| format!("{label}: exhausted retry attempts"))
374}
375
376/// Async sibling of [`retry_http_blocking`] for `reqwest::Client` (non-blocking)
377/// call sites such as the GitLab and Gitea release publishers.
378///
379/// Each attempt invokes `send` (a fresh future) and:
380///
381/// 1. On `Err` (transport-level): wraps in [`HttpError::from_response`] +
382///    a `<label>: HTTP transport error` context, classifies via
383///    [`is_retriable`] (network-substring + EOF chain match), and dispatches
384///    `Continue`/`Break`.
385/// 2. On non-success status: drains the body via `Response::text().await`,
386///    formats the outer message via `error_msg`, wraps in [`HttpError::new`]
387///    with the upstream status, and classifies (5xx/429 → `Continue`, 4xx →
388///    `Break`).
389/// 3. On success status: returns the raw [`reqwest::Response`] for the
390///    caller to consume (e.g. `.json()`, `.text()`, header inspection).
391///
392/// `success_class` mirrors the blocking variant: `Strict` rejects 3xx,
393/// `AllowRedirects` accepts them. Most async API clients want `Strict`
394/// (their reqwest::Client follows redirects by default, so a surfaced 3xx
395/// is itself an error).
396pub async fn retry_http_async<F, Fut, M>(
397    label: &str,
398    policy: &RetryPolicy,
399    success_class: SuccessClass,
400    mut send: F,
401    error_msg: M,
402) -> anyhow::Result<reqwest::Response>
403where
404    F: FnMut(u32) -> Fut,
405    Fut: std::future::Future<Output = Result<reqwest::Response, reqwest::Error>>,
406    M: Fn(reqwest::StatusCode, &str) -> String,
407{
408    use anyhow::Context as _;
409    retry_async(policy, |attempt| {
410        let fut = send(attempt);
411        let error_msg = &error_msg;
412        async move {
413            match fut.await {
414                Ok(resp) => {
415                    let status = resp.status();
416                    let succeeded = match success_class {
417                        SuccessClass::Strict => status.is_success(),
418                        SuccessClass::AllowRedirects => {
419                            status.is_success() || status.is_redirection()
420                        }
421                    };
422                    if succeeded {
423                        Ok(resp)
424                    } else {
425                        let body = resp
426                            .text()
427                            .await
428                            .unwrap_or_else(|e| format!("<failed to read body: {e}>"));
429                        let msg = error_msg(status, &body);
430                        let inner = anyhow::anyhow!("{msg}");
431                        let wrapped = anyhow::Error::new(HttpError::new(
432                            std::io::Error::other(inner.to_string()),
433                            status.as_u16(),
434                        ))
435                        .context(inner);
436                        // `as_ref()` is the head of the chain; `is_retriable`
437                        // walks `.source()` to reach `HttpError`. `root_cause()`
438                        // would unwrap past `HttpError` to the io::Error leaf
439                        // and miss the status. Pinned by
440                        // `classifier_5xx_via_anyhow_chain_uses_as_ref`.
441                        if is_retriable(wrapped.as_ref()) {
442                            Err(ControlFlow::Continue(wrapped))
443                        } else {
444                            Err(ControlFlow::Break(wrapped))
445                        }
446                    }
447                }
448                Err(e) => {
449                    // Transport-layer failure: wrap in HttpError(status=0) so
450                    // the chain-walking classifier can see network-error
451                    // substrings via the inner io::Error message.
452                    let err = anyhow::Error::new(HttpError::from_response(e, None))
453                        .context(format!("{label}: HTTP transport error"));
454                    if is_retriable(err.as_ref()) {
455                        Err(ControlFlow::Continue(err))
456                    } else {
457                        Err(ControlFlow::Break(err))
458                    }
459                }
460            }
461        }
462    })
463    .await
464    .with_context(|| format!("{label}: exhausted retry attempts"))
465}
466
467/// Classify a `reqwest::Result<reqwest::blocking::Response>` into the
468/// `ControlFlow` shape expected by `retry_sync` for a typical HTTP call:
469/// 5xx + transport errors retry, 4xx fast-fails, 2xx/3xx returns Ok. The
470/// returned response (Ok branch) is the caller's to consume.
471///
472/// This is the convention shared by every HTTP-uploading publisher; see audit
473/// A7 dedup S5.
474pub fn classify_http_sync(
475    result: reqwest::Result<reqwest::blocking::Response>,
476) -> Result<reqwest::blocking::Response, ControlFlow<anyhow::Error, anyhow::Error>> {
477    use anyhow::anyhow;
478    match result {
479        Ok(resp) => {
480            let status = resp.status();
481            if status.is_success() || status.is_redirection() {
482                Ok(resp)
483            } else if status.is_server_error() {
484                Err(ControlFlow::Continue(anyhow!(
485                    "HTTP {} {}",
486                    status.as_u16(),
487                    status.canonical_reason().unwrap_or("server error")
488                )))
489            } else {
490                // 4xx (and any other non-success/redirect/5xx): fast-fail
491                Err(ControlFlow::Break(anyhow!(
492                    "HTTP {} {}",
493                    status.as_u16(),
494                    status.canonical_reason().unwrap_or("client error")
495                )))
496            }
497        }
498        // Transport-layer failure (DNS, connect, TLS, timeout): retry.
499        Err(e) => Err(ControlFlow::Continue(anyhow!(e))),
500    }
501}
502
503// ---------------------------------------------------------------------------
504// Retriable-error classification
505// ---------------------------------------------------------------------------
506
507/// Carries an HTTP status code alongside the original error so
508/// [`is_retriable`] can route 5xx / 429 to retry and 4xx to fast-fail.
509///
510/// HTTP error carrying status + message. Construct via [`HttpError::new`]
511/// (status-only) or wrap an existing `reqwest::Response` via
512/// [`HttpError::from_response`].
513///
514/// A `status` of `0` denotes a network-level failure where no response was
515/// ever received (the no-response branch). Network-level failures
516/// are still classified via the inner error's message, so wrapping them in
517/// `HttpError { status: 0, .. }` does not lose retriability information.
518#[derive(Debug)]
519pub struct HttpError {
520    /// The wrapped error (transport, decode, or status-derived message).
521    /// Reachable via the [`StdError::source`] trait method (not directly).
522    source: Box<dyn StdError + Send + Sync + 'static>,
523    /// HTTP status code; `0` for transport-level failures.
524    pub status: u16,
525}
526
527impl HttpError {
528    /// Wrap an error with a status code. `0` denotes a network-level failure
529    /// (no response received).
530    pub fn new<E>(source: E, status: u16) -> Self
531    where
532        E: StdError + Send + Sync + 'static,
533    {
534        Self {
535            source: Box::new(source),
536            status,
537        }
538    }
539
540    /// Wrap a transport-layer error with the status code from the (possibly
541    /// missing) response.
542    /// `None` resp yields status `0` (network-level failure).
543    pub fn from_response<E>(err: E, resp: Option<&reqwest::Response>) -> Self
544    where
545        E: StdError + Send + Sync + 'static,
546    {
547        Self::new(err, resp.map(|r| r.status().as_u16()).unwrap_or(0))
548    }
549}
550
551/// Extract the upstream HTTP status from an [`anyhow::Error`] chain produced by
552/// [`retry_http_blocking`] / [`retry_http_async`].
553///
554/// Returns `0` when no [`HttpError`] is present in the chain — a transport-level
555/// failure that never received a response, or a non-HTTP error.
556pub fn http_status(err: &anyhow::Error) -> u16 {
557    err.chain()
558        .find_map(|e| e.downcast_ref::<HttpError>().map(|h| h.status))
559        .unwrap_or(0)
560}
561
562impl fmt::Display for HttpError {
563    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
564        // Defer to the inner error so messages stay focused on the cause.
565        // Delegate to the inner error message.
566        fmt::Display::fmt(&self.source, f)
567    }
568}
569
570impl StdError for HttpError {
571    fn source(&self) -> Option<&(dyn StdError + 'static)> {
572        Some(&*self.source)
573    }
574}
575
576/// Marker error wrapping any inner error so [`is_retriable`] returns `true`
577/// regardless of class — useful when a
578/// caller knows the failure is transient (e.g. an idempotent registry write
579/// returning 422 because of a transient race condition) and wants the retry
580/// loop to ignore the usual 4xx fast-fail.
581#[derive(Debug)]
582pub struct Retriable(Box<dyn StdError + Send + Sync + 'static>);
583
584impl Retriable {
585    /// Wrap any error so [`is_retriable`] returns `true` regardless of class.
586    /// Use this when a caller knows a 4xx is transient (e.g. a 422 from an
587    /// idempotent registry write losing a race) and wants to override the
588    /// usual fast-fail. For `Option<E>` inputs, see [`is_retriable_opt`] —
589    /// this constructor itself is non-nullable.
590    pub fn new<E>(source: E) -> Self
591    where
592        E: StdError + Send + Sync + 'static,
593    {
594        Self(Box::new(source))
595    }
596}
597
598impl fmt::Display for Retriable {
599    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
600        fmt::Display::fmt(&self.0, f)
601    }
602}
603
604impl StdError for Retriable {
605    fn source(&self) -> Option<&(dyn StdError + 'static)> {
606        Some(&*self.0)
607    }
608}
609
610/// Returns `true` if the message looks like a transient network-layer failure.
611///
612/// Network-error classification, extended for Rust /
613/// Windows. Each link in the error chain is checked two ways:
614///
615/// 1a. **Structural [`io::ErrorKind`] check** via `downcast_ref::<io::Error>()`.
616///     Treats `UnexpectedEof`, `TimedOut`, `ConnectionRefused`,
617///     `ConnectionReset`, `ConnectionAborted`, and `BrokenPipe` as transient.
618///     The OS-classified `ErrorKind` is robust where Display text is not:
619///     Linux's connect-refused says `"Connection refused"` but Windows
620///     surfaces a transient connect failure as
621///     `io::Error { kind: TimedOut, message: "operation timed out" }`, and
622///     a Windows-reset reads `"An existing connection was forcibly closed"`.
623///     Matching `kind()` catches all of them regardless of phrasing. Also
624///     recognises any `io::Error` whose Display form is `"EOF"` /
625///     `"unexpected eof"` (rustls / hyper convention; Rust has no
626///     equivalent of Go's `io.EOF` sentinel).
627///
628/// 1b. **Substring match on the lowercased Display form** against
629///     [`NETWORK_ERROR_NEEDLES`]. Covers the canonical surface plus the
630///     Windows / Rust-stdlib phrasings that bypass the kind check when an
631///     error has been wrapped (e.g. reqwest coercing the inner kind to
632///     `Other` while preserving the OS message text).
633///
634/// Walks `.source()` for both branches — Rust's `Display` impls do NOT
635/// inherit the wrapped error's text the way Go's `err.Error()` does, so a
636/// reqwest "Connection refused" message buried under an anyhow context would
637/// otherwise be invisible to the head-only string.
638pub fn is_network_error(err: &(dyn StdError + 'static)) -> bool {
639    let mut cur: Option<&(dyn StdError + 'static)> = Some(err);
640    while let Some(e) = cur {
641        // 1a. Structural ErrorKind check — robust to platform Display drift
642        //     (Windows's "operation timed out" vs Linux's "Connection refused").
643        if let Some(io_err) = e.downcast_ref::<io::Error>() {
644            match io_err.kind() {
645                io::ErrorKind::UnexpectedEof
646                | io::ErrorKind::TimedOut
647                | io::ErrorKind::ConnectionRefused
648                | io::ErrorKind::ConnectionReset
649                | io::ErrorKind::ConnectionAborted
650                | io::ErrorKind::BrokenPipe => return true,
651                _ => {}
652            }
653            let m = io_err.to_string().to_lowercase();
654            if m == "eof" || m == "unexpected eof" {
655                return true;
656            }
657        }
658
659        // 1b. Substring match on each link's own Display (NOT the full
660        //     chain "{e:#}" form, which would double-count the same text on
661        //     deeper links). Lowercased once per link.
662        let s = e.to_string().to_lowercase();
663        if NETWORK_ERROR_NEEDLES.iter().any(|n| s.contains(n)) {
664            return true;
665        }
666
667        cur = e.source();
668    }
669    false
670}
671
672/// The set of substrings classified as transient.
673///
674/// The first nine entries are the canonical network-error needles
675/// (matching is case-insensitive). The remaining entries cover Windows and
676/// Rust-stdlib phrasings of transient transport failures that surface when
677/// an `io::Error` has been wrapped by a higher layer (reqwest, hyper,
678/// anyhow), losing the original `ErrorKind` classification but preserving
679/// the OS message text. Without these, every publisher running on Windows
680/// fast-failed on the first transient connect blip instead of retrying.
681const NETWORK_ERROR_NEEDLES: &[&str] = &[
682    "connection reset",
683    "network is unreachable",
684    "connection closed",
685    "connection refused",
686    "tls handshake timeout",
687    "i/o timeout",
688    "broken pipe",
689    "timeout awaiting response headers",
690    "context deadline exceeded",
691    // Windows + macOS phrasing of ErrorKind::TimedOut after wrapping.
692    "operation timed out",
693    // Windows ErrorKind::ConnectionAborted phrasing.
694    "the network connection was aborted",
695    // Windows ErrorKind::ConnectionReset phrasing.
696    "an existing connection was forcibly closed",
697    // hyper-util / reqwest DNS-resolution failures wrapped through the
698    // connector. Surfaces as `client error (Connect): dns error: ...` with
699    // a platform-specific resolver tail ("Name or service not known" on
700    // Linux/glibc, "nodename nor servname provided, or not known" on macOS,
701    // "No such host is known" on Windows). The leading "dns error" prefix
702    // is the cross-platform constant.
703    "dns error",
704    // GAI (getaddrinfo) wording across resolvers; covers the Linux
705    // resolver tail above and BSD/macOS phrasing.
706    "failed to lookup address",
707    // Windows resolver tail when DNS-resolution fails.
708    "no such host is known",
709];
710
711/// Classify an error as retriable.
712///
713/// Returns `true` for:
714/// - any [`is_network_error`] match (substring + EOF / UnexpectedEof in the
715///   `source()` chain)
716/// - any error whose chain contains a [`Retriable`] wrapper
717/// - any error whose chain contains an [`HttpError`] with status `>= 500`
718///   or status `429` (Too Many Requests)
719///
720/// Returns `false` for plain errors and 4xx HTTP errors (other than 429) —
721/// those are fast-failed by the retry loop.
722pub fn is_retriable(err: &(dyn StdError + 'static)) -> bool {
723    // 1. Any link in the chain is an explicit Retriable marker.
724    let mut cur: Option<&(dyn StdError + 'static)> = Some(err);
725    while let Some(e) = cur {
726        if e.is::<Retriable>() {
727            return true;
728        }
729        if let Some(http) = e.downcast_ref::<HttpError>()
730            && status_is_retriable(http.status)
731        {
732            return true;
733        }
734        cur = e.source();
735    }
736
737    // 2. Network-error substring / EOF chain match.
738    is_network_error(err)
739}
740
741/// The canonical retriable-HTTP-status rule: server errors (`>= 500`) and
742/// `429 Too Many Requests`. Everything else — notably the remaining 4xx
743/// range — is fast-failed.
744///
745/// [`is_retriable`]'s [`HttpError`] arm delegates here, and raw-status
746/// classifiers that cannot route through [`HttpError`] (the gemfury and
747/// chocolatey multipart push loops, whose conflict-as-success / hard-fail
748/// cases need bespoke `ControlFlow` handling) call it directly, so the
749/// fast-fail/retry split for a bare status code has exactly one
750/// definition. Extending the rule (408/425, `Retry-After` awareness)
751/// updates every consumer at once — including the one-way-door publishers
752/// where a mis-fast-failed transient burns an unrecoverable publish
753/// attempt.
754pub fn status_is_retriable(status: u16) -> bool {
755    status >= 500 || status == 429
756}
757
758/// Convenience: `None` passes through as `false`. The
759/// `IsRetriable(nil) -> false` semantics.
760pub fn is_retriable_opt(err: Option<&(dyn StdError + 'static)>) -> bool {
761    err.is_some_and(is_retriable)
762}
763
764/// Apply ±20 % pseudo-jitter to `base` using a cheap subsecond-nanos modulo.
765///
766/// Returns a value in `[base * 0.8, base * 1.2)`. No `rand` crate dependency:
767/// `SystemTime::now().subsec_nanos()` provides ~nanosecond entropy that is
768/// sufficient for retry jitter (the goal is spreading out concurrent retriers,
769/// not cryptographic unpredictability).
770///
771/// The ±20 % window is a widely-adopted convention (AWS SDK, GCP client libs).
772/// Jitter only ever widens the sleep by up to 20 %; it never shortens it below
773/// 80 % of the nominal delay, so `Retry-After` honoring is conservative.
774pub fn jitter_duration(base: Duration) -> Duration {
775    let nanos = base.as_nanos() as u64;
776    // 20 % of the nominal duration.
777    let window = nanos / 5;
778    if window == 0 {
779        return base;
780    }
781    // Cheap pseudo-random offset in [0, window * 2) centred on window,
782    // giving a net range of [base - window, base + window). Routed
783    // through `sde::resolve_now()` so jitter collapses to a constant
784    // under `SOURCE_DATE_EPOCH` (no subsec precision) — required for
785    // determinism-harness byte-equality; real jitter is preserved in
786    // prod where the helper falls back to `Utc::now()`.
787    let seed = crate::sde::resolve_now().timestamp_subsec_nanos() as u64;
788    let offset = seed % (window * 2);
789    // Saturating arithmetic so we never panic on extreme values.
790    let jittered = nanos.saturating_sub(window).saturating_add(offset);
791    Duration::from_nanos(jittered)
792}
793
794#[cfg(test)]
795mod tests {
796    use super::*;
797    use std::sync::atomic::{AtomicU32, Ordering};
798
799    fn fast_policy() -> RetryPolicy {
800        RetryPolicy {
801            max_attempts: 4,
802            base_delay: Duration::from_millis(1),
803            max_delay: Duration::from_millis(5),
804        }
805    }
806
807    /// Locks the shallow shape of the best-effort pre-publish probe policy so a
808    /// future edit cannot silently re-point preflight probes at the production
809    /// write-ladder (10 attempts / 10s base / 5m cap), which would let one
810    /// wedged endpoint stall the gate for tens of minutes.
811    #[test]
812    fn preflight_policy_is_shallow() {
813        let p = RetryPolicy::PREFLIGHT;
814        assert_eq!(p.max_attempts, 3);
815        assert_eq!(p.base_delay, Duration::from_millis(200));
816        assert_eq!(p.max_delay, Duration::from_secs(1));
817        // Sub-second base + low cap: the whole probe ladder must stay well
818        // under a second of sleeps even when every attempt is exhausted.
819        let total_sleep: Duration = (2..=p.max_attempts).map(|n| p.delay_for(n)).sum();
820        assert!(
821            total_sleep < Duration::from_secs(1),
822            "preflight backoff sleeps must stay sub-second, got {total_sleep:?}"
823        );
824    }
825
826    #[test]
827    fn http_status_extracts_status_from_chain() {
828        let wrapped = anyhow::Error::new(HttpError::new(std::io::Error::other("boom"), 429))
829            .context("outer context");
830        assert_eq!(http_status(&wrapped), 429);
831    }
832
833    #[test]
834    fn http_status_is_zero_without_http_error() {
835        let plain = anyhow::anyhow!("not an http error");
836        assert_eq!(http_status(&plain), 0);
837    }
838
839    /// The idempotent floor raises a sub-floor cap to [`IDEMPOTENT_PUT_ATTEMPTS`]
840    /// but never lowers an operator-set higher cap. Fails if the floor constant
841    /// is reverted to 1 (or the `max()` semantics flip to a clamp).
842    #[test]
843    fn idempotent_floor_raises_low_cap_and_preserves_high_cap() {
844        let raised = RetryPolicy {
845            max_attempts: 1,
846            base_delay: Duration::from_millis(1),
847            max_delay: Duration::from_millis(5),
848        }
849        .with_idempotent_floor();
850        assert_eq!(
851            raised.max_attempts, IDEMPOTENT_PUT_ATTEMPTS,
852            "a single-attempt cap must be raised to the idempotent floor"
853        );
854
855        let preserved = RetryPolicy {
856            max_attempts: 7,
857            base_delay: Duration::from_millis(1),
858            max_delay: Duration::from_millis(5),
859        }
860        .with_idempotent_floor();
861        assert_eq!(
862            preserved.max_attempts, 7,
863            "an operator-set cap above the floor must be preserved, not lowered"
864        );
865    }
866
867    #[test]
868    fn jitter_returns_base_when_window_rounds_to_zero() {
869        // For any duration under 5ns the ±20 % window (`nanos / 5`) floors to
870        // 0, so jitter is a no-op and the base is returned unchanged — the
871        // early-return guard that avoids a `% 0` panic on tiny delays.
872        for n in 0..5u64 {
873            let base = Duration::from_nanos(n);
874            assert_eq!(
875                jitter_duration(base),
876                base,
877                "sub-5ns base {n} must pass through unjittered"
878            );
879        }
880    }
881
882    #[test]
883    fn jitter_stays_within_plus_minus_twenty_percent() {
884        // The jittered value never leaves [base*0.8, base*1.2) — the documented
885        // window. Uses a duration large enough that `nanos / 5 > 0`.
886        let base = Duration::from_millis(100);
887        let jittered = jitter_duration(base);
888        let lo = base.mul_f64(0.8);
889        let hi = base.mul_f64(1.2);
890        assert!(
891            jittered >= lo && jittered < hi,
892            "jittered {jittered:?} outside [{lo:?}, {hi:?})"
893        );
894    }
895
896    #[test]
897    fn delay_progression_caps_at_max() {
898        let p = RetryPolicy {
899            max_attempts: 10,
900            base_delay: Duration::from_millis(100),
901            max_delay: Duration::from_millis(500),
902        };
903        assert_eq!(p.delay_for(2), Duration::from_millis(100));
904        assert_eq!(p.delay_for(3), Duration::from_millis(200));
905        assert_eq!(p.delay_for(4), Duration::from_millis(400));
906        assert_eq!(p.delay_for(5), Duration::from_millis(500)); // capped
907        assert_eq!(p.delay_for(8), Duration::from_millis(500)); // capped
908    }
909
910    #[test]
911    fn sync_succeeds_on_first_attempt() {
912        let calls = AtomicU32::new(0);
913        let result: Result<&str, ()> = retry_sync(&fast_policy(), |_| {
914            calls.fetch_add(1, Ordering::SeqCst);
915            Ok("ok")
916        });
917        assert_eq!(result, Ok("ok"));
918        assert_eq!(calls.load(Ordering::SeqCst), 1);
919    }
920
921    #[test]
922    fn sync_retries_until_success() {
923        let calls = AtomicU32::new(0);
924        let result: Result<u32, &str> = retry_sync(&fast_policy(), |attempt| {
925            calls.fetch_add(1, Ordering::SeqCst);
926            if attempt < 3 {
927                Err(ControlFlow::Continue("transient"))
928            } else {
929                Ok(attempt)
930            }
931        });
932        assert_eq!(result, Ok(3));
933        assert_eq!(calls.load(Ordering::SeqCst), 3);
934    }
935
936    #[test]
937    fn sync_break_stops_immediately() {
938        let calls = AtomicU32::new(0);
939        let result: Result<(), &str> = retry_sync(&fast_policy(), |_| {
940            calls.fetch_add(1, Ordering::SeqCst);
941            Err(ControlFlow::Break("fatal"))
942        });
943        assert_eq!(result, Err("fatal"));
944        assert_eq!(calls.load(Ordering::SeqCst), 1);
945    }
946
947    #[test]
948    fn sync_returns_last_error_after_exhaustion() {
949        let calls = AtomicU32::new(0);
950        let result: Result<(), String> = retry_sync(&fast_policy(), |attempt| {
951            calls.fetch_add(1, Ordering::SeqCst);
952            Err(ControlFlow::Continue(format!("fail {attempt}")))
953        });
954        assert_eq!(result, Err("fail 4".to_string()));
955        assert_eq!(calls.load(Ordering::SeqCst), 4);
956    }
957
958    #[test]
959    fn deadline_already_elapsed_stops_after_one_attempt_without_sleeping() {
960        // A large base_delay proves the pre-attempt sleep is SKIPPED: with a
961        // deadline already in the past, the budget check must fire after the
962        // first Continue and return before any 10s sleep runs.
963        let policy = RetryPolicy {
964            max_attempts: 10,
965            base_delay: Duration::from_secs(10),
966            max_delay: Duration::from_secs(300),
967        };
968        let deadline = std::time::Instant::now();
969        let calls = AtomicU32::new(0);
970        let start = std::time::Instant::now();
971        let result: Result<(), &str> = retry_sync_deadline(&policy, Some(deadline), |_| {
972            calls.fetch_add(1, Ordering::SeqCst);
973            Err(ControlFlow::Continue("transient"))
974        });
975        assert_eq!(result, Err("transient"));
976        assert_eq!(
977            calls.load(Ordering::SeqCst),
978            1,
979            "budget-exhausted retry must call op exactly once"
980        );
981        assert!(
982            start.elapsed() < Duration::from_secs(1),
983            "deadline check must skip the 10s backoff sleep, took {:?}",
984            start.elapsed()
985        );
986    }
987
988    #[test]
989    fn deadline_none_matches_retry_sync_on_success() {
990        let calls = AtomicU32::new(0);
991        let result: Result<u32, &str> = retry_sync_deadline(&fast_policy(), None, |attempt| {
992            calls.fetch_add(1, Ordering::SeqCst);
993            if attempt < 2 {
994                Err(ControlFlow::Continue("transient"))
995            } else {
996                Ok(attempt)
997            }
998        });
999        assert_eq!(result, Ok(2));
1000        assert_eq!(calls.load(Ordering::SeqCst), 2);
1001
1002        let sync_calls = AtomicU32::new(0);
1003        let sync_result: Result<u32, &str> = retry_sync(&fast_policy(), |attempt| {
1004            sync_calls.fetch_add(1, Ordering::SeqCst);
1005            if attempt < 2 {
1006                Err(ControlFlow::Continue("transient"))
1007            } else {
1008                Ok(attempt)
1009            }
1010        });
1011        assert_eq!(sync_result, result);
1012        assert_eq!(sync_calls.load(Ordering::SeqCst), 2);
1013    }
1014
1015    #[test]
1016    fn deadline_far_in_future_does_not_change_behavior() {
1017        let deadline = std::time::Instant::now() + Duration::from_secs(3600);
1018        let calls = AtomicU32::new(0);
1019        let result: Result<u32, &str> =
1020            retry_sync_deadline(&fast_policy(), Some(deadline), |attempt| {
1021                calls.fetch_add(1, Ordering::SeqCst);
1022                if attempt < 3 {
1023                    Err(ControlFlow::Continue("transient"))
1024                } else {
1025                    Ok(attempt)
1026                }
1027            });
1028        assert_eq!(result, Ok(3));
1029        assert_eq!(calls.load(Ordering::SeqCst), 3);
1030    }
1031
1032    #[tokio::test]
1033    async fn async_retries_until_success() {
1034        let calls = std::sync::Arc::new(AtomicU32::new(0));
1035        let calls_inner = calls.clone();
1036        let result: Result<u32, &str> = retry_async(&fast_policy(), move |attempt| {
1037            let c = calls_inner.clone();
1038            async move {
1039                c.fetch_add(1, Ordering::SeqCst);
1040                if attempt < 2 {
1041                    Err(ControlFlow::Continue("transient"))
1042                } else {
1043                    Ok(attempt)
1044                }
1045            }
1046        })
1047        .await;
1048        assert_eq!(result, Ok(2));
1049        assert_eq!(calls.load(Ordering::SeqCst), 2);
1050    }
1051
1052    // -----------------------------------------------------------------------
1053    // is_network_error / is_retriable / HttpError / Retriable
1054    //
1055    // Network-error classification test cases.
1056    // -----------------------------------------------------------------------
1057
1058    /// Plain string error wrapper used in classification tests.
1059    #[derive(Debug)]
1060    struct StrErr(&'static str);
1061    impl fmt::Display for StrErr {
1062        fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
1063            f.write_str(self.0)
1064        }
1065    }
1066    impl StdError for StrErr {}
1067
1068    #[derive(Debug)]
1069    struct OwnedErr(String);
1070    impl fmt::Display for OwnedErr {
1071        fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
1072            f.write_str(&self.0)
1073        }
1074    }
1075    impl StdError for OwnedErr {}
1076
1077    #[test]
1078    fn network_error_substrings_match() {
1079        for s in [
1080            "connection reset by peer",
1081            "network is unreachable",
1082            "connection closed unexpectedly",
1083            "connection refused",
1084            "tls handshake timeout",
1085            "i/o timeout",
1086            "CONNECTION RESET",
1087            "TLS Handshake Timeout",
1088            "write: broken pipe",
1089            "net/http: timeout awaiting response headers",
1090            "context deadline exceeded",
1091            // DNS-resolution failures across platforms (hyper-util connector
1092            // surfaces these via reqwest as `client error (Connect): dns
1093            // error: <platform tail>`). Pin every tail we know about so a
1094            // cross-platform CI failure cannot reintroduce the gap.
1095            "client error (Connect): dns error: failed to lookup address information: Name or service not known",
1096            "dns error: nodename nor servname provided, or not known",
1097            "dns error: No such host is known. (os error 11001)",
1098        ] {
1099            let e = OwnedErr(s.to_string());
1100            assert!(is_network_error(&e), "expected network error: {s:?}");
1101        }
1102    }
1103
1104    #[test]
1105    fn network_error_io_eof_kinds() {
1106        let e = io::Error::from(io::ErrorKind::UnexpectedEof);
1107        assert!(is_network_error(&e));
1108
1109        // A custom-kind io::Error whose Display is "EOF" (rustls / hyper convention).
1110        let e2 = io::Error::other("EOF");
1111        assert!(is_network_error(&e2));
1112    }
1113
1114    // Windows-CI regression: connect() on Windows surfaces transient failures
1115    // as io::Error { kind: TimedOut, message: "operation timed out" }, neither
1116    // of which matched the original EOF-only kind check or the
1117    // needle list. Same shape for the connection-* kinds across platforms —
1118    // pin each branch.
1119
1120    #[test]
1121    fn is_network_error_classifies_io_timedout() {
1122        let e = io::Error::from(io::ErrorKind::TimedOut);
1123        assert!(is_network_error(&e));
1124        assert!(is_retriable(&e));
1125    }
1126
1127    #[test]
1128    fn is_network_error_classifies_io_connection_refused() {
1129        let e = io::Error::from(io::ErrorKind::ConnectionRefused);
1130        assert!(is_network_error(&e));
1131        assert!(is_retriable(&e));
1132    }
1133
1134    #[test]
1135    fn is_network_error_classifies_io_connection_reset() {
1136        let e = io::Error::from(io::ErrorKind::ConnectionReset);
1137        assert!(is_network_error(&e));
1138        assert!(is_retriable(&e));
1139    }
1140
1141    #[test]
1142    fn is_network_error_classifies_io_connection_aborted() {
1143        let e = io::Error::from(io::ErrorKind::ConnectionAborted);
1144        assert!(is_network_error(&e));
1145        assert!(is_retriable(&e));
1146    }
1147
1148    #[test]
1149    fn is_network_error_classifies_io_broken_pipe() {
1150        let e = io::Error::from(io::ErrorKind::BrokenPipe);
1151        assert!(is_network_error(&e));
1152        assert!(is_retriable(&e));
1153    }
1154
1155    #[test]
1156    fn is_network_error_classifies_operation_timed_out_substring() {
1157        // Simulate a reqwest- or hyper-wrapped error whose io::ErrorKind has
1158        // been coerced to Other but whose Display still carries the Windows /
1159        // macOS TimedOut phrasing. Both the substring path and the
1160        // ErrorKind path must classify this independently.
1161        let other_kind = io::Error::other("operation timed out");
1162        assert!(is_network_error(&other_kind));
1163        assert!(is_retriable(&other_kind));
1164
1165        let kind_only = io::Error::from(io::ErrorKind::TimedOut);
1166        assert!(is_network_error(&kind_only));
1167        assert!(is_retriable(&kind_only));
1168    }
1169
1170    #[test]
1171    fn network_error_wrapped_unexpected_eof() {
1172        // Wrap an UnexpectedEof in an outer error so chain-walking is exercised.
1173        #[derive(Debug)]
1174        struct Wrap(io::Error);
1175        impl fmt::Display for Wrap {
1176            fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
1177                write!(f, "read failed")
1178            }
1179        }
1180        impl StdError for Wrap {
1181            fn source(&self) -> Option<&(dyn StdError + 'static)> {
1182                Some(&self.0)
1183            }
1184        }
1185        let inner = io::Error::from(io::ErrorKind::UnexpectedEof);
1186        let outer = Wrap(inner);
1187        assert!(is_network_error(&outer));
1188    }
1189
1190    #[test]
1191    fn network_error_non_network_strings_reject() {
1192        for s in [
1193            "file not found",
1194            "permission denied",
1195            "dial tcp: lookup example.com: no such host",
1196            "",
1197        ] {
1198            let e = OwnedErr(s.to_string());
1199            assert!(!is_network_error(&e), "expected NOT network error: {s:?}");
1200        }
1201    }
1202
1203    #[test]
1204    fn retriable_opt_nil_passthrough() {
1205        assert!(!is_retriable_opt(None));
1206    }
1207
1208    #[test]
1209    fn http_error_500_retriable() {
1210        let e = HttpError::new(StrErr("internal server error"), 500);
1211        assert!(is_retriable(&e));
1212    }
1213
1214    #[test]
1215    fn http_error_502_503_retriable() {
1216        for s in [502u16, 503] {
1217            let e = HttpError::new(StrErr("bad gateway"), s);
1218            assert!(is_retriable(&e), "status {s} should be retriable");
1219        }
1220    }
1221
1222    #[test]
1223    fn http_error_429_retriable() {
1224        let e = HttpError::new(StrErr("rate limited"), 429);
1225        assert!(is_retriable(&e));
1226    }
1227
1228    #[test]
1229    fn http_error_4xx_not_retriable() {
1230        for s in [400u16, 401, 403, 404, 422] {
1231            let e = HttpError::new(StrErr("client err"), s);
1232            assert!(!is_retriable(&e), "status {s} should NOT be retriable");
1233        }
1234    }
1235
1236    #[test]
1237    fn http_error_zero_status_routes_via_message() {
1238        // Status 0 == network-level failure with no response. Retriability
1239        // falls back to the network-error substring matcher on the inner.
1240        let net = HttpError::new(StrErr("connection reset"), 0);
1241        assert!(is_retriable(&net));
1242
1243        let non_net = HttpError::new(StrErr("dial failed"), 0);
1244        assert!(!is_retriable(&non_net));
1245    }
1246
1247    #[test]
1248    fn http_error_unwrap_chain_visible() {
1249        let inner = StrErr("inner");
1250        let e = HttpError::new(inner, 503);
1251        assert!(e.source().is_some());
1252    }
1253
1254    #[test]
1255    fn from_response_nil_resp_yields_status_zero() {
1256        // No response means status 0.
1257        // Use a concrete `io::Error` since `reqwest::Error` cannot be
1258        // synthesised in tests; the API accepts any `E: StdError + Send + Sync`.
1259        let inner = io::Error::other("connect: dial tcp");
1260        let e = HttpError::from_response(inner, None);
1261        assert_eq!(e.status, 0);
1262    }
1263
1264    #[test]
1265    fn from_response_unwrap_chain_visible() {
1266        // The inner error must remain reachable via the StdError chain so
1267        // is_retriable's network-error matcher can still see the cause.
1268        let inner = io::Error::other("connection reset by peer");
1269        let e = HttpError::from_response(inner, None);
1270        assert!(
1271            e.source().is_some(),
1272            "inner error must be reachable via source()"
1273        );
1274        // And classification must walk through to the network-error matcher.
1275        assert!(is_retriable(&e));
1276    }
1277
1278    #[test]
1279    fn retriable_wrapper_is_retriable() {
1280        let e = Retriable::new(StrErr("retry me"));
1281        assert!(is_retriable(&e));
1282    }
1283
1284    #[test]
1285    fn retriable_wrapper_overrides_4xx() {
1286        // A 422 wrapped in Retriable is still retriable.
1287        let inner = HttpError::new(StrErr("exists"), 422);
1288        let outer = Retriable::new(inner);
1289        assert!(is_retriable(&outer));
1290    }
1291
1292    #[test]
1293    fn retriable_wrapper_unwrap_chain_visible() {
1294        let inner = StrErr("inner");
1295        let e = Retriable::new(inner);
1296        assert!(e.source().is_some());
1297    }
1298
1299    #[test]
1300    fn plain_error_not_retriable() {
1301        let e = StrErr("something");
1302        assert!(!is_retriable(&e));
1303    }
1304
1305    #[test]
1306    fn anyhow_error_threadable() {
1307        // Ensure is_retriable works through anyhow::Error's deref-to-dyn path
1308        // (which is the canonical caller form across the codebase).
1309        let e: anyhow::Error = anyhow::anyhow!("connection refused");
1310        assert!(is_retriable(e.as_ref()));
1311
1312        let e2: anyhow::Error = anyhow::anyhow!("permission denied");
1313        assert!(!is_retriable(e2.as_ref()));
1314    }
1315
1316    #[test]
1317    fn is_retriable_chain_walks_to_http_error() {
1318        // An anyhow::Error wrapping a concrete HttpError must be classified
1319        // by walking source(), not by Display alone — the message "outer"
1320        // gives no hint, the 503 status does.
1321        let inner = HttpError::new(StrErr("bad gateway"), 503);
1322        let wrapped: anyhow::Error = anyhow::Error::new(inner).context("publish failed");
1323        assert!(is_retriable(wrapped.as_ref()));
1324    }
1325
1326    // ----- as_ref vs root_cause drift guard ---------------------------------
1327    //
1328    // Every consumer of `retry_http_blocking` (artifactory, cloudsmith, the
1329    // future stage-blob upload paths) classifies via `is_retriable(err.as_ref())`.
1330    // A subtle but catastrophic regression is to "simplify" that to
1331    // `is_retriable(err.root_cause())`, which walks past the HttpError wrapper
1332    // to the leaf io::Error — at which point 5xx misclassifies as fast-fail
1333    // (the leaf has no status code), and the entire retry policy becomes a
1334    // no-op. These tests pin the distinction once at the helper's home.
1335
1336    #[test]
1337    fn classifier_5xx_via_anyhow_chain_uses_as_ref() {
1338        let wrapped: anyhow::Error =
1339            anyhow::Error::new(HttpError::new(std::io::Error::other("503"), 503))
1340                .context("publish");
1341        assert!(
1342            is_retriable(wrapped.as_ref()),
1343            "5xx HttpError reached via as_ref() must classify retriable"
1344        );
1345    }
1346
1347    #[test]
1348    fn classifier_root_cause_walks_past_http_error_drift_guard() {
1349        // Drift guard: root_cause() unwraps to the leaf io::Error, which
1350        // has no status. If a future caller ever swaps as_ref → root_cause
1351        // they'll regress 5xx retry handling. This assertion locks the
1352        // distinction.
1353        let wrapped: anyhow::Error =
1354            anyhow::Error::new(HttpError::new(std::io::Error::other("503"), 503))
1355                .context("publish");
1356        assert!(
1357            !is_retriable(wrapped.root_cause()),
1358            "root_cause() walks past HttpError; 5xx must NOT be detected via the leaf"
1359        );
1360    }
1361
1362    #[test]
1363    fn classifier_429_via_anyhow_chain_uses_as_ref() {
1364        // Symmetry with the 5xx case: 429 is the other retriable status
1365        // class and must also stay reachable via as_ref().
1366        let wrapped: anyhow::Error =
1367            anyhow::Error::new(HttpError::new(std::io::Error::other("429"), 429))
1368                .context("publish");
1369        assert!(is_retriable(wrapped.as_ref()));
1370        assert!(!is_retriable(wrapped.root_cause()));
1371    }
1372
1373    // ----- retry_http_blocking behavioural tests ---------------------------
1374    //
1375    // `reqwest::Error` has no public constructor, so the transport-error
1376    // branch is exercised indirectly via per-publisher integration tests
1377    // (which mock at the network layer). The unit tests here drive a tiny
1378    // hand-rolled TCP server so we can exercise the success / non-success
1379    // status branches with a real reqwest::blocking::Client end-to-end.
1380
1381    use crate::test_helpers::responder::spawn_oneshot_http_responder;
1382
1383    #[test]
1384    fn retry_http_blocking_success_returns_first_attempt() {
1385        let (addr, calls) =
1386            spawn_oneshot_http_responder(vec!["HTTP/1.1 200 OK\r\nContent-Length: 2\r\n\r\nok"]);
1387        let client = reqwest::blocking::Client::builder()
1388            .timeout(Duration::from_secs(2))
1389            .build()
1390            .expect("client");
1391        let policy = RetryPolicy {
1392            max_attempts: 3,
1393            base_delay: Duration::from_millis(1),
1394            max_delay: Duration::from_millis(2),
1395        };
1396        let result = retry_http_blocking(
1397            "test",
1398            &policy,
1399            SuccessClass::Strict,
1400            |_| client.get(format!("http://{addr}/")).send(),
1401            |_, _| String::from("should not be called on success"),
1402        );
1403        let (status, body) = result.expect("success");
1404        assert_eq!(status.as_u16(), 200);
1405        assert_eq!(body, "ok");
1406        assert_eq!(calls.load(Ordering::SeqCst), 1, "single attempt");
1407    }
1408
1409    #[test]
1410    fn retry_http_blocking_retries_5xx_then_succeeds() {
1411        let (addr, calls) = spawn_oneshot_http_responder(vec![
1412            "HTTP/1.1 503 Service Unavailable\r\nContent-Length: 0\r\n\r\n",
1413            "HTTP/1.1 200 OK\r\nContent-Length: 2\r\n\r\nok",
1414        ]);
1415        let client = reqwest::blocking::Client::builder()
1416            .timeout(Duration::from_secs(2))
1417            .build()
1418            .expect("client");
1419        let policy = RetryPolicy {
1420            max_attempts: 3,
1421            base_delay: Duration::from_millis(1),
1422            max_delay: Duration::from_millis(2),
1423        };
1424        let result = retry_http_blocking(
1425            "test",
1426            &policy,
1427            SuccessClass::Strict,
1428            |_| client.get(format!("http://{addr}/")).send(),
1429            |status, body| format!("{status}: {body}"),
1430        );
1431        let (status, _) = result.expect("eventually succeeds");
1432        assert_eq!(status.as_u16(), 200);
1433        assert_eq!(calls.load(Ordering::SeqCst), 2, "one retry then success");
1434    }
1435
1436    #[test]
1437    fn retry_http_blocking_4xx_fast_fails_no_retry() {
1438        let (addr, calls) = spawn_oneshot_http_responder(vec![
1439            "HTTP/1.1 404 Not Found\r\nContent-Length: 9\r\n\r\nnot found",
1440        ]);
1441        let client = reqwest::blocking::Client::builder()
1442            .timeout(Duration::from_secs(2))
1443            .build()
1444            .expect("client");
1445        let policy = RetryPolicy {
1446            max_attempts: 5,
1447            base_delay: Duration::from_millis(1),
1448            max_delay: Duration::from_millis(2),
1449        };
1450        let result = retry_http_blocking(
1451            "myscope",
1452            &policy,
1453            SuccessClass::Strict,
1454            |_| client.get(format!("http://{addr}/")).send(),
1455            |status, body| format!("custom error: {status} body={body}"),
1456        );
1457        let err = result.expect_err("4xx must fast-fail");
1458        let chain = format!("{err:#}");
1459        assert!(
1460            chain.contains("custom error"),
1461            "error formatter must be invoked on non-success; got: {chain}"
1462        );
1463        assert!(chain.contains("404"), "status must be in chain: {chain}");
1464        assert_eq!(
1465            calls.load(Ordering::SeqCst),
1466            1,
1467            "4xx must NOT retry (only one connection accepted)"
1468        );
1469    }
1470
1471    #[test]
1472    fn retry_http_blocking_redirect_class_alters_success_predicate() {
1473        let (addr, _calls) = spawn_oneshot_http_responder(vec![
1474            "HTTP/1.1 307 Temporary Redirect\r\nLocation: /next\r\nContent-Length: 0\r\n\r\n",
1475        ]);
1476        let client = reqwest::blocking::Client::builder()
1477            .timeout(Duration::from_secs(2))
1478            // Disable redirect-following so the 307 surfaces to our helper.
1479            .redirect(reqwest::redirect::Policy::none())
1480            .build()
1481            .expect("client");
1482        let policy = RetryPolicy {
1483            max_attempts: 3,
1484            base_delay: Duration::from_millis(1),
1485            max_delay: Duration::from_millis(2),
1486        };
1487        let result = retry_http_blocking(
1488            "test",
1489            &policy,
1490            SuccessClass::AllowRedirects,
1491            |_| client.get(format!("http://{addr}/")).send(),
1492            |_, _| String::from("should not be called on 3xx with AllowRedirects"),
1493        );
1494        let (status, _) = result.expect("3xx is success under AllowRedirects");
1495        assert_eq!(status.as_u16(), 307);
1496    }
1497
1498    // ----- retry_http_blocking_bytes behavioural tests ---------------------
1499
1500    #[test]
1501    fn retry_http_blocking_bytes_preserves_non_utf8_body() {
1502        // A body with invalid-UTF-8 byte sequences (gzip magic + a bare
1503        // continuation byte) proves the bytes variant does not run a lossy
1504        // UTF-8 pass over the success payload — `resp.text()` would silently
1505        // rewrite these to U+FFFD, corrupting the digest of whatever the
1506        // caller hashes.
1507        let body: Vec<u8> = vec![0x1f, 0x8b, 0x08, 0x00, 0x80, 0xff, 0xfe, 0x00];
1508        let listener = std::net::TcpListener::bind("127.0.0.1:0").expect("bind ephemeral port");
1509        let addr = listener.local_addr().expect("local_addr");
1510        let body_for_thread = body.clone();
1511        std::thread::spawn(move || {
1512            use std::io::{Read, Write};
1513            if let Ok((mut stream, _)) = listener.accept() {
1514                let mut buf = [0u8; 1024];
1515                let _ = stream.read(&mut buf);
1516                let header = format!(
1517                    "HTTP/1.1 200 OK\r\nContent-Length: {}\r\nConnection: close\r\n\r\n",
1518                    body_for_thread.len()
1519                );
1520                let _ = stream.write_all(header.as_bytes());
1521                let _ = stream.write_all(&body_for_thread);
1522                let _ = stream.flush();
1523                let _ = stream.shutdown(std::net::Shutdown::Both);
1524            }
1525        });
1526        let client = reqwest::blocking::Client::builder()
1527            .timeout(Duration::from_secs(2))
1528            .build()
1529            .expect("client");
1530        let policy = RetryPolicy {
1531            max_attempts: 1,
1532            base_delay: Duration::from_millis(1),
1533            max_delay: Duration::from_millis(2),
1534        };
1535        let result = retry_http_blocking_bytes(
1536            "test",
1537            &policy,
1538            SuccessClass::Strict,
1539            |_| client.get(format!("http://{addr}/")).send(),
1540            |_, _| String::from("should not be called on success"),
1541        );
1542        let (status, bytes) = result.expect("success");
1543        assert_eq!(status.as_u16(), 200);
1544        assert_eq!(bytes, body, "binary body must round-trip byte-for-byte");
1545    }
1546
1547    #[test]
1548    fn retry_http_blocking_bytes_4xx_fast_fails_no_retry() {
1549        let (addr, calls) = spawn_oneshot_http_responder(vec![
1550            "HTTP/1.1 404 Not Found\r\nContent-Length: 9\r\n\r\nnot found",
1551        ]);
1552        let client = reqwest::blocking::Client::builder()
1553            .timeout(Duration::from_secs(2))
1554            .build()
1555            .expect("client");
1556        let policy = RetryPolicy {
1557            max_attempts: 5,
1558            base_delay: Duration::from_millis(1),
1559            max_delay: Duration::from_millis(2),
1560        };
1561        let result = retry_http_blocking_bytes(
1562            "myscope",
1563            &policy,
1564            SuccessClass::Strict,
1565            |_| client.get(format!("http://{addr}/")).send(),
1566            |status, body| format!("custom error: {status} body={body}"),
1567        );
1568        let err = result.expect_err("4xx must fast-fail");
1569        let chain = format!("{err:#}");
1570        assert!(
1571            chain.contains("custom error") && chain.contains("not found"),
1572            "error formatter must see the (lossily-decoded) error body: {chain}"
1573        );
1574        assert_eq!(
1575            calls.load(Ordering::SeqCst),
1576            1,
1577            "4xx must NOT retry (only one connection accepted)"
1578        );
1579    }
1580
1581    #[test]
1582    fn retry_http_blocking_bytes_retries_5xx_then_succeeds() {
1583        let (addr, calls) = spawn_oneshot_http_responder(vec![
1584            "HTTP/1.1 503 Service Unavailable\r\nContent-Length: 0\r\n\r\n",
1585            "HTTP/1.1 200 OK\r\nContent-Length: 2\r\n\r\nok",
1586        ]);
1587        let client = reqwest::blocking::Client::builder()
1588            .timeout(Duration::from_secs(2))
1589            .build()
1590            .expect("client");
1591        let policy = RetryPolicy {
1592            max_attempts: 3,
1593            base_delay: Duration::from_millis(1),
1594            max_delay: Duration::from_millis(2),
1595        };
1596        let result = retry_http_blocking_bytes(
1597            "test",
1598            &policy,
1599            SuccessClass::Strict,
1600            |_| client.get(format!("http://{addr}/")).send(),
1601            |status, body| format!("{status}: {body}"),
1602        );
1603        let (status, bytes) = result.expect("eventually succeeds");
1604        assert_eq!(status.as_u16(), 200);
1605        assert_eq!(bytes, b"ok");
1606        assert_eq!(calls.load(Ordering::SeqCst), 2, "one retry then success");
1607    }
1608
1609    // ----- retry_http_async behavioural tests ------------------------------
1610    //
1611    // Mirrors the blocking suite but drives an async reqwest::Client against
1612    // the same hand-rolled TCP responder (running on a worker thread, so the
1613    // tokio reactor is free to drive the client futures). The transport-error
1614    // arm (Err(reqwest::Error)) is exercised by
1615    // `retry_http_{async,blocking}_transport_error_retries_then_fails` below,
1616    // which bind an ephemeral port, drop the listener, then point the client
1617    // at the now-defunct address.
1618
1619    #[tokio::test]
1620    async fn retry_http_async_success_returns_first_attempt() {
1621        let (addr, calls) =
1622            spawn_oneshot_http_responder(vec!["HTTP/1.1 200 OK\r\nContent-Length: 2\r\n\r\nok"]);
1623        let client = reqwest::Client::builder()
1624            .timeout(Duration::from_secs(2))
1625            .build()
1626            .expect("client");
1627        let policy = RetryPolicy {
1628            max_attempts: 3,
1629            base_delay: Duration::from_millis(1),
1630            max_delay: Duration::from_millis(2),
1631        };
1632        let result = retry_http_async(
1633            "test",
1634            &policy,
1635            SuccessClass::Strict,
1636            |_| client.get(format!("http://{addr}/")).send(),
1637            |_, _| String::from("should not be called on success"),
1638        )
1639        .await;
1640        let resp = result.expect("success");
1641        assert_eq!(resp.status().as_u16(), 200);
1642        let body = resp.text().await.expect("body");
1643        assert_eq!(body, "ok");
1644        assert_eq!(calls.load(Ordering::SeqCst), 1, "single attempt");
1645    }
1646
1647    #[tokio::test]
1648    async fn retry_http_async_retries_5xx_then_succeeds() {
1649        let (addr, calls) = spawn_oneshot_http_responder(vec![
1650            "HTTP/1.1 503 Service Unavailable\r\nContent-Length: 0\r\n\r\n",
1651            "HTTP/1.1 200 OK\r\nContent-Length: 2\r\n\r\nok",
1652        ]);
1653        let client = reqwest::Client::builder()
1654            .timeout(Duration::from_secs(2))
1655            .build()
1656            .expect("client");
1657        let policy = RetryPolicy {
1658            max_attempts: 3,
1659            base_delay: Duration::from_millis(1),
1660            max_delay: Duration::from_millis(2),
1661        };
1662        let result = retry_http_async(
1663            "test",
1664            &policy,
1665            SuccessClass::Strict,
1666            |_| client.get(format!("http://{addr}/")).send(),
1667            |status, body| format!("{status}: {body}"),
1668        )
1669        .await;
1670        let resp = result.expect("eventually succeeds");
1671        assert_eq!(resp.status().as_u16(), 200);
1672        assert_eq!(calls.load(Ordering::SeqCst), 2, "one retry then success");
1673    }
1674
1675    #[tokio::test]
1676    async fn retry_http_async_4xx_fast_fails_no_retry() {
1677        let (addr, calls) = spawn_oneshot_http_responder(vec![
1678            "HTTP/1.1 404 Not Found\r\nContent-Length: 9\r\n\r\nnot found",
1679        ]);
1680        let client = reqwest::Client::builder()
1681            .timeout(Duration::from_secs(2))
1682            .build()
1683            .expect("client");
1684        let policy = RetryPolicy {
1685            max_attempts: 5,
1686            base_delay: Duration::from_millis(1),
1687            max_delay: Duration::from_millis(2),
1688        };
1689        let result = retry_http_async(
1690            "myscope",
1691            &policy,
1692            SuccessClass::Strict,
1693            |_| client.get(format!("http://{addr}/")).send(),
1694            |status, body| format!("custom error: {status} body={body}"),
1695        )
1696        .await;
1697        let err = result.expect_err("4xx must fast-fail");
1698        let chain = format!("{err:#}");
1699        assert!(
1700            chain.contains("custom error"),
1701            "error formatter must be invoked on non-success; got: {chain}"
1702        );
1703        assert!(chain.contains("404"), "status must be in chain: {chain}");
1704        assert_eq!(
1705            calls.load(Ordering::SeqCst),
1706            1,
1707            "4xx must NOT retry (only one connection accepted)"
1708        );
1709    }
1710
1711    #[tokio::test]
1712    async fn retry_http_async_429_retries_then_succeeds() {
1713        // 429 (Too Many Requests) is the second retriable class alongside
1714        // 5xx. Ensures the helper doesn't accidentally fast-fail on rate
1715        // limits — a regression here would defeat the whole point of
1716        // wiring retry into release publishers.
1717        let (addr, calls) = spawn_oneshot_http_responder(vec![
1718            "HTTP/1.1 429 Too Many Requests\r\nContent-Length: 0\r\n\r\n",
1719            "HTTP/1.1 200 OK\r\nContent-Length: 2\r\n\r\nok",
1720        ]);
1721        let client = reqwest::Client::builder()
1722            .timeout(Duration::from_secs(2))
1723            .build()
1724            .expect("client");
1725        let policy = RetryPolicy {
1726            max_attempts: 3,
1727            base_delay: Duration::from_millis(1),
1728            max_delay: Duration::from_millis(2),
1729        };
1730        let result = retry_http_async(
1731            "test",
1732            &policy,
1733            SuccessClass::Strict,
1734            |_| client.get(format!("http://{addr}/")).send(),
1735            |status, body| format!("{status}: {body}"),
1736        )
1737        .await;
1738        let resp = result.expect("429 retried then success");
1739        assert_eq!(resp.status().as_u16(), 200);
1740        assert_eq!(calls.load(Ordering::SeqCst), 2);
1741    }
1742
1743    // ----- transport-error behavioural tests -------------------------------
1744    //
1745    // The transport-error arm (Err(reqwest::Error): DNS failure, connection
1746    // refused, EOF, TLS handshake failure, etc.) is the single most
1747    // reviewer-load-bearing path: it is the one the helper claims to retry
1748    // and that publishers rely on for resilience against transient network
1749    // blips. The pattern below dials the RFC 2606-reserved `.invalid` TLD,
1750    // which is guaranteed never to resolve, so every attempt fails at the
1751    // DNS-resolution stage in a few milliseconds on Linux, macOS, and
1752    // Windows alike.
1753    //
1754    // We verify:
1755    //   1. the helper retries (attempt counter > 1)
1756    //   2. eventually surfaces an Err with the configured label in the chain
1757    // The outer attempt counter is incremented inside the closure, so it
1758    // sees one bump per attempt regardless of the underlying transport
1759    // outcome.
1760    //
1761    // RFC 2606 (https://datatracker.ietf.org/doc/html/rfc2606) reserves the
1762    // `.invalid` TLD precisely for this purpose; using it removes any
1763    // dependence on OS-level TCP semantics (Windows' kernel can retransmit
1764    // SYN against an unbound loopback port until the connect timeout fires
1765    // rather than refusing synchronously like Linux + macOS do).
1766    const TRANSPORT_FAIL_URL: &str = "http://nonexistent.invalid/";
1767
1768    #[test]
1769    fn retry_http_blocking_transport_error_retries_then_fails() {
1770        let attempts = std::sync::Arc::new(AtomicU32::new(0));
1771        let attempts_inner = attempts.clone();
1772        let client = reqwest::blocking::Client::builder()
1773            .timeout(Duration::from_millis(500))
1774            .build()
1775            .expect("client");
1776        let policy = RetryPolicy {
1777            max_attempts: 3,
1778            base_delay: Duration::from_millis(1),
1779            max_delay: Duration::from_millis(2),
1780        };
1781        let result = retry_http_blocking(
1782            "test-transport",
1783            &policy,
1784            SuccessClass::Strict,
1785            |_| {
1786                attempts_inner.fetch_add(1, Ordering::SeqCst);
1787                client.get(TRANSPORT_FAIL_URL).send()
1788            },
1789            |_, _| String::from("non-success branch should not be reached"),
1790        );
1791        let err = result.expect_err("transport error must surface as Err");
1792        let chain = format!("{err:#}");
1793        assert!(
1794            attempts.load(Ordering::SeqCst) > 1,
1795            "transport error must be retried; got {} attempts; chain={chain}",
1796            attempts.load(Ordering::SeqCst)
1797        );
1798        assert!(
1799            chain.contains("test-transport"),
1800            "label must surface in error chain; got: {chain}"
1801        );
1802    }
1803
1804    #[tokio::test]
1805    async fn retry_http_async_transport_error_retries_then_fails() {
1806        let attempts = std::sync::Arc::new(AtomicU32::new(0));
1807        let attempts_inner = attempts.clone();
1808        let client = reqwest::Client::builder()
1809            .timeout(Duration::from_millis(500))
1810            .build()
1811            .expect("client");
1812        let policy = RetryPolicy {
1813            max_attempts: 3,
1814            base_delay: Duration::from_millis(1),
1815            max_delay: Duration::from_millis(2),
1816        };
1817        let result = retry_http_async(
1818            "test-transport-async",
1819            &policy,
1820            SuccessClass::Strict,
1821            |_| {
1822                attempts_inner.fetch_add(1, Ordering::SeqCst);
1823                client.get(TRANSPORT_FAIL_URL).send()
1824            },
1825            |_, _| String::from("non-success branch should not be reached"),
1826        )
1827        .await;
1828        let err = result.expect_err("transport error must surface as Err");
1829        assert!(
1830            attempts.load(Ordering::SeqCst) > 1,
1831            "transport error must be retried; got {} attempts",
1832            attempts.load(Ordering::SeqCst)
1833        );
1834        let chain = format!("{err:#}");
1835        assert!(
1836            chain.contains("test-transport-async"),
1837            "label must surface in error chain; got: {chain}"
1838        );
1839    }
1840}