Skip to main content

thunderstore_engine/
retry.rs

1//! Bounded retry with exponential backoff for transient HTTP failures.
2//!
3//! Retries are applied only to idempotent network operations that failed for a
4//! *transient* reason (connection/timeout/body errors, `5xx`, or `429`) — never
5//! to a deterministic failure such as a `4xx` response or one of the crate's own
6//! terminal errors (a size-cap breach, a checksum mismatch, an unsafe filename).
7//! The whole download path is already retry-safe: archives stream to a temp file
8//! that is only renamed into place on success, so a retried attempt restarts
9//! cleanly.
10
11#![deny(missing_docs)]
12
13use crate::error::{Error, Result};
14use std::future::Future;
15use std::sync::atomic::{AtomicU64, Ordering};
16use std::time::Duration;
17
18/// Policy for retrying transient network failures.
19///
20/// The delay before the retry following a (0-based) attempt is
21/// `base_delay * 2^attempt`, capped at `max_delay`, plus a little jitter.
22#[derive(Debug, Clone, Copy)]
23pub struct RetryPolicy {
24  /// Maximum number of retries *after* the initial attempt (so total attempts is
25  /// `max_retries + 1`). Zero disables retrying.
26  pub max_retries: u32,
27  /// The base backoff delay, doubled on each successive retry.
28  pub base_delay: Duration,
29  /// The ceiling on any single backoff delay.
30  pub max_delay: Duration,
31}
32
33impl Default for RetryPolicy {
34  fn default() -> Self {
35    Self {
36      max_retries: 3,
37      base_delay: Duration::from_millis(250),
38      max_delay: Duration::from_secs(10),
39    }
40  }
41}
42
43impl RetryPolicy {
44  /// A policy that performs no retries (a single attempt).
45  pub fn none() -> Self {
46    Self {
47      max_retries: 0,
48      ..Self::default()
49    }
50  }
51
52  /// The backoff delay before the retry following `attempt` (0-based).
53  fn backoff(&self, attempt: u32) -> Duration {
54    let factor = 1u32.checked_shl(attempt).unwrap_or(u32::MAX);
55    let capped = self.base_delay.saturating_mul(factor).min(self.max_delay);
56    let jitter_ceiling_ms = (capped.as_millis() / 4) as u64;
57
58    if jitter_ceiling_ms == 0 {
59      return capped;
60    }
61
62    capped + Duration::from_millis(jitter_source() % (jitter_ceiling_ms + 1))
63  }
64}
65
66/// A cheap, dependency-free varying value used only to desynchronize concurrent
67/// retries. Not cryptographic and not for any security purpose.
68fn jitter_source() -> u64 {
69  static COUNTER: AtomicU64 = AtomicU64::new(0);
70
71  COUNTER.fetch_add(0x9E37_79B9_7F4A_7C15, Ordering::Relaxed)
72}
73
74/// Runs `op`, retrying per `policy` while `is_retryable` accepts the error.
75///
76/// Returns the first `Ok`, or the last `Err` once retries are exhausted or the
77/// error is non-retryable.
78///
79/// `op` is a plain `FnMut` returning a future (call sites pass `|| async { .. }`)
80/// rather than an `AsyncFnMut`. This is deliberate: an async closure's returned
81/// future carries a higher-ranked lifetime that the compiler cannot prove `Send`
82/// "for any lifetime", which would make every retried operation — and therefore
83/// the public `get_manifest`/`download_files` futures — `!Send`. A closure
84/// returning a single concrete future type has no such higher-ranked lifetime, so
85/// the futures stay `Send` and can be driven from a multi-threaded runtime.
86pub(crate) async fn retry_with<T, F>(
87  policy: &RetryPolicy,
88  is_retryable: impl Fn(&Error) -> bool,
89  mut op: impl FnMut() -> F,
90) -> Result<T>
91where
92  F: Future<Output = Result<T>>,
93{
94  let mut attempt = 0u32;
95
96  loop {
97    match op().await {
98      Ok(value) => return Ok(value),
99      Err(err) => {
100        if attempt >= policy.max_retries || !is_retryable(&err) {
101          return Err(err);
102        }
103
104        let delay = policy.backoff(attempt);
105
106        tracing::warn!(
107          "transient network error ({}); retry {}/{}",
108          err,
109          attempt + 1,
110          policy.max_retries
111        );
112
113        if !delay.is_zero() {
114          tokio::time::sleep(delay).await;
115        }
116
117        attempt += 1;
118      }
119    }
120  }
121}
122
123/// [`retry_with`] using [`is_retryable_http`] as the classifier.
124pub(crate) async fn retry_http<T, F>(policy: &RetryPolicy, op: impl FnMut() -> F) -> Result<T>
125where
126  F: Future<Output = Result<T>>,
127{
128  retry_with(policy, is_retryable_http, op).await
129}
130
131/// Whether an error is a transient HTTP failure worth retrying: a connection,
132/// timeout, request, or body error, or a `5xx`/`429` status. A `4xx` (other than
133/// `429`) and every non-HTTP error are treated as terminal.
134pub(crate) fn is_retryable_http(err: &Error) -> bool {
135  match err {
136    Error::Http(e) => {
137      if let Some(status) = e.status() {
138        status.is_server_error() || status == reqwest::StatusCode::TOO_MANY_REQUESTS
139      } else {
140        e.is_timeout() || e.is_connect() || e.is_request() || e.is_body()
141      }
142    }
143    _ => false,
144  }
145}
146
147#[cfg(test)]
148mod tests {
149  use super::*;
150  use std::cell::Cell;
151
152  fn zero_delay(max_retries: u32) -> RetryPolicy {
153    RetryPolicy {
154      max_retries,
155      base_delay: Duration::ZERO,
156      max_delay: Duration::ZERO,
157    }
158  }
159
160  #[tokio::test]
161  async fn retries_until_success() {
162    let calls = Cell::new(0u32);
163
164    let result: Result<u32> = retry_with(
165      &zero_delay(3),
166      |_| true,
167      || async {
168        calls.set(calls.get() + 1);
169
170        if calls.get() < 3 {
171          Err(Error::Cache("transient".to_string()))
172        } else {
173          Ok(42)
174        }
175      },
176    )
177    .await;
178
179    assert_eq!(result.unwrap(), 42);
180    assert_eq!(calls.get(), 3);
181  }
182
183  #[tokio::test]
184  async fn gives_up_after_max_retries() {
185    let calls = Cell::new(0u32);
186
187    let result: Result<u32> = retry_with(
188      &zero_delay(2),
189      |_| true,
190      || async {
191        calls.set(calls.get() + 1);
192
193        Err(Error::Cache("always".to_string()))
194      },
195    )
196    .await;
197
198    assert!(result.is_err());
199    assert_eq!(calls.get(), 3); // initial attempt + 2 retries
200  }
201
202  #[tokio::test]
203  async fn does_not_retry_non_retryable() {
204    let calls = Cell::new(0u32);
205
206    let result: Result<u32> = retry_with(
207      &zero_delay(5),
208      |_| false,
209      || async {
210        calls.set(calls.get() + 1);
211
212        Err(Error::Cache("terminal".to_string()))
213      },
214    )
215    .await;
216
217    assert!(result.is_err());
218    assert_eq!(calls.get(), 1);
219  }
220
221  #[test]
222  fn crate_terminal_errors_are_not_retryable() {
223    assert!(!is_retryable_http(&Error::SizeLimitExceeded {
224      context: "download body".to_string(),
225      limit: 1,
226      actual: None,
227    }));
228    assert!(!is_retryable_http(&Error::Cache("bad cache".to_string())));
229    assert!(!is_retryable_http(&Error::ChecksumMismatch {
230      path: "x".to_string(),
231      expected: 1,
232      actual: 2,
233    }));
234  }
235}