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 {
return b;
}
if 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}");
}
}
fn block(src: &str, start: &str, anchor: &str) -> String {
let at = src.find(anchor).unwrap_or_else(|| panic!("no {anchor}"));
let from = src[..at + anchor.len()]
.rfind(start)
.unwrap_or_else(|| panic!("no {start}"));
let open = from + src[from..].find('{').unwrap();
let mut depth = 0;
let mut end = open;
for (i, c) in src[open..].char_indices() {
match c {
'{' => depth += 1,
'}' => depth -= 1,
_ => {}
}
if depth == 0 {
end = open + i;
break;
}
}
let flat: String = src[open..=end]
.chars()
.filter(|c| !c.is_whitespace())
.collect();
flat.replace(",}", "}")
.replace(",)", ")")
.replace("},", "}")
}
#[test]
fn verus_bodies_match() {
let spec = include_str!("../verus/policy.rs");
let code = include_str!("policy.rs");
for (start, anchor) in [
(
"match outcome {",
"Outcome::Success | Outcome::ClientError => Step::Done",
),
(
"if secs <= u64::MAX / 1000 {",
"if secs <= u64::MAX / 1000 {",
),
] {
assert_eq!(
block(spec, start, anchor),
block(code, start, anchor),
"{start}"
);
}
}
#[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 };
let step = decide(o, attempt, idem, ra, &rule);
let wait = ra.unwrap_or(DEFAULT_RETRY_AFTER_MS);
if o == Outcome::RateLimited && attempt < max_attempts && wait <= cap {
prop_assert_eq!(step, Step::Retry(wait));
}
if matches!(o, Outcome::ServerError | Outcome::NetworkError) && idem && attempt < max_attempts {
prop_assert_eq!(step, Step::Retry(backoff_ms(init, attempt, cap)));
}
match step {
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_matches_closed_form(init: u64, n in 0u32..200, cap: u64) {
let exp = n.saturating_sub(1).min(64);
let want = (u128::from(init) << exp).min(u128::from(cap));
prop_assert_eq!(u128::from(backoff_ms(init, n, cap)), want);
}
#[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));
}
}
}