Skip to main content

anodizer_core/retry/
error.rs

1use std::error::Error as StdError;
2use std::fmt;
3use std::io;
4use std::time::Duration;
5
6// ---------------------------------------------------------------------------
7// Retriable-error classification
8// ---------------------------------------------------------------------------
9
10/// Carries an HTTP status code alongside the original error so
11/// [`is_retriable`] can route 5xx / 429 to retry and 4xx to fast-fail.
12///
13/// HTTP error carrying status + message. Construct via [`HttpError::new`]
14/// (status-only) or wrap an existing `reqwest::Response` via
15/// [`HttpError::from_response`].
16///
17/// A `status` of `0` denotes a network-level failure where no response was
18/// ever received (the no-response branch). Network-level failures
19/// are still classified via the inner error's message, so wrapping them in
20/// `HttpError { status: 0, .. }` does not lose retriability information.
21#[derive(Debug)]
22pub struct HttpError {
23    /// The wrapped error (transport, decode, or status-derived message).
24    /// Reachable via the [`StdError::source`] trait method (not directly).
25    source: Box<dyn StdError + Send + Sync + 'static>,
26    /// HTTP status code; `0` for transport-level failures.
27    pub status: u16,
28}
29
30impl HttpError {
31    /// Wrap an error with a status code. `0` denotes a network-level failure
32    /// (no response received).
33    pub fn new<E>(source: E, status: u16) -> Self
34    where
35        E: StdError + Send + Sync + 'static,
36    {
37        Self {
38            source: Box::new(source),
39            status,
40        }
41    }
42
43    /// Wrap a transport-layer error with the status code from the (possibly
44    /// missing) response.
45    /// `None` resp yields status `0` (network-level failure).
46    pub fn from_response<E>(err: E, resp: Option<&reqwest::Response>) -> Self
47    where
48        E: StdError + Send + Sync + 'static,
49    {
50        Self::new(err, resp.map(|r| r.status().as_u16()).unwrap_or(0))
51    }
52}
53
54/// Extract the upstream HTTP status from an [`anyhow::Error`] chain produced by
55/// [`retry_http_blocking`] / [`retry_http_async`].
56///
57/// Returns `0` when no [`HttpError`] is present in the chain — a transport-level
58/// failure that never received a response, or a non-HTTP error.
59pub fn http_status(err: &anyhow::Error) -> u16 {
60    err.chain()
61        .find_map(|e| e.downcast_ref::<HttpError>().map(|h| h.status))
62        .unwrap_or(0)
63}
64
65impl fmt::Display for HttpError {
66    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
67        // Defer to the inner error so messages stay focused on the cause.
68        // Delegate to the inner error message.
69        fmt::Display::fmt(&self.source, f)
70    }
71}
72
73impl StdError for HttpError {
74    fn source(&self) -> Option<&(dyn StdError + 'static)> {
75        Some(&*self.source)
76    }
77}
78
79/// Marker error wrapping any inner error so [`is_retriable`] returns `true`
80/// regardless of class — useful when a
81/// caller knows the failure is transient (e.g. an idempotent registry write
82/// returning 422 because of a transient race condition) and wants the retry
83/// loop to ignore the usual 4xx fast-fail.
84#[derive(Debug)]
85pub struct Retriable(Box<dyn StdError + Send + Sync + 'static>);
86
87impl Retriable {
88    /// Wrap any error so [`is_retriable`] returns `true` regardless of class.
89    /// Use this when a caller knows a 4xx is transient (e.g. a 422 from an
90    /// idempotent registry write losing a race) and wants to override the
91    /// usual fast-fail. For `Option<E>` inputs, see [`is_retriable_opt`] —
92    /// this constructor itself is non-nullable.
93    pub fn new<E>(source: E) -> Self
94    where
95        E: StdError + Send + Sync + 'static,
96    {
97        Self(Box::new(source))
98    }
99}
100
101impl fmt::Display for Retriable {
102    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
103        fmt::Display::fmt(&self.0, f)
104    }
105}
106
107impl StdError for Retriable {
108    fn source(&self) -> Option<&(dyn StdError + 'static)> {
109        Some(&*self.0)
110    }
111}
112
113/// Returns `true` if the message looks like a transient network-layer failure.
114///
115/// Network-error classification, extended for Rust /
116/// Windows. Each link in the error chain is checked two ways:
117///
118/// 1a. **Structural [`io::ErrorKind`] check** via `downcast_ref::<io::Error>()`.
119///     Treats `UnexpectedEof`, `TimedOut`, `ConnectionRefused`,
120///     `ConnectionReset`, `ConnectionAborted`, and `BrokenPipe` as transient.
121///     The OS-classified `ErrorKind` is robust where Display text is not:
122///     Linux's connect-refused says `"Connection refused"` but Windows
123///     surfaces a transient connect failure as
124///     `io::Error { kind: TimedOut, message: "operation timed out" }`, and
125///     a Windows-reset reads `"An existing connection was forcibly closed"`.
126///     Matching `kind()` catches all of them regardless of phrasing. Also
127///     recognises any `io::Error` whose Display form is `"EOF"` /
128///     `"unexpected eof"` (rustls / hyper convention; Rust has no
129///     equivalent of Go's `io.EOF` sentinel).
130///
131/// 1b. **Substring match on the lowercased Display form** against
132///     [`NETWORK_ERROR_NEEDLES`]. Covers the canonical surface plus the
133///     Windows / Rust-stdlib phrasings that bypass the kind check when an
134///     error has been wrapped (e.g. reqwest coercing the inner kind to
135///     `Other` while preserving the OS message text).
136///
137/// Walks `.source()` for both branches — Rust's `Display` impls do NOT
138/// inherit the wrapped error's text the way Go's `err.Error()` does, so a
139/// reqwest "Connection refused" message buried under an anyhow context would
140/// otherwise be invisible to the head-only string.
141pub fn is_network_error(err: &(dyn StdError + 'static)) -> bool {
142    let mut cur: Option<&(dyn StdError + 'static)> = Some(err);
143    while let Some(e) = cur {
144        // 1a. Structural ErrorKind check — robust to platform Display drift
145        //     (Windows's "operation timed out" vs Linux's "Connection refused").
146        if let Some(io_err) = e.downcast_ref::<io::Error>() {
147            match io_err.kind() {
148                io::ErrorKind::UnexpectedEof
149                | io::ErrorKind::TimedOut
150                | io::ErrorKind::ConnectionRefused
151                | io::ErrorKind::ConnectionReset
152                | io::ErrorKind::ConnectionAborted
153                | io::ErrorKind::BrokenPipe => return true,
154                _ => {}
155            }
156            let m = io_err.to_string().to_lowercase();
157            if m == "eof" || m == "unexpected eof" {
158                return true;
159            }
160        }
161
162        // 1b. Substring match on each link's own Display (NOT the full
163        //     chain "{e:#}" form, which would double-count the same text on
164        //     deeper links). Lowercased once per link.
165        let s = e.to_string().to_lowercase();
166        if NETWORK_ERROR_NEEDLES.iter().any(|n| s.contains(n)) {
167            return true;
168        }
169
170        cur = e.source();
171    }
172    false
173}
174
175/// The set of substrings classified as transient.
176///
177/// The first nine entries are the canonical network-error needles
178/// (matching is case-insensitive). The remaining entries cover Windows and
179/// Rust-stdlib phrasings of transient transport failures that surface when
180/// an `io::Error` has been wrapped by a higher layer (reqwest, hyper,
181/// anyhow), losing the original `ErrorKind` classification but preserving
182/// the OS message text. Without these, every publisher running on Windows
183/// fast-failed on the first transient connect blip instead of retrying.
184const NETWORK_ERROR_NEEDLES: &[&str] = &[
185    "connection reset",
186    "network is unreachable",
187    "connection closed",
188    "connection refused",
189    "tls handshake timeout",
190    "i/o timeout",
191    "broken pipe",
192    "timeout awaiting response headers",
193    "context deadline exceeded",
194    // Windows + macOS phrasing of ErrorKind::TimedOut after wrapping.
195    "operation timed out",
196    // Windows ErrorKind::ConnectionAborted phrasing.
197    "the network connection was aborted",
198    // Windows ErrorKind::ConnectionReset phrasing.
199    "an existing connection was forcibly closed",
200    // hyper-util / reqwest DNS-resolution failures wrapped through the
201    // connector. Surfaces as `client error (Connect): dns error: ...` with
202    // a platform-specific resolver tail ("Name or service not known" on
203    // Linux/glibc, "nodename nor servname provided, or not known" on macOS,
204    // "No such host is known" on Windows). The leading "dns error" prefix
205    // is the cross-platform constant.
206    "dns error",
207    // GAI (getaddrinfo) wording across resolvers; covers the Linux
208    // resolver tail above and BSD/macOS phrasing.
209    "failed to lookup address",
210    // Windows resolver tail when DNS-resolution fails.
211    "no such host is known",
212];
213
214/// Classify an error as retriable.
215///
216/// Returns `true` for:
217/// - any [`is_network_error`] match (substring + EOF / UnexpectedEof in the
218///   `source()` chain)
219/// - any error whose chain contains a [`Retriable`] wrapper
220/// - any error whose chain contains an [`HttpError`] with status `>= 500`
221///   or status `429` (Too Many Requests)
222///
223/// Returns `false` for plain errors and 4xx HTTP errors (other than 429) —
224/// those are fast-failed by the retry loop.
225pub fn is_retriable(err: &(dyn StdError + 'static)) -> bool {
226    // 1. Any link in the chain is an explicit Retriable marker.
227    let mut cur: Option<&(dyn StdError + 'static)> = Some(err);
228    while let Some(e) = cur {
229        if e.is::<Retriable>() {
230            return true;
231        }
232        if let Some(http) = e.downcast_ref::<HttpError>()
233            && status_is_retriable(http.status)
234        {
235            return true;
236        }
237        cur = e.source();
238    }
239
240    // 2. Network-error substring / EOF chain match.
241    is_network_error(err)
242}
243
244/// The canonical retriable-HTTP-status rule: server errors (`>= 500`) and
245/// `429 Too Many Requests`. Everything else — notably the remaining 4xx
246/// range — is fast-failed.
247///
248/// [`is_retriable`]'s [`HttpError`] arm delegates here, and raw-status
249/// classifiers that cannot route through [`HttpError`] (the gemfury and
250/// chocolatey multipart push loops, whose conflict-as-success / hard-fail
251/// cases need bespoke `ControlFlow` handling) call it directly, so the
252/// fast-fail/retry split for a bare status code has exactly one
253/// definition. Extending the rule (408/425, `Retry-After` awareness)
254/// updates every consumer at once — including the one-way-door publishers
255/// where a mis-fast-failed transient burns an unrecoverable publish
256/// attempt.
257pub fn status_is_retriable(status: u16) -> bool {
258    status >= 500 || status == 429
259}
260
261/// Convenience: `None` passes through as `false`. The
262/// `IsRetriable(nil) -> false` semantics.
263pub fn is_retriable_opt(err: Option<&(dyn StdError + 'static)>) -> bool {
264    err.is_some_and(is_retriable)
265}
266
267/// Apply ±20 % pseudo-jitter to `base` using a cheap subsecond-nanos modulo.
268///
269/// Returns a value in `[base * 0.8, base * 1.2)`. No `rand` crate dependency:
270/// `SystemTime::now().subsec_nanos()` provides ~nanosecond entropy that is
271/// sufficient for retry jitter (the goal is spreading out concurrent retriers,
272/// not cryptographic unpredictability).
273///
274/// The ±20 % window is a widely-adopted convention (AWS SDK, GCP client libs).
275/// Jitter only ever widens the sleep by up to 20 %; it never shortens it below
276/// 80 % of the nominal delay, so `Retry-After` honoring is conservative.
277pub fn jitter_duration(base: Duration) -> Duration {
278    let nanos = base.as_nanos() as u64;
279    // 20 % of the nominal duration.
280    let window = nanos / 5;
281    if window == 0 {
282        return base;
283    }
284    // Cheap pseudo-random offset in [0, window * 2) centred on window,
285    // giving a net range of [base - window, base + window). The wall-clock
286    // seed is XORed with a process-local Weyl sequence (odd-constant atomic
287    // counter, so consecutive draws stay well spread) because under
288    // SOURCE_DATE_EPOCH a pinned clock would collapse jitter to a constant
289    // and re-synchronize concurrent retriers on every round — recreating
290    // the exact collision jitter exists to break. SOURCE_DATE_EPOCH pins
291    // BUILD OUTPUT bytes; a retry sleep duration never reaches an artifact,
292    // so varying it is determinism-safe (which is also why this reads the
293    // real clock instead of `sde::resolve_now()`).
294    static JITTER_SEQ: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
295    let clock = std::time::SystemTime::now()
296        .duration_since(std::time::UNIX_EPOCH)
297        .map(|d| d.subsec_nanos() as u64)
298        .unwrap_or(0);
299    let seq = JITTER_SEQ.fetch_add(0x9E37_79B9_7F4A_7C15, std::sync::atomic::Ordering::Relaxed);
300    let seed = clock ^ seq;
301    let offset = seed % (window * 2);
302    // Saturating arithmetic so we never panic on extreme values.
303    let jittered = nanos.saturating_sub(window).saturating_add(offset);
304    Duration::from_nanos(jittered)
305}