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    pub fn delay_for(&self, next_attempt: u32) -> Duration {
63        // `next_attempt` is the attempt we're about to run (≥2). The wait
64        // before attempt 2 uses base_delay; before attempt 3 uses base_delay*2;
65        // i.e. multiplier = 2^(next_attempt - 2).
66        let exp = next_attempt.saturating_sub(2);
67        let mult = 1u64.checked_shl(exp).unwrap_or(u64::MAX);
68        let ms = (self.base_delay.as_millis() as u64).saturating_mul(mult);
69        std::cmp::min(Duration::from_millis(ms), self.max_delay)
70    }
71}
72
73/// Retry a synchronous operation according to `policy`.
74///
75/// `op` returns:
76/// - `Ok(T)` on success (no retry).
77/// - `Err(ControlFlow::Continue(e))` to retry if attempts remain.
78/// - `Err(ControlFlow::Break(e))` to stop immediately (4xx-style fast-fail).
79///
80/// Returns the last error if all attempts are exhausted.
81pub fn retry_sync<T, E, F>(policy: &RetryPolicy, mut op: F) -> Result<T, E>
82where
83    F: FnMut(u32) -> Result<T, ControlFlow<E, E>>,
84{
85    let max = policy.max_attempts.max(1);
86    let mut attempt: u32 = 1;
87    loop {
88        if attempt > 1 {
89            std::thread::sleep(policy.delay_for(attempt));
90        }
91        match op(attempt) {
92            Ok(v) => return Ok(v),
93            Err(ControlFlow::Break(e)) => return Err(e),
94            Err(ControlFlow::Continue(e)) => {
95                if attempt >= max {
96                    return Err(e);
97                }
98            }
99        }
100        attempt += 1;
101    }
102}
103
104/// Retry an asynchronous operation according to `policy`.
105///
106/// Same semantics as `retry_sync` but awaits `op` and uses `tokio::time::sleep`.
107pub async fn retry_async<T, E, F, Fut>(policy: &RetryPolicy, mut op: F) -> Result<T, E>
108where
109    F: FnMut(u32) -> Fut,
110    Fut: std::future::Future<Output = Result<T, ControlFlow<E, E>>>,
111{
112    let max = policy.max_attempts.max(1);
113    let mut attempt: u32 = 1;
114    loop {
115        if attempt > 1 {
116            tokio::time::sleep(policy.delay_for(attempt)).await;
117        }
118        match op(attempt).await {
119            Ok(v) => return Ok(v),
120            Err(ControlFlow::Break(e)) => return Err(e),
121            Err(ControlFlow::Continue(e)) => {
122                if attempt >= max {
123                    return Err(e);
124                }
125            }
126        }
127        attempt += 1;
128    }
129}
130
131/// Whether to consider 3xx redirects a success outcome (most upload-style
132/// publishers do, since the underlying client follows redirects under the
133/// hood; some callers explicitly want only 2xx).
134#[derive(Debug, Clone, Copy, PartialEq, Eq)]
135pub enum SuccessClass {
136    /// 2xx only. Any 3xx is treated as a non-success status (eligible for
137    /// retry / fast-fail per `is_retriable`).
138    Strict,
139    /// 2xx OR 3xx. Used by upload publishers whose servers may emit a
140    /// 301/302/307 in the success path (artifactory does this for some
141    /// virtual repo configurations).
142    AllowRedirects,
143}
144
145/// Drive a single HTTP call to completion, retrying transient failures via
146/// the shared [`retry_sync`] machinery.
147///
148/// On every attempt, `send` is invoked to construct + dispatch a fresh
149/// request. The closure must rebuild the request from scratch (multipart
150/// `Form`, streamed body, etc. are move-only). The helper:
151///
152/// 1. On `Err` (transport-level): wrap in [`HttpError::from_response`] +
153///    a `<label>: <stage> transport error` context, classify with
154///    [`is_retriable`] (so EOF / connection-reset retry, plain "dial
155///    failed" fast-fails), and dispatch `Continue`/`Break`.
156/// 2. On non-success status: drain the body, format the outer message via
157///    `error_msg`, wrap in [`HttpError::new`] with the upstream status, and
158///    classify (5xx/429 → `Continue`, 4xx → `Break`).
159/// 3. On success status: return `(status, body)`.
160///
161/// The `error_msg` closure receives the response status and body so callers
162/// can format publisher-specific envelopes (e.g. artifactory's
163/// `{"errors":[...]}` JSON).
164///
165/// Replaces three nearly-identical retry loops:
166/// - `stage-publish/cloudsmith.rs::retry_request`
167/// - `stage-publish/artifactory.rs::upload_single_artifact` (inline)
168/// - `stage-announce/helpers.rs::retry_http` (now wraps this helper; see
169///   announce/helpers.rs for the thin adapter that returns the body string
170///   instead of `(StatusCode, String)`).
171pub fn retry_http_blocking<F, M>(
172    label: &str,
173    policy: &RetryPolicy,
174    success_class: SuccessClass,
175    mut send: F,
176    error_msg: M,
177) -> anyhow::Result<(reqwest::StatusCode, String)>
178where
179    F: FnMut(u32) -> Result<reqwest::blocking::Response, reqwest::Error>,
180    M: Fn(reqwest::StatusCode, &str) -> String,
181{
182    use anyhow::Context as _;
183    retry_sync(policy, |attempt| {
184        match send(attempt) {
185            Ok(resp) => {
186                let status = resp.status();
187                let succeeded = match success_class {
188                    SuccessClass::Strict => status.is_success(),
189                    SuccessClass::AllowRedirects => status.is_success() || status.is_redirection(),
190                };
191                let body = resp
192                    .text()
193                    .unwrap_or_else(|e| format!("<failed to read body: {e}>"));
194                if succeeded {
195                    Ok((status, body))
196                } else {
197                    let msg = error_msg(status, &body);
198                    let inner = anyhow::anyhow!("{msg}");
199                    let wrapped = anyhow::Error::new(HttpError::new(
200                        std::io::Error::other(inner.to_string()),
201                        status.as_u16(),
202                    ))
203                    .context(inner);
204                    // `as_ref()` is the head of the chain; `is_retriable` walks
205                    // `.source()` to reach `HttpError`. `root_cause()` would
206                    // unwrap past `HttpError` to the io::Error leaf and miss
207                    // the status. Pinned by
208                    // `classifier_5xx_via_anyhow_chain_uses_as_ref`.
209                    if is_retriable(wrapped.as_ref()) {
210                        Err(ControlFlow::Continue(wrapped))
211                    } else {
212                        Err(ControlFlow::Break(wrapped))
213                    }
214                }
215            }
216            Err(e) => {
217                // Transport-layer failure: always wrap in HttpError(status=0)
218                // so the chain-walking classifier can see network-error
219                // substrings via the inner io::Error message.
220                let err = anyhow::Error::new(HttpError::from_response(e, None))
221                    .context(format!("{label}: HTTP transport error"));
222                if is_retriable(err.as_ref()) {
223                    Err(ControlFlow::Continue(err))
224                } else {
225                    Err(ControlFlow::Break(err))
226                }
227            }
228        }
229    })
230    .with_context(|| format!("{label}: exhausted retry attempts"))
231}
232
233/// Async sibling of [`retry_http_blocking`] for `reqwest::Client` (non-blocking)
234/// call sites such as the GitLab and Gitea release publishers.
235///
236/// Each attempt invokes `send` (a fresh future) and:
237///
238/// 1. On `Err` (transport-level): wraps in [`HttpError::from_response`] +
239///    a `<label>: HTTP transport error` context, classifies via
240///    [`is_retriable`] (network-substring + EOF chain match), and dispatches
241///    `Continue`/`Break`.
242/// 2. On non-success status: drains the body via `Response::text().await`,
243///    formats the outer message via `error_msg`, wraps in [`HttpError::new`]
244///    with the upstream status, and classifies (5xx/429 → `Continue`, 4xx →
245///    `Break`).
246/// 3. On success status: returns the raw [`reqwest::Response`] for the
247///    caller to consume (e.g. `.json()`, `.text()`, header inspection).
248///
249/// `success_class` mirrors the blocking variant: `Strict` rejects 3xx,
250/// `AllowRedirects` accepts them. Most async API clients want `Strict`
251/// (their reqwest::Client follows redirects by default, so a surfaced 3xx
252/// is itself an error).
253pub async fn retry_http_async<F, Fut, M>(
254    label: &str,
255    policy: &RetryPolicy,
256    success_class: SuccessClass,
257    mut send: F,
258    error_msg: M,
259) -> anyhow::Result<reqwest::Response>
260where
261    F: FnMut(u32) -> Fut,
262    Fut: std::future::Future<Output = Result<reqwest::Response, reqwest::Error>>,
263    M: Fn(reqwest::StatusCode, &str) -> String,
264{
265    use anyhow::Context as _;
266    retry_async(policy, |attempt| {
267        let fut = send(attempt);
268        let error_msg = &error_msg;
269        async move {
270            match fut.await {
271                Ok(resp) => {
272                    let status = resp.status();
273                    let succeeded = match success_class {
274                        SuccessClass::Strict => status.is_success(),
275                        SuccessClass::AllowRedirects => {
276                            status.is_success() || status.is_redirection()
277                        }
278                    };
279                    if succeeded {
280                        Ok(resp)
281                    } else {
282                        let body = resp
283                            .text()
284                            .await
285                            .unwrap_or_else(|e| format!("<failed to read body: {e}>"));
286                        let msg = error_msg(status, &body);
287                        let inner = anyhow::anyhow!("{msg}");
288                        let wrapped = anyhow::Error::new(HttpError::new(
289                            std::io::Error::other(inner.to_string()),
290                            status.as_u16(),
291                        ))
292                        .context(inner);
293                        // `as_ref()` is the head of the chain; `is_retriable`
294                        // walks `.source()` to reach `HttpError`. `root_cause()`
295                        // would unwrap past `HttpError` to the io::Error leaf
296                        // and miss the status. Pinned by
297                        // `classifier_5xx_via_anyhow_chain_uses_as_ref`.
298                        if is_retriable(wrapped.as_ref()) {
299                            Err(ControlFlow::Continue(wrapped))
300                        } else {
301                            Err(ControlFlow::Break(wrapped))
302                        }
303                    }
304                }
305                Err(e) => {
306                    // Transport-layer failure: wrap in HttpError(status=0) so
307                    // the chain-walking classifier can see network-error
308                    // substrings via the inner io::Error message.
309                    let err = anyhow::Error::new(HttpError::from_response(e, None))
310                        .context(format!("{label}: HTTP transport error"));
311                    if is_retriable(err.as_ref()) {
312                        Err(ControlFlow::Continue(err))
313                    } else {
314                        Err(ControlFlow::Break(err))
315                    }
316                }
317            }
318        }
319    })
320    .await
321    .with_context(|| format!("{label}: exhausted retry attempts"))
322}
323
324/// Classify a `reqwest::Result<reqwest::blocking::Response>` into the
325/// `ControlFlow` shape expected by `retry_sync` for a typical HTTP call:
326/// 5xx + transport errors retry, 4xx fast-fails, 2xx/3xx returns Ok. The
327/// returned response (Ok branch) is the caller's to consume.
328///
329/// This is the convention shared by every HTTP-uploading publisher; see audit
330/// A7 dedup S5.
331pub fn classify_http_sync(
332    result: reqwest::Result<reqwest::blocking::Response>,
333) -> Result<reqwest::blocking::Response, ControlFlow<anyhow::Error, anyhow::Error>> {
334    use anyhow::anyhow;
335    match result {
336        Ok(resp) => {
337            let status = resp.status();
338            if status.is_success() || status.is_redirection() {
339                Ok(resp)
340            } else if status.is_server_error() {
341                Err(ControlFlow::Continue(anyhow!(
342                    "HTTP {} {}",
343                    status.as_u16(),
344                    status.canonical_reason().unwrap_or("server error")
345                )))
346            } else {
347                // 4xx (and any other non-success/redirect/5xx): fast-fail
348                Err(ControlFlow::Break(anyhow!(
349                    "HTTP {} {}",
350                    status.as_u16(),
351                    status.canonical_reason().unwrap_or("client error")
352                )))
353            }
354        }
355        // Transport-layer failure (DNS, connect, TLS, timeout): retry.
356        Err(e) => Err(ControlFlow::Continue(anyhow!(e))),
357    }
358}
359
360// ---------------------------------------------------------------------------
361// Retriable-error classification
362// ---------------------------------------------------------------------------
363
364/// Carries an HTTP status code alongside the original error so
365/// [`is_retriable`] can route 5xx / 429 to retry and 4xx to fast-fail.
366///
367/// HTTP error carrying status + message. Construct via [`HttpError::new`]
368/// (status-only) or wrap an existing `reqwest::Response` via
369/// [`HttpError::from_response`].
370///
371/// A `status` of `0` denotes a network-level failure where no response was
372/// ever received (the no-response branch). Network-level failures
373/// are still classified via the inner error's message, so wrapping them in
374/// `HttpError { status: 0, .. }` does not lose retriability information.
375#[derive(Debug)]
376pub struct HttpError {
377    /// The wrapped error (transport, decode, or status-derived message).
378    /// Reachable via the [`StdError::source`] trait method (not directly).
379    source: Box<dyn StdError + Send + Sync + 'static>,
380    /// HTTP status code; `0` for transport-level failures.
381    pub status: u16,
382}
383
384impl HttpError {
385    /// Wrap an error with a status code. `0` denotes a network-level failure
386    /// (no response received).
387    pub fn new<E>(source: E, status: u16) -> Self
388    where
389        E: StdError + Send + Sync + 'static,
390    {
391        Self {
392            source: Box::new(source),
393            status,
394        }
395    }
396
397    /// Wrap a transport-layer error with the status code from the (possibly
398    /// missing) response.
399    /// `None` resp yields status `0` (network-level failure).
400    pub fn from_response<E>(err: E, resp: Option<&reqwest::Response>) -> Self
401    where
402        E: StdError + Send + Sync + 'static,
403    {
404        Self::new(err, resp.map(|r| r.status().as_u16()).unwrap_or(0))
405    }
406}
407
408impl fmt::Display for HttpError {
409    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
410        // Defer to the inner error so messages stay focused on the cause.
411        // Delegate to the inner error message.
412        fmt::Display::fmt(&self.source, f)
413    }
414}
415
416impl StdError for HttpError {
417    fn source(&self) -> Option<&(dyn StdError + 'static)> {
418        Some(&*self.source)
419    }
420}
421
422/// Marker error wrapping any inner error so [`is_retriable`] returns `true`
423/// regardless of class — useful when a
424/// caller knows the failure is transient (e.g. an idempotent registry write
425/// returning 422 because of a transient race condition) and wants the retry
426/// loop to ignore the usual 4xx fast-fail.
427#[derive(Debug)]
428pub struct Retriable(Box<dyn StdError + Send + Sync + 'static>);
429
430impl Retriable {
431    /// Wrap any error so [`is_retriable`] returns `true` regardless of class.
432    /// Use this when a caller knows a 4xx is transient (e.g. a 422 from an
433    /// idempotent registry write losing a race) and wants to override the
434    /// usual fast-fail. For `Option<E>` inputs, see [`is_retriable_opt`] —
435    /// this constructor itself is non-nullable.
436    pub fn new<E>(source: E) -> Self
437    where
438        E: StdError + Send + Sync + 'static,
439    {
440        Self(Box::new(source))
441    }
442}
443
444impl fmt::Display for Retriable {
445    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
446        fmt::Display::fmt(&self.0, f)
447    }
448}
449
450impl StdError for Retriable {
451    fn source(&self) -> Option<&(dyn StdError + 'static)> {
452        Some(&*self.0)
453    }
454}
455
456/// Returns `true` if the message looks like a transient network-layer failure.
457///
458/// Network-error classification, extended for Rust /
459/// Windows. Each link in the error chain is checked two ways:
460///
461/// 1a. **Structural [`io::ErrorKind`] check** via `downcast_ref::<io::Error>()`.
462///     Treats `UnexpectedEof`, `TimedOut`, `ConnectionRefused`,
463///     `ConnectionReset`, `ConnectionAborted`, and `BrokenPipe` as transient.
464///     The OS-classified `ErrorKind` is robust where Display text is not:
465///     Linux's connect-refused says `"Connection refused"` but Windows
466///     surfaces a transient connect failure as
467///     `io::Error { kind: TimedOut, message: "operation timed out" }`, and
468///     a Windows-reset reads `"An existing connection was forcibly closed"`.
469///     Matching `kind()` catches all of them regardless of phrasing. Also
470///     recognises any `io::Error` whose Display form is `"EOF"` /
471///     `"unexpected eof"` (rustls / hyper convention; Rust has no
472///     equivalent of Go's `io.EOF` sentinel).
473///
474/// 1b. **Substring match on the lowercased Display form** against
475///     [`NETWORK_ERROR_NEEDLES`]. Covers the canonical surface plus the
476///     Windows / Rust-stdlib phrasings that bypass the kind check when an
477///     error has been wrapped (e.g. reqwest coercing the inner kind to
478///     `Other` while preserving the OS message text).
479///
480/// Walks `.source()` for both branches — Rust's `Display` impls do NOT
481/// inherit the wrapped error's text the way Go's `err.Error()` does, so a
482/// reqwest "Connection refused" message buried under an anyhow context would
483/// otherwise be invisible to the head-only string.
484pub fn is_network_error(err: &(dyn StdError + 'static)) -> bool {
485    let mut cur: Option<&(dyn StdError + 'static)> = Some(err);
486    while let Some(e) = cur {
487        // 1a. Structural ErrorKind check — robust to platform Display drift
488        //     (Windows's "operation timed out" vs Linux's "Connection refused").
489        if let Some(io_err) = e.downcast_ref::<io::Error>() {
490            match io_err.kind() {
491                io::ErrorKind::UnexpectedEof
492                | io::ErrorKind::TimedOut
493                | io::ErrorKind::ConnectionRefused
494                | io::ErrorKind::ConnectionReset
495                | io::ErrorKind::ConnectionAborted
496                | io::ErrorKind::BrokenPipe => return true,
497                _ => {}
498            }
499            let m = io_err.to_string().to_lowercase();
500            if m == "eof" || m == "unexpected eof" {
501                return true;
502            }
503        }
504
505        // 1b. Substring match on each link's own Display (NOT the full
506        //     chain "{e:#}" form, which would double-count the same text on
507        //     deeper links). Lowercased once per link.
508        let s = e.to_string().to_lowercase();
509        if NETWORK_ERROR_NEEDLES.iter().any(|n| s.contains(n)) {
510            return true;
511        }
512
513        cur = e.source();
514    }
515    false
516}
517
518/// The set of substrings classified as transient.
519///
520/// The first nine entries are the canonical network-error needles
521/// (matching is case-insensitive). The remaining entries cover Windows and
522/// Rust-stdlib phrasings of transient transport failures that surface when
523/// an `io::Error` has been wrapped by a higher layer (reqwest, hyper,
524/// anyhow), losing the original `ErrorKind` classification but preserving
525/// the OS message text. Without these, every publisher running on Windows
526/// fast-failed on the first transient connect blip instead of retrying.
527const NETWORK_ERROR_NEEDLES: &[&str] = &[
528    "connection reset",
529    "network is unreachable",
530    "connection closed",
531    "connection refused",
532    "tls handshake timeout",
533    "i/o timeout",
534    "broken pipe",
535    "timeout awaiting response headers",
536    "context deadline exceeded",
537    // Windows + macOS phrasing of ErrorKind::TimedOut after wrapping.
538    "operation timed out",
539    // Windows ErrorKind::ConnectionAborted phrasing.
540    "the network connection was aborted",
541    // Windows ErrorKind::ConnectionReset phrasing.
542    "an existing connection was forcibly closed",
543    // hyper-util / reqwest DNS-resolution failures wrapped through the
544    // connector. Surfaces as `client error (Connect): dns error: ...` with
545    // a platform-specific resolver tail ("Name or service not known" on
546    // Linux/glibc, "nodename nor servname provided, or not known" on macOS,
547    // "No such host is known" on Windows). The leading "dns error" prefix
548    // is the cross-platform constant.
549    "dns error",
550    // GAI (getaddrinfo) wording across resolvers; covers the Linux
551    // resolver tail above and BSD/macOS phrasing.
552    "failed to lookup address",
553    // Windows resolver tail when DNS-resolution fails.
554    "no such host is known",
555];
556
557/// Classify an error as retriable.
558///
559/// Returns `true` for:
560/// - any [`is_network_error`] match (substring + EOF / UnexpectedEof in the
561///   `source()` chain)
562/// - any error whose chain contains a [`Retriable`] wrapper
563/// - any error whose chain contains an [`HttpError`] with status `>= 500`
564///   or status `429` (Too Many Requests)
565///
566/// Returns `false` for plain errors and 4xx HTTP errors (other than 429) —
567/// those are fast-failed by the retry loop.
568pub fn is_retriable(err: &(dyn StdError + 'static)) -> bool {
569    // 1. Any link in the chain is an explicit Retriable marker.
570    let mut cur: Option<&(dyn StdError + 'static)> = Some(err);
571    while let Some(e) = cur {
572        if e.is::<Retriable>() {
573            return true;
574        }
575        if let Some(http) = e.downcast_ref::<HttpError>()
576            && (http.status >= 500 || http.status == 429)
577        {
578            return true;
579        }
580        cur = e.source();
581    }
582
583    // 2. Network-error substring / EOF chain match.
584    is_network_error(err)
585}
586
587/// Convenience: `None` passes through as `false`. The
588/// `IsRetriable(nil) -> false` semantics.
589pub fn is_retriable_opt(err: Option<&(dyn StdError + 'static)>) -> bool {
590    err.is_some_and(is_retriable)
591}
592
593/// Apply ±20 % pseudo-jitter to `base` using a cheap subsecond-nanos modulo.
594///
595/// Returns a value in `[base * 0.8, base * 1.2)`. No `rand` crate dependency:
596/// `SystemTime::now().subsec_nanos()` provides ~nanosecond entropy that is
597/// sufficient for retry jitter (the goal is spreading out concurrent retriers,
598/// not cryptographic unpredictability).
599///
600/// The ±20 % window is a widely-adopted convention (AWS SDK, GCP client libs).
601/// Jitter only ever widens the sleep by up to 20 %; it never shortens it below
602/// 80 % of the nominal delay, so `Retry-After` honoring is conservative.
603pub fn jitter_duration(base: Duration) -> Duration {
604    let nanos = base.as_nanos() as u64;
605    // 20 % of the nominal duration.
606    let window = nanos / 5;
607    if window == 0 {
608        return base;
609    }
610    // Cheap pseudo-random offset in [0, window * 2) centred on window,
611    // giving a net range of [base - window, base + window). Routed
612    // through `sde::resolve_now()` so jitter collapses to a constant
613    // under `SOURCE_DATE_EPOCH` (no subsec precision) — required for
614    // determinism-harness byte-equality; real jitter is preserved in
615    // prod where the helper falls back to `Utc::now()`.
616    let seed = crate::sde::resolve_now().timestamp_subsec_nanos() as u64;
617    let offset = seed % (window * 2);
618    // Saturating arithmetic so we never panic on extreme values.
619    let jittered = nanos.saturating_sub(window).saturating_add(offset);
620    Duration::from_nanos(jittered)
621}
622
623#[cfg(test)]
624mod tests {
625    use super::*;
626    use std::sync::atomic::{AtomicU32, Ordering};
627
628    fn fast_policy() -> RetryPolicy {
629        RetryPolicy {
630            max_attempts: 4,
631            base_delay: Duration::from_millis(1),
632            max_delay: Duration::from_millis(5),
633        }
634    }
635
636    #[test]
637    fn jitter_returns_base_when_window_rounds_to_zero() {
638        // For any duration under 5ns the ±20 % window (`nanos / 5`) floors to
639        // 0, so jitter is a no-op and the base is returned unchanged — the
640        // early-return guard that avoids a `% 0` panic on tiny delays.
641        for n in 0..5u64 {
642            let base = Duration::from_nanos(n);
643            assert_eq!(
644                jitter_duration(base),
645                base,
646                "sub-5ns base {n} must pass through unjittered"
647            );
648        }
649    }
650
651    #[test]
652    fn jitter_stays_within_plus_minus_twenty_percent() {
653        // The jittered value never leaves [base*0.8, base*1.2) — the documented
654        // window. Uses a duration large enough that `nanos / 5 > 0`.
655        let base = Duration::from_millis(100);
656        let jittered = jitter_duration(base);
657        let lo = base.mul_f64(0.8);
658        let hi = base.mul_f64(1.2);
659        assert!(
660            jittered >= lo && jittered < hi,
661            "jittered {jittered:?} outside [{lo:?}, {hi:?})"
662        );
663    }
664
665    #[test]
666    fn delay_progression_caps_at_max() {
667        let p = RetryPolicy {
668            max_attempts: 10,
669            base_delay: Duration::from_millis(100),
670            max_delay: Duration::from_millis(500),
671        };
672        assert_eq!(p.delay_for(2), Duration::from_millis(100));
673        assert_eq!(p.delay_for(3), Duration::from_millis(200));
674        assert_eq!(p.delay_for(4), Duration::from_millis(400));
675        assert_eq!(p.delay_for(5), Duration::from_millis(500)); // capped
676        assert_eq!(p.delay_for(8), Duration::from_millis(500)); // capped
677    }
678
679    #[test]
680    fn sync_succeeds_on_first_attempt() {
681        let calls = AtomicU32::new(0);
682        let result: Result<&str, ()> = retry_sync(&fast_policy(), |_| {
683            calls.fetch_add(1, Ordering::SeqCst);
684            Ok("ok")
685        });
686        assert_eq!(result, Ok("ok"));
687        assert_eq!(calls.load(Ordering::SeqCst), 1);
688    }
689
690    #[test]
691    fn sync_retries_until_success() {
692        let calls = AtomicU32::new(0);
693        let result: Result<u32, &str> = retry_sync(&fast_policy(), |attempt| {
694            calls.fetch_add(1, Ordering::SeqCst);
695            if attempt < 3 {
696                Err(ControlFlow::Continue("transient"))
697            } else {
698                Ok(attempt)
699            }
700        });
701        assert_eq!(result, Ok(3));
702        assert_eq!(calls.load(Ordering::SeqCst), 3);
703    }
704
705    #[test]
706    fn sync_break_stops_immediately() {
707        let calls = AtomicU32::new(0);
708        let result: Result<(), &str> = retry_sync(&fast_policy(), |_| {
709            calls.fetch_add(1, Ordering::SeqCst);
710            Err(ControlFlow::Break("fatal"))
711        });
712        assert_eq!(result, Err("fatal"));
713        assert_eq!(calls.load(Ordering::SeqCst), 1);
714    }
715
716    #[test]
717    fn sync_returns_last_error_after_exhaustion() {
718        let calls = AtomicU32::new(0);
719        let result: Result<(), String> = retry_sync(&fast_policy(), |attempt| {
720            calls.fetch_add(1, Ordering::SeqCst);
721            Err(ControlFlow::Continue(format!("fail {attempt}")))
722        });
723        assert_eq!(result, Err("fail 4".to_string()));
724        assert_eq!(calls.load(Ordering::SeqCst), 4);
725    }
726
727    #[tokio::test]
728    async fn async_retries_until_success() {
729        let calls = std::sync::Arc::new(AtomicU32::new(0));
730        let calls_inner = calls.clone();
731        let result: Result<u32, &str> = retry_async(&fast_policy(), move |attempt| {
732            let c = calls_inner.clone();
733            async move {
734                c.fetch_add(1, Ordering::SeqCst);
735                if attempt < 2 {
736                    Err(ControlFlow::Continue("transient"))
737                } else {
738                    Ok(attempt)
739                }
740            }
741        })
742        .await;
743        assert_eq!(result, Ok(2));
744        assert_eq!(calls.load(Ordering::SeqCst), 2);
745    }
746
747    // -----------------------------------------------------------------------
748    // is_network_error / is_retriable / HttpError / Retriable
749    //
750    // Network-error classification test cases.
751    // -----------------------------------------------------------------------
752
753    /// Plain string error wrapper used in classification tests.
754    #[derive(Debug)]
755    struct StrErr(&'static str);
756    impl fmt::Display for StrErr {
757        fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
758            f.write_str(self.0)
759        }
760    }
761    impl StdError for StrErr {}
762
763    #[derive(Debug)]
764    struct OwnedErr(String);
765    impl fmt::Display for OwnedErr {
766        fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
767            f.write_str(&self.0)
768        }
769    }
770    impl StdError for OwnedErr {}
771
772    #[test]
773    fn network_error_substrings_match() {
774        for s in [
775            "connection reset by peer",
776            "network is unreachable",
777            "connection closed unexpectedly",
778            "connection refused",
779            "tls handshake timeout",
780            "i/o timeout",
781            "CONNECTION RESET",
782            "TLS Handshake Timeout",
783            "write: broken pipe",
784            "net/http: timeout awaiting response headers",
785            "context deadline exceeded",
786            // DNS-resolution failures across platforms (hyper-util connector
787            // surfaces these via reqwest as `client error (Connect): dns
788            // error: <platform tail>`). Pin every tail we know about so a
789            // cross-platform CI failure cannot reintroduce the gap.
790            "client error (Connect): dns error: failed to lookup address information: Name or service not known",
791            "dns error: nodename nor servname provided, or not known",
792            "dns error: No such host is known. (os error 11001)",
793        ] {
794            let e = OwnedErr(s.to_string());
795            assert!(is_network_error(&e), "expected network error: {s:?}");
796        }
797    }
798
799    #[test]
800    fn network_error_io_eof_kinds() {
801        let e = io::Error::from(io::ErrorKind::UnexpectedEof);
802        assert!(is_network_error(&e));
803
804        // A custom-kind io::Error whose Display is "EOF" (rustls / hyper convention).
805        let e2 = io::Error::other("EOF");
806        assert!(is_network_error(&e2));
807    }
808
809    // Windows-CI regression: connect() on Windows surfaces transient failures
810    // as io::Error { kind: TimedOut, message: "operation timed out" }, neither
811    // of which matched the original EOF-only kind check or the
812    // needle list. Same shape for the connection-* kinds across platforms —
813    // pin each branch.
814
815    #[test]
816    fn is_network_error_classifies_io_timedout() {
817        let e = io::Error::from(io::ErrorKind::TimedOut);
818        assert!(is_network_error(&e));
819        assert!(is_retriable(&e));
820    }
821
822    #[test]
823    fn is_network_error_classifies_io_connection_refused() {
824        let e = io::Error::from(io::ErrorKind::ConnectionRefused);
825        assert!(is_network_error(&e));
826        assert!(is_retriable(&e));
827    }
828
829    #[test]
830    fn is_network_error_classifies_io_connection_reset() {
831        let e = io::Error::from(io::ErrorKind::ConnectionReset);
832        assert!(is_network_error(&e));
833        assert!(is_retriable(&e));
834    }
835
836    #[test]
837    fn is_network_error_classifies_io_connection_aborted() {
838        let e = io::Error::from(io::ErrorKind::ConnectionAborted);
839        assert!(is_network_error(&e));
840        assert!(is_retriable(&e));
841    }
842
843    #[test]
844    fn is_network_error_classifies_io_broken_pipe() {
845        let e = io::Error::from(io::ErrorKind::BrokenPipe);
846        assert!(is_network_error(&e));
847        assert!(is_retriable(&e));
848    }
849
850    #[test]
851    fn is_network_error_classifies_operation_timed_out_substring() {
852        // Simulate a reqwest- or hyper-wrapped error whose io::ErrorKind has
853        // been coerced to Other but whose Display still carries the Windows /
854        // macOS TimedOut phrasing. Both the substring path and the
855        // ErrorKind path must classify this independently.
856        let other_kind = io::Error::other("operation timed out");
857        assert!(is_network_error(&other_kind));
858        assert!(is_retriable(&other_kind));
859
860        let kind_only = io::Error::from(io::ErrorKind::TimedOut);
861        assert!(is_network_error(&kind_only));
862        assert!(is_retriable(&kind_only));
863    }
864
865    #[test]
866    fn network_error_wrapped_unexpected_eof() {
867        // Wrap an UnexpectedEof in an outer error so chain-walking is exercised.
868        #[derive(Debug)]
869        struct Wrap(io::Error);
870        impl fmt::Display for Wrap {
871            fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
872                write!(f, "read failed")
873            }
874        }
875        impl StdError for Wrap {
876            fn source(&self) -> Option<&(dyn StdError + 'static)> {
877                Some(&self.0)
878            }
879        }
880        let inner = io::Error::from(io::ErrorKind::UnexpectedEof);
881        let outer = Wrap(inner);
882        assert!(is_network_error(&outer));
883    }
884
885    #[test]
886    fn network_error_non_network_strings_reject() {
887        for s in [
888            "file not found",
889            "permission denied",
890            "dial tcp: lookup example.com: no such host",
891            "",
892        ] {
893            let e = OwnedErr(s.to_string());
894            assert!(!is_network_error(&e), "expected NOT network error: {s:?}");
895        }
896    }
897
898    #[test]
899    fn retriable_opt_nil_passthrough() {
900        assert!(!is_retriable_opt(None));
901    }
902
903    #[test]
904    fn http_error_500_retriable() {
905        let e = HttpError::new(StrErr("internal server error"), 500);
906        assert!(is_retriable(&e));
907    }
908
909    #[test]
910    fn http_error_502_503_retriable() {
911        for s in [502u16, 503] {
912            let e = HttpError::new(StrErr("bad gateway"), s);
913            assert!(is_retriable(&e), "status {s} should be retriable");
914        }
915    }
916
917    #[test]
918    fn http_error_429_retriable() {
919        let e = HttpError::new(StrErr("rate limited"), 429);
920        assert!(is_retriable(&e));
921    }
922
923    #[test]
924    fn http_error_4xx_not_retriable() {
925        for s in [400u16, 401, 403, 404, 422] {
926            let e = HttpError::new(StrErr("client err"), s);
927            assert!(!is_retriable(&e), "status {s} should NOT be retriable");
928        }
929    }
930
931    #[test]
932    fn http_error_zero_status_routes_via_message() {
933        // Status 0 == network-level failure with no response. Retriability
934        // falls back to the network-error substring matcher on the inner.
935        let net = HttpError::new(StrErr("connection reset"), 0);
936        assert!(is_retriable(&net));
937
938        let non_net = HttpError::new(StrErr("dial failed"), 0);
939        assert!(!is_retriable(&non_net));
940    }
941
942    #[test]
943    fn http_error_unwrap_chain_visible() {
944        let inner = StrErr("inner");
945        let e = HttpError::new(inner, 503);
946        assert!(e.source().is_some());
947    }
948
949    #[test]
950    fn from_response_nil_resp_yields_status_zero() {
951        // No response means status 0.
952        // Use a concrete `io::Error` since `reqwest::Error` cannot be
953        // synthesised in tests; the API accepts any `E: StdError + Send + Sync`.
954        let inner = io::Error::other("connect: dial tcp");
955        let e = HttpError::from_response(inner, None);
956        assert_eq!(e.status, 0);
957    }
958
959    #[test]
960    fn from_response_unwrap_chain_visible() {
961        // The inner error must remain reachable via the StdError chain so
962        // is_retriable's network-error matcher can still see the cause.
963        let inner = io::Error::other("connection reset by peer");
964        let e = HttpError::from_response(inner, None);
965        assert!(
966            e.source().is_some(),
967            "inner error must be reachable via source()"
968        );
969        // And classification must walk through to the network-error matcher.
970        assert!(is_retriable(&e));
971    }
972
973    #[test]
974    fn retriable_wrapper_is_retriable() {
975        let e = Retriable::new(StrErr("retry me"));
976        assert!(is_retriable(&e));
977    }
978
979    #[test]
980    fn retriable_wrapper_overrides_4xx() {
981        // A 422 wrapped in Retriable is still retriable.
982        let inner = HttpError::new(StrErr("exists"), 422);
983        let outer = Retriable::new(inner);
984        assert!(is_retriable(&outer));
985    }
986
987    #[test]
988    fn retriable_wrapper_unwrap_chain_visible() {
989        let inner = StrErr("inner");
990        let e = Retriable::new(inner);
991        assert!(e.source().is_some());
992    }
993
994    #[test]
995    fn plain_error_not_retriable() {
996        let e = StrErr("something");
997        assert!(!is_retriable(&e));
998    }
999
1000    #[test]
1001    fn anyhow_error_threadable() {
1002        // Ensure is_retriable works through anyhow::Error's deref-to-dyn path
1003        // (which is the canonical caller form across the codebase).
1004        let e: anyhow::Error = anyhow::anyhow!("connection refused");
1005        assert!(is_retriable(e.as_ref()));
1006
1007        let e2: anyhow::Error = anyhow::anyhow!("permission denied");
1008        assert!(!is_retriable(e2.as_ref()));
1009    }
1010
1011    #[test]
1012    fn is_retriable_chain_walks_to_http_error() {
1013        // An anyhow::Error wrapping a concrete HttpError must be classified
1014        // by walking source(), not by Display alone — the message "outer"
1015        // gives no hint, the 503 status does.
1016        let inner = HttpError::new(StrErr("bad gateway"), 503);
1017        let wrapped: anyhow::Error = anyhow::Error::new(inner).context("publish failed");
1018        assert!(is_retriable(wrapped.as_ref()));
1019    }
1020
1021    // ----- as_ref vs root_cause drift guard ---------------------------------
1022    //
1023    // Every consumer of `retry_http_blocking` (artifactory, cloudsmith, the
1024    // future stage-blob upload paths) classifies via `is_retriable(err.as_ref())`.
1025    // A subtle but catastrophic regression is to "simplify" that to
1026    // `is_retriable(err.root_cause())`, which walks past the HttpError wrapper
1027    // to the leaf io::Error — at which point 5xx misclassifies as fast-fail
1028    // (the leaf has no status code), and the entire retry policy becomes a
1029    // no-op. These tests pin the distinction once at the helper's home.
1030
1031    #[test]
1032    fn classifier_5xx_via_anyhow_chain_uses_as_ref() {
1033        let wrapped: anyhow::Error =
1034            anyhow::Error::new(HttpError::new(std::io::Error::other("503"), 503))
1035                .context("publish");
1036        assert!(
1037            is_retriable(wrapped.as_ref()),
1038            "5xx HttpError reached via as_ref() must classify retriable"
1039        );
1040    }
1041
1042    #[test]
1043    fn classifier_root_cause_walks_past_http_error_drift_guard() {
1044        // Drift guard: root_cause() unwraps to the leaf io::Error, which
1045        // has no status. If a future caller ever swaps as_ref → root_cause
1046        // they'll regress 5xx retry handling. This assertion locks the
1047        // distinction.
1048        let wrapped: anyhow::Error =
1049            anyhow::Error::new(HttpError::new(std::io::Error::other("503"), 503))
1050                .context("publish");
1051        assert!(
1052            !is_retriable(wrapped.root_cause()),
1053            "root_cause() walks past HttpError; 5xx must NOT be detected via the leaf"
1054        );
1055    }
1056
1057    #[test]
1058    fn classifier_429_via_anyhow_chain_uses_as_ref() {
1059        // Symmetry with the 5xx case: 429 is the other retriable status
1060        // class and must also stay reachable via as_ref().
1061        let wrapped: anyhow::Error =
1062            anyhow::Error::new(HttpError::new(std::io::Error::other("429"), 429))
1063                .context("publish");
1064        assert!(is_retriable(wrapped.as_ref()));
1065        assert!(!is_retriable(wrapped.root_cause()));
1066    }
1067
1068    // ----- retry_http_blocking behavioural tests ---------------------------
1069    //
1070    // `reqwest::Error` has no public constructor, so the transport-error
1071    // branch is exercised indirectly via per-publisher integration tests
1072    // (which mock at the network layer). The unit tests here drive a tiny
1073    // hand-rolled TCP server so we can exercise the success / non-success
1074    // status branches with a real reqwest::blocking::Client end-to-end.
1075
1076    use crate::test_helpers::responder::spawn_oneshot_http_responder;
1077
1078    #[test]
1079    fn retry_http_blocking_success_returns_first_attempt() {
1080        let (addr, calls) =
1081            spawn_oneshot_http_responder(vec!["HTTP/1.1 200 OK\r\nContent-Length: 2\r\n\r\nok"]);
1082        let client = reqwest::blocking::Client::builder()
1083            .timeout(Duration::from_secs(2))
1084            .build()
1085            .expect("client");
1086        let policy = RetryPolicy {
1087            max_attempts: 3,
1088            base_delay: Duration::from_millis(1),
1089            max_delay: Duration::from_millis(2),
1090        };
1091        let result = retry_http_blocking(
1092            "test",
1093            &policy,
1094            SuccessClass::Strict,
1095            |_| client.get(format!("http://{addr}/")).send(),
1096            |_, _| String::from("should not be called on success"),
1097        );
1098        let (status, body) = result.expect("success");
1099        assert_eq!(status.as_u16(), 200);
1100        assert_eq!(body, "ok");
1101        assert_eq!(calls.load(Ordering::SeqCst), 1, "single attempt");
1102    }
1103
1104    #[test]
1105    fn retry_http_blocking_retries_5xx_then_succeeds() {
1106        let (addr, calls) = spawn_oneshot_http_responder(vec![
1107            "HTTP/1.1 503 Service Unavailable\r\nContent-Length: 0\r\n\r\n",
1108            "HTTP/1.1 200 OK\r\nContent-Length: 2\r\n\r\nok",
1109        ]);
1110        let client = reqwest::blocking::Client::builder()
1111            .timeout(Duration::from_secs(2))
1112            .build()
1113            .expect("client");
1114        let policy = RetryPolicy {
1115            max_attempts: 3,
1116            base_delay: Duration::from_millis(1),
1117            max_delay: Duration::from_millis(2),
1118        };
1119        let result = retry_http_blocking(
1120            "test",
1121            &policy,
1122            SuccessClass::Strict,
1123            |_| client.get(format!("http://{addr}/")).send(),
1124            |status, body| format!("{status}: {body}"),
1125        );
1126        let (status, _) = result.expect("eventually succeeds");
1127        assert_eq!(status.as_u16(), 200);
1128        assert_eq!(calls.load(Ordering::SeqCst), 2, "one retry then success");
1129    }
1130
1131    #[test]
1132    fn retry_http_blocking_4xx_fast_fails_no_retry() {
1133        let (addr, calls) = spawn_oneshot_http_responder(vec![
1134            "HTTP/1.1 404 Not Found\r\nContent-Length: 9\r\n\r\nnot found",
1135        ]);
1136        let client = reqwest::blocking::Client::builder()
1137            .timeout(Duration::from_secs(2))
1138            .build()
1139            .expect("client");
1140        let policy = RetryPolicy {
1141            max_attempts: 5,
1142            base_delay: Duration::from_millis(1),
1143            max_delay: Duration::from_millis(2),
1144        };
1145        let result = retry_http_blocking(
1146            "myscope",
1147            &policy,
1148            SuccessClass::Strict,
1149            |_| client.get(format!("http://{addr}/")).send(),
1150            |status, body| format!("custom error: {status} body={body}"),
1151        );
1152        let err = result.expect_err("4xx must fast-fail");
1153        let chain = format!("{err:#}");
1154        assert!(
1155            chain.contains("custom error"),
1156            "error formatter must be invoked on non-success; got: {chain}"
1157        );
1158        assert!(chain.contains("404"), "status must be in chain: {chain}");
1159        assert_eq!(
1160            calls.load(Ordering::SeqCst),
1161            1,
1162            "4xx must NOT retry (only one connection accepted)"
1163        );
1164    }
1165
1166    #[test]
1167    fn retry_http_blocking_redirect_class_alters_success_predicate() {
1168        let (addr, _calls) = spawn_oneshot_http_responder(vec![
1169            "HTTP/1.1 307 Temporary Redirect\r\nLocation: /next\r\nContent-Length: 0\r\n\r\n",
1170        ]);
1171        let client = reqwest::blocking::Client::builder()
1172            .timeout(Duration::from_secs(2))
1173            // Disable redirect-following so the 307 surfaces to our helper.
1174            .redirect(reqwest::redirect::Policy::none())
1175            .build()
1176            .expect("client");
1177        let policy = RetryPolicy {
1178            max_attempts: 3,
1179            base_delay: Duration::from_millis(1),
1180            max_delay: Duration::from_millis(2),
1181        };
1182        let result = retry_http_blocking(
1183            "test",
1184            &policy,
1185            SuccessClass::AllowRedirects,
1186            |_| client.get(format!("http://{addr}/")).send(),
1187            |_, _| String::from("should not be called on 3xx with AllowRedirects"),
1188        );
1189        let (status, _) = result.expect("3xx is success under AllowRedirects");
1190        assert_eq!(status.as_u16(), 307);
1191    }
1192
1193    // ----- retry_http_async behavioural tests ------------------------------
1194    //
1195    // Mirrors the blocking suite but drives an async reqwest::Client against
1196    // the same hand-rolled TCP responder (running on a worker thread, so the
1197    // tokio reactor is free to drive the client futures). The transport-error
1198    // arm (Err(reqwest::Error)) is exercised by
1199    // `retry_http_{async,blocking}_transport_error_retries_then_fails` below,
1200    // which bind an ephemeral port, drop the listener, then point the client
1201    // at the now-defunct address.
1202
1203    #[tokio::test]
1204    async fn retry_http_async_success_returns_first_attempt() {
1205        let (addr, calls) =
1206            spawn_oneshot_http_responder(vec!["HTTP/1.1 200 OK\r\nContent-Length: 2\r\n\r\nok"]);
1207        let client = reqwest::Client::builder()
1208            .timeout(Duration::from_secs(2))
1209            .build()
1210            .expect("client");
1211        let policy = RetryPolicy {
1212            max_attempts: 3,
1213            base_delay: Duration::from_millis(1),
1214            max_delay: Duration::from_millis(2),
1215        };
1216        let result = retry_http_async(
1217            "test",
1218            &policy,
1219            SuccessClass::Strict,
1220            |_| client.get(format!("http://{addr}/")).send(),
1221            |_, _| String::from("should not be called on success"),
1222        )
1223        .await;
1224        let resp = result.expect("success");
1225        assert_eq!(resp.status().as_u16(), 200);
1226        let body = resp.text().await.expect("body");
1227        assert_eq!(body, "ok");
1228        assert_eq!(calls.load(Ordering::SeqCst), 1, "single attempt");
1229    }
1230
1231    #[tokio::test]
1232    async fn retry_http_async_retries_5xx_then_succeeds() {
1233        let (addr, calls) = spawn_oneshot_http_responder(vec![
1234            "HTTP/1.1 503 Service Unavailable\r\nContent-Length: 0\r\n\r\n",
1235            "HTTP/1.1 200 OK\r\nContent-Length: 2\r\n\r\nok",
1236        ]);
1237        let client = reqwest::Client::builder()
1238            .timeout(Duration::from_secs(2))
1239            .build()
1240            .expect("client");
1241        let policy = RetryPolicy {
1242            max_attempts: 3,
1243            base_delay: Duration::from_millis(1),
1244            max_delay: Duration::from_millis(2),
1245        };
1246        let result = retry_http_async(
1247            "test",
1248            &policy,
1249            SuccessClass::Strict,
1250            |_| client.get(format!("http://{addr}/")).send(),
1251            |status, body| format!("{status}: {body}"),
1252        )
1253        .await;
1254        let resp = result.expect("eventually succeeds");
1255        assert_eq!(resp.status().as_u16(), 200);
1256        assert_eq!(calls.load(Ordering::SeqCst), 2, "one retry then success");
1257    }
1258
1259    #[tokio::test]
1260    async fn retry_http_async_4xx_fast_fails_no_retry() {
1261        let (addr, calls) = spawn_oneshot_http_responder(vec![
1262            "HTTP/1.1 404 Not Found\r\nContent-Length: 9\r\n\r\nnot found",
1263        ]);
1264        let client = reqwest::Client::builder()
1265            .timeout(Duration::from_secs(2))
1266            .build()
1267            .expect("client");
1268        let policy = RetryPolicy {
1269            max_attempts: 5,
1270            base_delay: Duration::from_millis(1),
1271            max_delay: Duration::from_millis(2),
1272        };
1273        let result = retry_http_async(
1274            "myscope",
1275            &policy,
1276            SuccessClass::Strict,
1277            |_| client.get(format!("http://{addr}/")).send(),
1278            |status, body| format!("custom error: {status} body={body}"),
1279        )
1280        .await;
1281        let err = result.expect_err("4xx must fast-fail");
1282        let chain = format!("{err:#}");
1283        assert!(
1284            chain.contains("custom error"),
1285            "error formatter must be invoked on non-success; got: {chain}"
1286        );
1287        assert!(chain.contains("404"), "status must be in chain: {chain}");
1288        assert_eq!(
1289            calls.load(Ordering::SeqCst),
1290            1,
1291            "4xx must NOT retry (only one connection accepted)"
1292        );
1293    }
1294
1295    #[tokio::test]
1296    async fn retry_http_async_429_retries_then_succeeds() {
1297        // 429 (Too Many Requests) is the second retriable class alongside
1298        // 5xx. Ensures the helper doesn't accidentally fast-fail on rate
1299        // limits — a regression here would defeat the whole point of
1300        // wiring retry into release publishers.
1301        let (addr, calls) = spawn_oneshot_http_responder(vec![
1302            "HTTP/1.1 429 Too Many Requests\r\nContent-Length: 0\r\n\r\n",
1303            "HTTP/1.1 200 OK\r\nContent-Length: 2\r\n\r\nok",
1304        ]);
1305        let client = reqwest::Client::builder()
1306            .timeout(Duration::from_secs(2))
1307            .build()
1308            .expect("client");
1309        let policy = RetryPolicy {
1310            max_attempts: 3,
1311            base_delay: Duration::from_millis(1),
1312            max_delay: Duration::from_millis(2),
1313        };
1314        let result = retry_http_async(
1315            "test",
1316            &policy,
1317            SuccessClass::Strict,
1318            |_| client.get(format!("http://{addr}/")).send(),
1319            |status, body| format!("{status}: {body}"),
1320        )
1321        .await;
1322        let resp = result.expect("429 retried then success");
1323        assert_eq!(resp.status().as_u16(), 200);
1324        assert_eq!(calls.load(Ordering::SeqCst), 2);
1325    }
1326
1327    // ----- transport-error behavioural tests -------------------------------
1328    //
1329    // The transport-error arm (Err(reqwest::Error): DNS failure, connection
1330    // refused, EOF, TLS handshake failure, etc.) is the single most
1331    // reviewer-load-bearing path: it is the one the helper claims to retry
1332    // and that publishers rely on for resilience against transient network
1333    // blips. The pattern below dials the RFC 2606-reserved `.invalid` TLD,
1334    // which is guaranteed never to resolve, so every attempt fails at the
1335    // DNS-resolution stage in a few milliseconds on Linux, macOS, and
1336    // Windows alike.
1337    //
1338    // We verify:
1339    //   1. the helper retries (attempt counter > 1)
1340    //   2. eventually surfaces an Err with the configured label in the chain
1341    // The outer attempt counter is incremented inside the closure, so it
1342    // sees one bump per attempt regardless of the underlying transport
1343    // outcome.
1344    //
1345    // RFC 2606 (https://datatracker.ietf.org/doc/html/rfc2606) reserves the
1346    // `.invalid` TLD precisely for this purpose; using it removes any
1347    // dependence on OS-level TCP semantics (Windows' kernel can retransmit
1348    // SYN against an unbound loopback port until the connect timeout fires
1349    // rather than refusing synchronously like Linux + macOS do).
1350    const TRANSPORT_FAIL_URL: &str = "http://nonexistent.invalid/";
1351
1352    #[test]
1353    fn retry_http_blocking_transport_error_retries_then_fails() {
1354        let attempts = std::sync::Arc::new(AtomicU32::new(0));
1355        let attempts_inner = attempts.clone();
1356        let client = reqwest::blocking::Client::builder()
1357            .timeout(Duration::from_millis(500))
1358            .build()
1359            .expect("client");
1360        let policy = RetryPolicy {
1361            max_attempts: 3,
1362            base_delay: Duration::from_millis(1),
1363            max_delay: Duration::from_millis(2),
1364        };
1365        let result = retry_http_blocking(
1366            "test-transport",
1367            &policy,
1368            SuccessClass::Strict,
1369            |_| {
1370                attempts_inner.fetch_add(1, Ordering::SeqCst);
1371                client.get(TRANSPORT_FAIL_URL).send()
1372            },
1373            |_, _| String::from("non-success branch should not be reached"),
1374        );
1375        let err = result.expect_err("transport error must surface as Err");
1376        let chain = format!("{err:#}");
1377        assert!(
1378            attempts.load(Ordering::SeqCst) > 1,
1379            "transport error must be retried; got {} attempts; chain={chain}",
1380            attempts.load(Ordering::SeqCst)
1381        );
1382        assert!(
1383            chain.contains("test-transport"),
1384            "label must surface in error chain; got: {chain}"
1385        );
1386    }
1387
1388    #[tokio::test]
1389    async fn retry_http_async_transport_error_retries_then_fails() {
1390        let attempts = std::sync::Arc::new(AtomicU32::new(0));
1391        let attempts_inner = attempts.clone();
1392        let client = reqwest::Client::builder()
1393            .timeout(Duration::from_millis(500))
1394            .build()
1395            .expect("client");
1396        let policy = RetryPolicy {
1397            max_attempts: 3,
1398            base_delay: Duration::from_millis(1),
1399            max_delay: Duration::from_millis(2),
1400        };
1401        let result = retry_http_async(
1402            "test-transport-async",
1403            &policy,
1404            SuccessClass::Strict,
1405            |_| {
1406                attempts_inner.fetch_add(1, Ordering::SeqCst);
1407                client.get(TRANSPORT_FAIL_URL).send()
1408            },
1409            |_, _| String::from("non-success branch should not be reached"),
1410        )
1411        .await;
1412        let err = result.expect_err("transport error must surface as Err");
1413        assert!(
1414            attempts.load(Ordering::SeqCst) > 1,
1415            "transport error must be retried; got {} attempts",
1416            attempts.load(Ordering::SeqCst)
1417        );
1418        let chain = format!("{err:#}");
1419        assert!(
1420            chain.contains("test-transport-async"),
1421            "label must surface in error chain; got: {chain}"
1422        );
1423    }
1424}