use std::time::Duration;
const MIN_BACKOFF: Duration = Duration::from_millis(1);
#[derive(Debug, Clone)]
pub struct RetryPolicy {
pub max_retries: usize,
pub base_backoff: Duration,
pub max_backoff: Duration,
}
impl Default for RetryPolicy {
fn default() -> Self {
Self {
max_retries: 4,
base_backoff: Duration::from_millis(50),
max_backoff: Duration::from_secs(10),
}
}
}
impl RetryPolicy {
pub(crate) fn backoff(&self, attempt: usize) -> Duration {
exponential(self.base_backoff, self.max_backoff, attempt)
}
}
fn exponential(base: Duration, max: Duration, attempt: usize) -> Duration {
let factor = 2u32.saturating_pow(attempt.min(31) as u32);
base.max(MIN_BACKOFF)
.saturating_mul(factor)
.min(max.max(MIN_BACKOFF))
}
pub(crate) fn with_jitter(d: Duration) -> Duration {
let nanos = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|t| t.subsec_nanos())
.unwrap_or(0);
let frac = (nanos % 250) as f64 / 1000.0;
d + d.mul_f64(frac)
}
#[derive(Debug, Clone)]
pub enum RetryMode {
None,
Bounded(RetryPolicy),
UntilCancelled { base: Duration, max: Duration },
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum BreakerMode {
FailFast,
WaitForProbe,
}
#[derive(Debug, Clone)]
pub struct CallPolicy {
pub retry: RetryMode,
pub breaker: BreakerMode,
}
impl CallPolicy {
pub fn background() -> Self {
Self {
retry: RetryMode::None,
breaker: BreakerMode::FailFast,
}
}
pub fn essential() -> Self {
Self {
retry: RetryMode::UntilCancelled {
base: Duration::from_millis(250),
max: Duration::from_secs(10),
},
breaker: BreakerMode::WaitForProbe,
}
}
pub fn bounded(policy: RetryPolicy) -> Self {
Self {
retry: RetryMode::Bounded(policy),
breaker: BreakerMode::FailFast,
}
}
pub fn wait_for_probe(mut self) -> Self {
self.breaker = BreakerMode::WaitForProbe;
self
}
pub(crate) fn next_backoff(&self, attempt: usize) -> Option<Duration> {
match &self.retry {
RetryMode::None => None,
RetryMode::Bounded(p) => (attempt < p.max_retries).then(|| p.backoff(attempt)),
RetryMode::UntilCancelled { base, max } => Some(exponential(*base, *max, attempt)),
}
}
pub(crate) fn retry_after_cap(&self) -> Option<Duration> {
match &self.retry {
RetryMode::None => None,
RetryMode::Bounded(p) => Some(p.max_backoff.max(MIN_BACKOFF)),
RetryMode::UntilCancelled { max, .. } => Some((*max).max(MIN_BACKOFF)),
}
}
}
pub(crate) fn is_retryable_status(status: u16) -> bool {
status == 408 || status == 429 || (500..600).contains(&status)
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum Health {
Healthy,
Reachable,
Inconclusive,
Failing,
}
pub(crate) fn status_health(status: u16) -> Health {
match status {
200..=299 => Health::Healthy,
408 | 429 => Health::Inconclusive,
500..=599 => Health::Failing,
_ => Health::Reachable,
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn retryability_and_health_are_separate_questions() {
for status in [408, 429] {
assert!(is_retryable_status(status));
assert_eq!(status_health(status), Health::Inconclusive);
}
assert!(is_retryable_status(503));
assert_eq!(status_health(503), Health::Failing);
assert!(!is_retryable_status(404));
assert_eq!(status_health(404), Health::Reachable);
assert!(!is_retryable_status(200));
assert_eq!(status_health(200), Health::Healthy);
}
#[test]
fn a_zeroed_policy_still_sleeps() {
let p = CallPolicy {
retry: RetryMode::UntilCancelled {
base: Duration::ZERO,
max: Duration::ZERO,
},
breaker: BreakerMode::FailFast,
};
assert_eq!(p.next_backoff(0), Some(MIN_BACKOFF));
assert_eq!(p.next_backoff(9), Some(MIN_BACKOFF));
let bounded = CallPolicy::bounded(RetryPolicy {
max_retries: 2,
base_backoff: Duration::ZERO,
max_backoff: Duration::ZERO,
});
assert_eq!(bounded.next_backoff(0), Some(MIN_BACKOFF));
assert_eq!(bounded.next_backoff(2), None, "the budget still runs out");
}
#[test]
fn retry_after_is_capped_by_the_mode() {
assert_eq!(CallPolicy::background().retry_after_cap(), None);
assert_eq!(
CallPolicy::essential().retry_after_cap(),
Some(Duration::from_secs(10))
);
assert_eq!(
CallPolicy::bounded(RetryPolicy::default()).retry_after_cap(),
Some(Duration::from_secs(10))
);
}
}