pub const DEFAULT_TOLERANCE_SECS: u64 = 300;
pub const MAX_ATTEMPTS: u32 = 10;
pub const DEFAULT_RETRY_AFTER_MS: u64 = 1_000;
#[must_use]
pub const fn timestamp_fresh(now: u64, ts: u64, tolerance: u64) -> bool {
now.abs_diff(ts) <= tolerance
}
#[must_use]
pub const fn backoff_ms(initial: u64, attempt: u32, cap: u64) -> u64 {
let mut b = if initial <= cap { initial } else { cap };
let mut i = 1;
while i < attempt {
if b == cap || b == 0 {
return b;
}
b = if cap - b <= b { cap } else { b * 2 };
i += 1;
}
b
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Outcome {
Success,
RateLimited,
ServerError,
NetworkError,
ClientError,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Step {
Done,
Retry(u64),
GiveUp,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct RetryRule {
pub max_attempts: u32,
pub initial_backoff_ms: u64,
pub max_wait_ms: u64,
}
#[must_use]
pub const fn decide(
outcome: Outcome,
attempt: u32,
idempotent: bool,
retry_after_ms: Option<u64>,
rule: &RetryRule,
) -> Step {
match outcome {
Outcome::Success | Outcome::ClientError => Step::Done,
Outcome::RateLimited => {
let wait = match retry_after_ms {
Some(w) => w,
None => DEFAULT_RETRY_AFTER_MS,
};
if attempt < rule.max_attempts && wait <= rule.max_wait_ms {
Step::Retry(wait)
} else {
Step::GiveUp
}
}
Outcome::ServerError | Outcome::NetworkError => {
if idempotent && attempt < rule.max_attempts {
Step::Retry(backoff_ms(
rule.initial_backoff_ms,
attempt,
rule.max_wait_ms,
))
} else {
Step::GiveUp
}
}
}
}
#[must_use]
pub const fn retry_after_ms(secs: u64) -> u64 {
if secs <= u64::MAX / 1000 {
secs * 1000
} else {
u64::MAX
}
}
#[cfg(test)]
mod tests {
use super::*;
use proptest::prelude::*;
const RULE: RetryRule = RetryRule {
max_attempts: 3,
initial_backoff_ms: 100,
max_wait_ms: 1_000,
};
#[test]
fn verus_constants_match() {
let spec = include_str!("../verus/policy.rs");
for line in [
"pub const DEFAULT_TOLERANCE_SECS: u64 = 300;",
"pub const MAX_ATTEMPTS: u32 = 10;",
"pub const DEFAULT_RETRY_AFTER_MS: u64 = 1_000;",
] {
assert!(spec.contains(line), "verus/policy.rs lacks {line}");
}
}
#[test]
fn fresh_edges() {
assert!(timestamp_fresh(1_000, 700, 300));
assert!(!timestamp_fresh(1_000, 699, 300));
assert!(timestamp_fresh(1_000, 1_300, 300));
assert!(!timestamp_fresh(1_000, 1_301, 300));
assert!(timestamp_fresh(u64::MAX, u64::MAX, 0));
assert!(!timestamp_fresh(0, u64::MAX, u64::MAX - 1));
}
#[test]
fn backoff_doubles_and_caps() {
assert_eq!(backoff_ms(100, 0, 1_000), 100);
assert_eq!(backoff_ms(100, 1, 1_000), 100);
assert_eq!(backoff_ms(100, 2, 1_000), 200);
assert_eq!(backoff_ms(100, 4, 1_000), 800);
assert_eq!(backoff_ms(100, 5, 1_000), 1_000);
assert_eq!(backoff_ms(5_000, 1, 1_000), 1_000);
assert_eq!(backoff_ms(u64::MAX, 60, u64::MAX), u64::MAX);
assert_eq!(backoff_ms(1, u32::MAX, 8), 8);
assert_eq!(backoff_ms(0, u32::MAX, 8), 0);
}
#[test]
fn decide_table() {
use Outcome::*;
assert_eq!(decide(Success, 1, false, None, &RULE), Step::Done);
assert_eq!(decide(ClientError, 1, true, None, &RULE), Step::Done);
assert_eq!(
decide(RateLimited, 1, false, Some(500), &RULE),
Step::Retry(500)
);
assert_eq!(
decide(RateLimited, 1, false, None, &RULE),
Step::Retry(1_000)
);
assert_eq!(
decide(RateLimited, 1, false, Some(1_001), &RULE),
Step::GiveUp
);
assert_eq!(decide(RateLimited, 3, false, Some(1), &RULE), Step::GiveUp);
assert_eq!(decide(ServerError, 1, false, None, &RULE), Step::GiveUp);
assert_eq!(decide(ServerError, 2, true, None, &RULE), Step::Retry(200));
assert_eq!(decide(NetworkError, 1, true, None, &RULE), Step::Retry(100));
assert_eq!(decide(NetworkError, 3, true, None, &RULE), Step::GiveUp);
}
#[test]
fn retry_after_saturates() {
assert_eq!(retry_after_ms(3), 3_000);
assert_eq!(retry_after_ms(u64::MAX), u64::MAX);
}
fn outcome() -> impl Strategy<Value = Outcome> {
prop_oneof![
Just(Outcome::Success),
Just(Outcome::RateLimited),
Just(Outcome::ServerError),
Just(Outcome::NetworkError),
Just(Outcome::ClientError),
]
}
proptest! {
#[test]
fn decide_invariants(
o in outcome(),
attempt in 0u32..20,
idem: bool,
ra in proptest::option::of(0u64..5_000),
max_attempts in 1u32..=MAX_ATTEMPTS,
init in 0u64..10_000,
cap in 0u64..10_000,
) {
let rule = RetryRule { max_attempts, initial_backoff_ms: init, max_wait_ms: cap };
match decide(o, attempt, idem, ra, &rule) {
Step::Retry(w) => {
prop_assert!(attempt < max_attempts);
prop_assert!(w <= cap);
prop_assert!(o == Outcome::RateLimited || idem);
}
Step::Done => prop_assert!(matches!(o, Outcome::Success | Outcome::ClientError)),
Step::GiveUp => prop_assert!(!matches!(o, Outcome::Success | Outcome::ClientError)),
}
}
#[test]
fn fresh_is_symmetric(a: u64, b: u64, t: u64) {
prop_assert_eq!(timestamp_fresh(a, b, t), timestamp_fresh(b, a, t));
prop_assert_eq!(timestamp_fresh(a, b, t), a.abs_diff(b) <= t);
}
#[test]
fn backoff_is_monotonic_and_capped(init in 0u64..1_000_000, n in 1u32..70, cap: u64) {
let a = backoff_ms(init, n, cap);
prop_assert!(a <= cap);
prop_assert!(a <= backoff_ms(init, n + 1, cap));
}
}
}