Skip to main content

anodizer_core/retry/
http.rs

1use super::*;
2use std::ops::ControlFlow;
3
4/// Whether to consider 3xx redirects a success outcome (most upload-style
5/// publishers do, since the underlying client follows redirects under the
6/// hood; some callers explicitly want only 2xx).
7#[derive(Debug, Clone, Copy, PartialEq, Eq)]
8pub enum SuccessClass {
9    /// 2xx only. Any 3xx is treated as a non-success status (eligible for
10    /// retry / fast-fail per `is_retriable`).
11    Strict,
12    /// 2xx OR 3xx. Used by upload publishers whose servers may emit a
13    /// 301/302/307 in the success path (artifactory does this for some
14    /// virtual repo configurations).
15    AllowRedirects,
16}
17
18/// Drive a single HTTP call to completion, retrying transient failures via
19/// the shared [`retry_sync`] machinery.
20///
21/// On every attempt, `send` is invoked to construct + dispatch a fresh
22/// request. The closure must rebuild the request from scratch (multipart
23/// `Form`, streamed body, etc. are move-only). The helper:
24///
25/// 1. On `Err` (transport-level): wrap in [`HttpError::from_response`] +
26///    a `<label>: <stage> transport error` context, classify with
27///    [`is_retriable`] (so EOF / connection-reset retry, plain "dial
28///    failed" fast-fails), and dispatch `Continue`/`Break`.
29/// 2. On non-success status: drain the body, format the outer message via
30///    `error_msg`, wrap in [`HttpError::new`] with the upstream status, and
31///    classify (5xx/429 → `Continue`, 4xx → `Break`).
32/// 3. On success status: return `(status, body)`.
33///
34/// The `error_msg` closure receives the response status and body so callers
35/// can format publisher-specific envelopes (e.g. artifactory's
36/// `{"errors":[...]}` JSON).
37///
38/// Replaces three nearly-identical retry loops:
39/// - `stage-publish/cloudsmith.rs::retry_request`
40/// - `stage-publish/artifactory.rs::upload_single_artifact` (inline)
41/// - `stage-announce/helpers.rs::retry_http` (now wraps this helper; see
42///   announce/helpers.rs for the thin adapter that returns the body string
43///   instead of `(StatusCode, String)`).
44pub fn retry_http_blocking<F, M>(
45    rlog: RetryLog<'_>,
46    policy: &RetryPolicy,
47    success_class: SuccessClass,
48    send: F,
49    error_msg: M,
50) -> anyhow::Result<(reqwest::StatusCode, String)>
51where
52    F: FnMut(u32) -> Result<reqwest::blocking::Response, reqwest::Error>,
53    M: Fn(reqwest::StatusCode, &str) -> String,
54{
55    retry_http_blocking_deadline(rlog, policy, None, success_class, send, error_msg)
56}
57
58/// Like [`retry_http_blocking`], but stops once the next backoff would push
59/// total wall-time past `deadline` (from [`crate::context::Context::retry_deadline`]), so
60/// a long upload storm exits resumable before the outer job timeout instead of
61/// running the full attempt ladder. `deadline: None` is the unbounded form.
62pub fn retry_http_blocking_deadline<F, M>(
63    rlog: RetryLog<'_>,
64    policy: &RetryPolicy,
65    deadline: Option<std::time::Instant>,
66    success_class: SuccessClass,
67    mut send: F,
68    error_msg: M,
69) -> anyhow::Result<(reqwest::StatusCode, String)>
70where
71    F: FnMut(u32) -> Result<reqwest::blocking::Response, reqwest::Error>,
72    M: Fn(reqwest::StatusCode, &str) -> String,
73{
74    use anyhow::Context as _;
75    retry_sync_deadline(rlog, policy, deadline, |attempt| {
76        match send(attempt) {
77            Ok(resp) => {
78                let status = resp.status();
79                let succeeded = match success_class {
80                    SuccessClass::Strict => status.is_success(),
81                    SuccessClass::AllowRedirects => status.is_success() || status.is_redirection(),
82                };
83                let body = resp
84                    .text()
85                    .unwrap_or_else(|e| format!("<failed to read body: {e}>"));
86                if succeeded {
87                    Ok((status, body))
88                } else {
89                    let msg = error_msg(status, &body);
90                    let inner = anyhow::anyhow!("{msg}");
91                    let wrapped = anyhow::Error::new(HttpError::new(
92                        std::io::Error::other(inner.to_string()),
93                        status.as_u16(),
94                    ))
95                    .context(inner);
96                    // `as_ref()` is the head of the chain; `is_retriable` walks
97                    // `.source()` to reach `HttpError`. `root_cause()` would
98                    // unwrap past `HttpError` to the io::Error leaf and miss
99                    // the status. Pinned by
100                    // `classifier_5xx_via_anyhow_chain_uses_as_ref`.
101                    if is_retriable(wrapped.as_ref()) {
102                        Err(ControlFlow::Continue(wrapped))
103                    } else {
104                        Err(ControlFlow::Break(wrapped))
105                    }
106                }
107            }
108            Err(e) => {
109                // Transport-layer failure: always wrap in HttpError(status=0)
110                // so the chain-walking classifier can see network-error
111                // substrings via the inner io::Error message.
112                let err = anyhow::Error::new(HttpError::from_response(e, None))
113                    .context(format!("{}: HTTP transport error", rlog.desc()));
114                if is_retriable(err.as_ref()) {
115                    Err(ControlFlow::Continue(err))
116                } else {
117                    Err(ControlFlow::Break(err))
118                }
119            }
120        }
121    })
122    .with_context(|| format!("{}: exhausted retry attempts", rlog.desc()))
123}
124
125/// Binary-body sibling of [`retry_http_blocking`] for endpoints whose success
126/// payload is not valid UTF-8 (e.g. a gzip-compressed `.crate` tarball).
127///
128/// `resp.text()` runs a lossy UTF-8 conversion that silently rewrites
129/// non-UTF-8 byte sequences to U+FFFD, corrupting a binary payload with no
130/// error raised — a caller hashing the "recovered" bytes would never match
131/// the original digest. This variant reads the success body via
132/// `resp.bytes()` instead, keeping every other behavior (retry classification,
133/// `HttpError` wrapping, `success_class`) identical to the text variant. The
134/// error-path body is still decoded lossily into text purely for the
135/// `error_msg` formatter — error responses are conventionally textual/JSON,
136/// and a few replacement characters in an already-failing message are
137/// harmless.
138pub fn retry_http_blocking_bytes<F, M>(
139    rlog: RetryLog<'_>,
140    policy: &RetryPolicy,
141    success_class: SuccessClass,
142    send: F,
143    error_msg: M,
144) -> anyhow::Result<(reqwest::StatusCode, Vec<u8>)>
145where
146    F: FnMut(u32) -> Result<reqwest::blocking::Response, reqwest::Error>,
147    M: Fn(reqwest::StatusCode, &str) -> String,
148{
149    retry_http_blocking_bytes_deadline(rlog, policy, None, success_class, send, error_msg)
150}
151
152/// Deadline-bounded sibling of [`retry_http_blocking_bytes`], mirroring
153/// [`retry_http_blocking_deadline`] for binary success bodies. `deadline: None`
154/// is the unbounded form.
155pub fn retry_http_blocking_bytes_deadline<F, M>(
156    rlog: RetryLog<'_>,
157    policy: &RetryPolicy,
158    deadline: Option<std::time::Instant>,
159    success_class: SuccessClass,
160    mut send: F,
161    error_msg: M,
162) -> anyhow::Result<(reqwest::StatusCode, Vec<u8>)>
163where
164    F: FnMut(u32) -> Result<reqwest::blocking::Response, reqwest::Error>,
165    M: Fn(reqwest::StatusCode, &str) -> String,
166{
167    use anyhow::Context as _;
168    retry_sync_deadline(rlog, policy, deadline, |attempt| match send(attempt) {
169        Ok(resp) => {
170            let status = resp.status();
171            let succeeded = match success_class {
172                SuccessClass::Strict => status.is_success(),
173                SuccessClass::AllowRedirects => status.is_success() || status.is_redirection(),
174            };
175            let bytes = resp
176                .bytes()
177                .map(|b| b.to_vec())
178                .unwrap_or_else(|e| format!("<failed to read body: {e}>").into_bytes());
179            if succeeded {
180                Ok((status, bytes))
181            } else {
182                let body_text = String::from_utf8_lossy(&bytes).into_owned();
183                let msg = error_msg(status, &body_text);
184                let inner = anyhow::anyhow!("{msg}");
185                let wrapped = anyhow::Error::new(HttpError::new(
186                    std::io::Error::other(inner.to_string()),
187                    status.as_u16(),
188                ))
189                .context(inner);
190                if is_retriable(wrapped.as_ref()) {
191                    Err(ControlFlow::Continue(wrapped))
192                } else {
193                    Err(ControlFlow::Break(wrapped))
194                }
195            }
196        }
197        Err(e) => {
198            let err = anyhow::Error::new(HttpError::from_response(e, None))
199                .context(format!("{}: HTTP transport error", rlog.desc()));
200            if is_retriable(err.as_ref()) {
201                Err(ControlFlow::Continue(err))
202            } else {
203                Err(ControlFlow::Break(err))
204            }
205        }
206    })
207    .with_context(|| format!("{}: exhausted retry attempts", rlog.desc()))
208}
209
210/// Async sibling of [`retry_http_blocking`] for `reqwest::Client` (non-blocking)
211/// call sites such as the GitLab and Gitea release publishers.
212///
213/// Each attempt invokes `send` (a fresh future) and:
214///
215/// 1. On `Err` (transport-level): wraps in [`HttpError::from_response`] +
216///    a `<label>: HTTP transport error` context, classifies via
217///    [`is_retriable`] (network-substring + EOF chain match), and dispatches
218///    `Continue`/`Break`.
219/// 2. On non-success status: drains the body via `Response::text().await`,
220///    formats the outer message via `error_msg`, wraps in [`HttpError::new`]
221///    with the upstream status, and classifies (5xx/429 → `Continue`, 4xx →
222///    `Break`).
223/// 3. On success status: returns the raw [`reqwest::Response`] for the
224///    caller to consume (e.g. `.json()`, `.text()`, header inspection).
225///
226/// `success_class` mirrors the blocking variant: `Strict` rejects 3xx,
227/// `AllowRedirects` accepts them. Most async API clients want `Strict`
228/// (their reqwest::Client follows redirects by default, so a surfaced 3xx
229/// is itself an error).
230pub async fn retry_http_async<F, Fut, M>(
231    rlog: RetryLog<'_>,
232    policy: &RetryPolicy,
233    success_class: SuccessClass,
234    send: F,
235    error_msg: M,
236) -> anyhow::Result<reqwest::Response>
237where
238    F: FnMut(u32) -> Fut,
239    Fut: std::future::Future<Output = Result<reqwest::Response, reqwest::Error>>,
240    M: Fn(reqwest::StatusCode, &str) -> String,
241{
242    retry_http_async_deadline(rlog, policy, None, success_class, send, error_msg).await
243}
244
245/// Deadline-bounded sibling of [`retry_http_async`], the async counterpart of
246/// [`retry_http_blocking_deadline`], so async upload publishers (GitLab/Gitea
247/// release-asset uploads) honor the [`crate::context::Context::retry_deadline`] budget.
248/// `deadline: None` is the unbounded form.
249pub async fn retry_http_async_deadline<F, Fut, M>(
250    rlog: RetryLog<'_>,
251    policy: &RetryPolicy,
252    deadline: Option<std::time::Instant>,
253    success_class: SuccessClass,
254    mut send: F,
255    error_msg: M,
256) -> anyhow::Result<reqwest::Response>
257where
258    F: FnMut(u32) -> Fut,
259    Fut: std::future::Future<Output = Result<reqwest::Response, reqwest::Error>>,
260    M: Fn(reqwest::StatusCode, &str) -> String,
261{
262    use anyhow::Context as _;
263    retry_async_deadline(rlog, policy, deadline, |attempt| {
264        let fut = send(attempt);
265        let error_msg = &error_msg;
266        async move {
267            match fut.await {
268                Ok(resp) => {
269                    let status = resp.status();
270                    let succeeded = match success_class {
271                        SuccessClass::Strict => status.is_success(),
272                        SuccessClass::AllowRedirects => {
273                            status.is_success() || status.is_redirection()
274                        }
275                    };
276                    if succeeded {
277                        Ok(resp)
278                    } else {
279                        let body = resp
280                            .text()
281                            .await
282                            .unwrap_or_else(|e| format!("<failed to read body: {e}>"));
283                        let msg = error_msg(status, &body);
284                        let inner = anyhow::anyhow!("{msg}");
285                        let wrapped = anyhow::Error::new(HttpError::new(
286                            std::io::Error::other(inner.to_string()),
287                            status.as_u16(),
288                        ))
289                        .context(inner);
290                        // `as_ref()` is the head of the chain; `is_retriable`
291                        // walks `.source()` to reach `HttpError`. `root_cause()`
292                        // would unwrap past `HttpError` to the io::Error leaf
293                        // and miss the status. Pinned by
294                        // `classifier_5xx_via_anyhow_chain_uses_as_ref`.
295                        if is_retriable(wrapped.as_ref()) {
296                            Err(ControlFlow::Continue(wrapped))
297                        } else {
298                            Err(ControlFlow::Break(wrapped))
299                        }
300                    }
301                }
302                Err(e) => {
303                    // Transport-layer failure: wrap in HttpError(status=0) so
304                    // the chain-walking classifier can see network-error
305                    // substrings via the inner io::Error message.
306                    let err = anyhow::Error::new(HttpError::from_response(e, None))
307                        .context(format!("{}: HTTP transport error", rlog.desc()));
308                    if is_retriable(err.as_ref()) {
309                        Err(ControlFlow::Continue(err))
310                    } else {
311                        Err(ControlFlow::Break(err))
312                    }
313                }
314            }
315        }
316    })
317    .await
318    .with_context(|| format!("{}: exhausted retry attempts", rlog.desc()))
319}
320
321/// Classify a `reqwest::Result<reqwest::blocking::Response>` into the
322/// `ControlFlow` shape expected by `retry_sync` for a typical HTTP call:
323/// 5xx + transport errors retry, 4xx fast-fails, 2xx/3xx returns Ok. The
324/// returned response (Ok branch) is the caller's to consume.
325///
326/// This is the convention shared by every HTTP-uploading publisher; see audit
327/// A7 dedup S5.
328pub fn classify_http_sync(
329    result: reqwest::Result<reqwest::blocking::Response>,
330) -> Result<reqwest::blocking::Response, ControlFlow<anyhow::Error, anyhow::Error>> {
331    use anyhow::anyhow;
332    match result {
333        Ok(resp) => {
334            let status = resp.status();
335            if status.is_success() || status.is_redirection() {
336                Ok(resp)
337            } else if status.is_server_error() {
338                Err(ControlFlow::Continue(anyhow!(
339                    "HTTP {} {}",
340                    status.as_u16(),
341                    status.canonical_reason().unwrap_or("server error")
342                )))
343            } else {
344                // 4xx (and any other non-success/redirect/5xx): fast-fail
345                Err(ControlFlow::Break(anyhow!(
346                    "HTTP {} {}",
347                    status.as_u16(),
348                    status.canonical_reason().unwrap_or("client error")
349                )))
350            }
351        }
352        // Transport-layer failure (DNS, connect, TLS, timeout): retry.
353        Err(e) => Err(ControlFlow::Continue(anyhow!(e))),
354    }
355}