use std::time::Duration;
use libdd_capabilities::sleep::SleepCapability;
#[derive(Debug, Clone)]
#[cfg_attr(test, derive(PartialEq))]
pub enum RetryBackoffType {
Linear,
Constant,
Exponential,
}
#[derive(Debug, Clone)]
#[cfg_attr(test, derive(PartialEq))]
pub struct RetryStrategy {
max_retries: u32,
delay_ms: Duration,
backoff_type: RetryBackoffType,
jitter: Option<Duration>,
}
impl Default for RetryStrategy {
fn default() -> Self {
RetryStrategy {
max_retries: 5,
delay_ms: Duration::from_millis(100),
backoff_type: RetryBackoffType::Exponential,
jitter: None,
}
}
}
impl RetryStrategy {
pub fn new(
max_retries: u32,
delay_ms: u64,
backoff_type: RetryBackoffType,
jitter: Option<u64>,
) -> RetryStrategy {
RetryStrategy {
max_retries,
delay_ms: Duration::from_millis(delay_ms),
backoff_type,
jitter: jitter
.filter(|jitter_ms| *jitter_ms != 0)
.map(Duration::from_millis),
}
}
pub(crate) async fn delay<C: SleepCapability>(&self, attempt: u32, capabilities: &C) {
capabilities.sleep(self.delay_for_attempt(attempt)).await;
}
fn delay_for_attempt(&self, attempt: u32) -> Duration {
let retry_index = attempt.saturating_sub(1);
let multiplier = match self.backoff_type {
RetryBackoffType::Exponential => 2u32.checked_pow(retry_index).unwrap_or(u32::MAX),
RetryBackoffType::Constant => 1,
RetryBackoffType::Linear => retry_index.saturating_add(1),
};
let delay = self
.delay_ms
.checked_mul(multiplier)
.unwrap_or(Duration::MAX);
if let Some(jitter) = self.jitter {
let jitter_ms = u64::try_from(jitter.as_millis()).unwrap_or(u64::MAX);
let randomized_ms = rand::random::<u64>() % jitter_ms;
delay.saturating_add(Duration::from_millis(randomized_ms))
} else {
delay
}
}
pub(crate) fn max_retries(&self) -> u32 {
self.max_retries
}
}
#[cfg(test)]
mod tests {
use super::*;
use libdd_capabilities_impl::NativeSleepCapability;
use tokio::time::Instant;
const RETRY_STRATEGY_TIME_TOLERANCE_MS: u64 = 100;
#[cfg_attr(miri, ignore)]
#[tokio::test(start_paused = true)]
async fn test_retry_strategy_constant() {
let retry_strategy = RetryStrategy {
max_retries: 5,
delay_ms: Duration::from_millis(100),
backoff_type: RetryBackoffType::Constant,
jitter: None,
};
let capabilities = NativeSleepCapability;
let start = Instant::now();
retry_strategy.delay(1, &capabilities).await;
let elapsed = start.elapsed();
assert!(
elapsed >= retry_strategy.delay_ms
&& elapsed
<= retry_strategy.delay_ms
+ Duration::from_millis(RETRY_STRATEGY_TIME_TOLERANCE_MS),
"Elapsed time of {} ms was not within expected range",
elapsed.as_millis()
);
let start = Instant::now();
retry_strategy.delay(2, &capabilities).await;
let elapsed = start.elapsed();
assert!(
elapsed >= retry_strategy.delay_ms
&& elapsed
<= retry_strategy.delay_ms
+ Duration::from_millis(RETRY_STRATEGY_TIME_TOLERANCE_MS),
"Elapsed time of {} ms was not within expected range",
elapsed.as_millis()
);
}
#[cfg_attr(miri, ignore)]
#[tokio::test(start_paused = true)]
async fn test_retry_strategy_linear() {
let retry_strategy = RetryStrategy {
max_retries: 5,
delay_ms: Duration::from_millis(100),
backoff_type: RetryBackoffType::Linear,
jitter: None,
};
let capabilities = NativeSleepCapability;
let start = Instant::now();
retry_strategy.delay(1, &capabilities).await;
let elapsed = start.elapsed();
assert!(
elapsed >= retry_strategy.delay_ms
&& elapsed
<= retry_strategy.delay_ms
+ Duration::from_millis(RETRY_STRATEGY_TIME_TOLERANCE_MS),
"Elapsed time of {} ms was not within expected range",
elapsed.as_millis()
);
let start = Instant::now();
retry_strategy.delay(3, &capabilities).await;
let elapsed = start.elapsed();
assert!(
elapsed >= retry_strategy.delay_ms + (retry_strategy.delay_ms * 2)
&& elapsed
<= retry_strategy.delay_ms
+ (retry_strategy.delay_ms * 2)
+ Duration::from_millis(RETRY_STRATEGY_TIME_TOLERANCE_MS),
"Elapsed time of {} ms was not within expected range",
elapsed.as_millis()
);
}
#[cfg_attr(miri, ignore)]
#[tokio::test(start_paused = true)]
async fn test_retry_strategy_exponential() {
let retry_strategy = RetryStrategy {
max_retries: 5,
delay_ms: Duration::from_millis(100),
backoff_type: RetryBackoffType::Exponential,
jitter: None,
};
let capabilities = NativeSleepCapability;
let start = Instant::now();
retry_strategy.delay(1, &capabilities).await;
let elapsed = start.elapsed();
assert!(
elapsed >= retry_strategy.delay_ms
&& elapsed
<= retry_strategy.delay_ms
+ Duration::from_millis(RETRY_STRATEGY_TIME_TOLERANCE_MS),
"Elapsed time of {} ms was not within expected range",
elapsed.as_millis()
);
let start = Instant::now();
retry_strategy.delay(3, &capabilities).await;
let elapsed = start.elapsed();
assert!(
elapsed >= retry_strategy.delay_ms * 4
&& elapsed
<= retry_strategy.delay_ms * 4
+ Duration::from_millis(RETRY_STRATEGY_TIME_TOLERANCE_MS),
"Elapsed time of {} ms was not within expected range",
elapsed.as_millis()
);
}
#[cfg_attr(miri, ignore)]
#[tokio::test(start_paused = true)]
async fn test_retry_strategy_jitter() {
let retry_strategy = RetryStrategy {
max_retries: 5,
delay_ms: Duration::from_millis(100),
backoff_type: RetryBackoffType::Constant,
jitter: Some(Duration::from_millis(50)),
};
let capabilities = NativeSleepCapability;
let start = Instant::now();
retry_strategy.delay(1, &capabilities).await;
let elapsed = start.elapsed();
assert!(
elapsed >= retry_strategy.delay_ms
&& elapsed
<= retry_strategy.delay_ms
+ retry_strategy.jitter.unwrap()
+ Duration::from_millis(RETRY_STRATEGY_TIME_TOLERANCE_MS),
"Elapsed time of {} ms was not within expected range",
elapsed.as_millis()
);
}
#[cfg_attr(miri, ignore)]
#[tokio::test]
async fn test_retry_strategy_max_retries() {
let retry_strategy = RetryStrategy {
max_retries: 17,
delay_ms: Duration::from_millis(100),
backoff_type: RetryBackoffType::Constant,
jitter: Some(Duration::from_millis(50)),
};
assert_eq!(
retry_strategy.max_retries(),
17,
"Max retries did not match expected value"
);
}
#[test]
fn test_retry_delays() {
let retry_strategy = RetryStrategy::new(3, 100, RetryBackoffType::Exponential, None);
assert_eq!(
retry_strategy.delay_for_attempt(1),
Duration::from_millis(100)
);
assert_eq!(
retry_strategy.delay_for_attempt(2),
Duration::from_millis(200)
);
assert_eq!(
retry_strategy.delay_for_attempt(3),
Duration::from_millis(400)
);
}
#[test]
fn test_zero_jitter_is_supported() {
let retry_strategy = RetryStrategy::new(1, 100, RetryBackoffType::Constant, Some(0));
assert_eq!(retry_strategy.jitter, None);
assert_eq!(
retry_strategy.delay_for_attempt(1),
Duration::from_millis(100)
);
}
}