use std::time::Duration;
use crate::error::ForgeError;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum ErrorClass {
Transient,
Permanent,
Integrity,
Cancelled,
}
pub(crate) fn classify(error: &ForgeError) -> ErrorClass {
if matches!(error, ForgeError::Network(_)) {
return ErrorClass::Transient;
}
let message = error.to_string().to_ascii_lowercase();
if message.contains("ctrl-c") || message.contains("cancel") {
ErrorClass::Cancelled
} else if message.contains("sha-256")
|| message.contains("checksum")
|| message.contains("verification")
|| message.contains("escapes")
{
ErrorClass::Integrity
} else if message.contains("timed out")
|| message.contains("timeout")
|| message.contains("temporarily unavailable")
|| message.contains("spurious network")
|| message.contains("failed to download")
|| message.contains("connection reset")
|| message.contains("connection refused")
|| message.contains("http2 framing")
|| message.contains("dns error")
{
ErrorClass::Transient
} else {
ErrorClass::Permanent
}
}
#[derive(Debug, Clone, Copy)]
pub(crate) struct RetryPolicy {
pub(crate) max_attempts: u32,
pub(crate) base_delay: Duration,
pub(crate) max_delay: Duration,
}
impl RetryPolicy {
pub(crate) fn delay(&self, attempt: u32, seed: u64) -> Duration {
let factor = 1_u32.checked_shl(attempt.min(20)).unwrap_or(u32::MAX);
let exponential = self.base_delay.saturating_mul(factor);
let capped = exponential.min(self.max_delay);
let jitter_millis = if capped.is_zero() {
0
} else {
seed.wrapping_mul(6_364_136_223_846_793_005)
.wrapping_add(u64::from(attempt))
% (u64::try_from(capped.as_millis()).unwrap_or(u64::MAX) / 4 + 1)
};
capped.saturating_add(Duration::from_millis(jitter_millis))
}
pub(crate) fn should_retry(&self, attempt: u32, idempotent: bool, error: &ForgeError) -> bool {
idempotent && attempt + 1 < self.max_attempts && classify(error) == ErrorClass::Transient
}
}
#[cfg(test)]
mod tests {
use std::time::Duration;
use crate::error::ForgeError;
use crate::execution::retry::RetryPolicy;
#[test]
fn retries_only_transient_idempotent_operations() {
let policy = RetryPolicy {
max_attempts: 3,
base_delay: Duration::from_millis(100),
max_delay: Duration::from_secs(1),
};
assert!(policy.should_retry(0, true, &ForgeError::Network("timeout".into())));
assert!(!policy.should_retry(0, false, &ForgeError::Network("timeout".into())));
assert!(!policy.should_retry(0, true, &ForgeError::Config("invalid".into())));
assert!(policy.should_retry(
0,
true,
&ForgeError::Command("spurious network error: connection reset".into())
));
assert!(!policy.should_retry(0, true, &ForgeError::Command("checksum mismatch".into())));
assert!(!policy.should_retry(2, true, &ForgeError::Network("timeout".into())));
}
#[test]
fn backoff_is_deterministic_and_capped() {
let policy = RetryPolicy {
max_attempts: 10,
base_delay: Duration::from_millis(100),
max_delay: Duration::from_secs(1),
};
assert_eq!(policy.delay(2, 42), policy.delay(2, 42));
assert!(policy.delay(20, 42) <= Duration::from_millis(1_250));
}
}