use std::sync::Arc;
use std::time::Duration;
use backon::ExponentialBuilder;
use crate::error::RunError;
#[derive(Clone)]
pub struct RetryPolicy {
pub(crate) backoff: ExponentialBuilder,
pub(crate) predicate: Arc<dyn Fn(&RunError) -> bool + Send + Sync>,
}
impl RetryPolicy {
pub fn with_backoff(backoff: ExponentialBuilder) -> Self {
Self {
backoff,
predicate: Arc::new(default_transient),
}
}
pub fn when(mut self, f: impl Fn(&RunError) -> bool + Send + Sync + 'static) -> Self {
self.predicate = Arc::new(f);
self
}
}
impl Default for RetryPolicy {
fn default() -> Self {
Self {
backoff: ExponentialBuilder::default()
.with_factor(2.0)
.with_min_delay(Duration::from_millis(100))
.with_max_times(3)
.with_jitter(),
predicate: Arc::new(default_transient),
}
}
}
impl std::fmt::Debug for RetryPolicy {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("RetryPolicy")
.field("backoff", &"<ExponentialBuilder>")
.field("predicate", &"<closure>")
.finish()
}
}
pub fn default_transient(err: &RunError) -> bool {
match err {
RunError::NonZeroExit { stderr, .. } => {
stderr.contains("stale") || stderr.contains(".lock")
}
RunError::Spawn { .. } | RunError::Timeout { .. } => false,
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::cmd_display::CmdDisplay;
use std::io;
fn cd(prog: &str) -> CmdDisplay {
CmdDisplay::new(prog.into(), vec![], false)
}
fn fake_non_zero(stderr: &str) -> RunError {
#[cfg(unix)]
let status = {
use std::os::unix::process::ExitStatusExt;
std::process::ExitStatus::from_raw(256)
};
#[cfg(windows)]
let status = {
use std::os::windows::process::ExitStatusExt;
std::process::ExitStatus::from_raw(1)
};
RunError::NonZeroExit {
command: cd("x"),
status,
stdout: vec![],
stderr: stderr.to_string(),
}
}
fn fake_spawn() -> RunError {
RunError::Spawn {
command: cd("x"),
source: io::Error::new(io::ErrorKind::NotFound, "missing"),
}
}
fn fake_timeout() -> RunError {
RunError::Timeout {
command: cd("x"),
elapsed: Duration::from_secs(30),
stdout: vec![],
stderr: String::new(),
}
}
#[test]
fn default_transient_retries_stale() {
assert!(default_transient(&fake_non_zero("The working copy is stale")));
}
#[test]
fn default_transient_retries_lock() {
assert!(default_transient(&fake_non_zero(
"fatal: Unable to create '/repo/.git/index.lock'"
)));
}
#[test]
fn default_transient_skips_other_nonzero() {
assert!(!default_transient(&fake_non_zero("invalid revision")));
}
#[test]
fn default_transient_skips_spawn() {
assert!(!default_transient(&fake_spawn()));
}
#[test]
fn default_transient_skips_timeout() {
assert!(!default_transient(&fake_timeout()));
}
#[test]
fn custom_predicate() {
let policy = RetryPolicy::default().when(|err| match err {
RunError::NonZeroExit { stderr, .. } => stderr.contains("network"),
_ => false,
});
assert!((policy.predicate)(&fake_non_zero("network unreachable")));
assert!(!(policy.predicate)(&fake_non_zero(".lock")));
}
#[test]
fn debug_does_not_panic() {
let _ = format!("{:?}", RetryPolicy::default());
}
}