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::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::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}