Skip to main content

crate_seq_registry/
backoff.rs

1//! Exponential backoff retry loop for `cargo publish`.
2
3#[cfg(test)]
4#[path = "backoff_tests.rs"]
5mod tests;
6
7use std::path::Path;
8use std::time::Duration;
9
10use crate::publish::{run_cargo_publish, PublishOutcome};
11use crate::Error;
12
13/// Configuration for the exponential backoff retry loop.
14pub struct BackoffConfig {
15    /// Initial delay in milliseconds. Default: `1000`.
16    pub base_ms: u64,
17    /// Maximum delay cap in milliseconds. Default: `60_000`.
18    pub cap_ms: u64,
19    /// Maximum number of retry attempts (not counting the first attempt). Default: `5`.
20    pub max_retries: u32,
21    /// Maximum random jitter in milliseconds added to each delay. Default: `1000`.
22    pub jitter_max_ms: u64,
23}
24
25impl Default for BackoffConfig {
26    fn default() -> Self {
27        Self {
28            base_ms: 1_000,
29            cap_ms: 60_000,
30            max_retries: 5,
31            jitter_max_ms: 1_000,
32        }
33    }
34}
35
36/// `min(cap_ms, base_ms * 2^attempt) + jitter` for the given attempt.
37pub(crate) fn compute_delay(attempt: u32, config: &BackoffConfig, jitter: u64) -> u64 {
38    // 1 << attempt, saturating at u64::MAX to avoid overflow.
39    let multiplier = 1u64.checked_shl(attempt).unwrap_or(u64::MAX);
40    let exponential = config.base_ms.saturating_mul(multiplier);
41    exponential.min(config.cap_ms).saturating_add(jitter)
42}
43
44/// Deterministic jitter derived from the attempt index.
45///
46/// Avoids pulling in a random crate; adequate for a CLI tool with human-scale
47/// retry counts.
48fn jitter_for_attempt(attempt: u32, jitter_max_ms: u64) -> u64 {
49    if jitter_max_ms == 0 {
50        return 0;
51    }
52    // Knuth multiplicative hash variant — spreads attempt values across range.
53    let hash = u64::from(attempt).wrapping_mul(2_654_435_761);
54    hash % jitter_max_ms
55}
56
57/// Publishes with exponential backoff, retrying on `RateLimited` outcomes.
58///
59/// `AlreadyPublished` is treated as `Success`. Other failures stop the loop.
60/// Returns `Ok(PublishOutcome::RateLimited)` if all retries are exhausted.
61///
62/// # Errors
63///
64/// Returns [`Error::Subprocess`] if the subprocess cannot be spawned.
65pub fn backoff_publish(
66    dir: &Path,
67    token: Option<&str>,
68    config: &BackoffConfig,
69) -> Result<PublishOutcome, Error> {
70    for attempt in 0..=config.max_retries {
71        let outcome = run_cargo_publish(dir, token)?;
72
73        match outcome {
74            PublishOutcome::Success | PublishOutcome::AlreadyPublished => {
75                return Ok(PublishOutcome::Success);
76            }
77            PublishOutcome::Failed(_) => return Ok(outcome),
78            PublishOutcome::RateLimited => {
79                if attempt < config.max_retries {
80                    let jitter = jitter_for_attempt(attempt, config.jitter_max_ms);
81                    let delay_ms = compute_delay(attempt, config, jitter);
82                    std::thread::sleep(Duration::from_millis(delay_ms));
83                } else {
84                    return Ok(PublishOutcome::RateLimited);
85                }
86            }
87        }
88    }
89
90    Ok(PublishOutcome::RateLimited)
91}