use crate::vta_receive_leg::ReceiveHealth;
use std::time::Duration;
#[must_use]
pub fn replies_not_arriving_summary(consecutive_timeouts: u32) -> String {
format!(
"Your VTA received the request, but its replies aren't reaching this app — its \
message inbox isn't being collected ({consecutive_timeouts} replies missed in a \
row)."
)
}
#[must_use]
pub fn replies_not_arriving_text(consecutive_timeouts: u32) -> String {
format!(
"{} Reconnecting…",
replies_not_arriving_summary(consecutive_timeouts)
)
}
#[must_use]
pub fn describe_health(health: &ReceiveHealth) -> String {
let last = match health.since_last_reply {
Some(age) => format!("last reply {} ago", short_duration(age)),
None => "no reply yet this session".to_string(),
};
match health.consecutive_reply_timeouts {
0 => last,
1 => format!("{last} (1 reply missed)"),
n => format!("{last} ({n} missed in a row)"),
}
}
#[must_use]
pub fn short_duration(d: Duration) -> String {
let s = d.as_secs();
match s {
0..=59 => format!("{s}s"),
60..=3599 => format!("{}m {}s", s / 60, s % 60),
_ => format!("{}h {}m", s / 3600, (s % 3600) / 60),
}
}
pub const REBUILD_BACKOFF_BASE: Duration = Duration::from_secs(5);
pub const REBUILD_BACKOFF_CAP: Duration = Duration::from_secs(300);
pub const REBUILD_BACKOFF_JITTER: f64 = 0.2;
#[derive(Clone, Debug, Default, PartialEq, Eq)]
pub struct RebuildBackoff {
attempt: u32,
}
impl RebuildBackoff {
#[must_use]
pub fn attempt(&self) -> u32 {
self.attempt
}
#[must_use]
pub fn nominal(attempt: u32) -> Duration {
let factor = 1u32 << attempt.min(6);
REBUILD_BACKOFF_BASE
.saturating_mul(factor)
.min(REBUILD_BACKOFF_CAP)
}
pub fn next_delay(&mut self, unit: f64) -> Duration {
let nominal = Self::nominal(self.attempt);
self.attempt = self.attempt.saturating_add(1);
let unit = unit.clamp(0.0, 1.0);
let factor = 1.0 + REBUILD_BACKOFF_JITTER * (2.0 * unit - 1.0);
nominal.mul_f64(factor).min(REBUILD_BACKOFF_CAP)
}
pub fn reset(&mut self) {
self.attempt = 0;
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn replies_not_arriving_reads_as_its_own_state() {
let text = replies_not_arriving_text(2);
assert!(text.starts_with(&replies_not_arriving_summary(2)));
assert!(text.contains("received the request"), "{text}");
assert!(text.contains("replies aren't reaching this app"), "{text}");
assert!(text.contains("inbox isn't being collected"), "{text}");
assert!(text.contains("2 replies missed"), "{text}");
assert!(text.ends_with("Reconnecting…"), "{text}");
let lower = text.to_lowercase();
for wrong in [
"unreachable",
"could not reach",
"auth",
"not accept",
"rejected",
] {
assert!(!lower.contains(wrong), "{wrong:?} in {text}");
}
}
#[test]
fn health_reads_for_a_person() {
let mut h = ReceiveHealth::default();
assert_eq!(describe_health(&h), "no reply yet this session");
h.since_last_reply = Some(Duration::from_secs(12));
assert_eq!(describe_health(&h), "last reply 12s ago");
h.consecutive_reply_timeouts = 3;
h.since_last_reply = Some(Duration::from_secs(250));
assert_eq!(
describe_health(&h),
"last reply 4m 10s ago (3 missed in a row)"
);
h.consecutive_reply_timeouts = 1;
assert!(describe_health(&h).ends_with("(1 reply missed)"));
assert_eq!(short_duration(Duration::from_secs(3_725)), "1h 2m");
}
#[test]
fn backoff_doubles_from_five_seconds_to_five_minutes() {
let mut b = RebuildBackoff::default();
let got: Vec<u64> = (0..9).map(|_| b.next_delay(0.5).as_secs()).collect();
assert_eq!(got, vec![5, 10, 20, 40, 80, 160, 300, 300, 300]);
assert_eq!(b.attempt(), 9);
}
#[test]
fn backoff_jitter_stays_within_bounds_and_under_the_cap() {
for attempt in 0..20 {
let nominal = RebuildBackoff::nominal(attempt);
for unit in [0.0, 0.25, 0.5, 0.75, 0.999_999] {
let mut b = RebuildBackoff { attempt };
let d = b.next_delay(unit);
assert!(d <= REBUILD_BACKOFF_CAP, "{d:?} past the cap");
assert!(
d >= nominal.mul_f64(1.0 - REBUILD_BACKOFF_JITTER) - Duration::from_millis(1)
);
assert!(
d <= nominal.mul_f64(1.0 + REBUILD_BACKOFF_JITTER) + Duration::from_millis(1)
);
}
}
let lo = RebuildBackoff::default().next_delay(0.0);
let hi = RebuildBackoff::default().next_delay(0.99);
assert!(lo < hi);
assert_eq!(lo, Duration::from_secs(4));
}
#[test]
fn backoff_resets_to_the_base() {
let mut b = RebuildBackoff::default();
for _ in 0..5 {
b.next_delay(0.5);
}
b.reset();
assert_eq!(b.attempt(), 0);
assert_eq!(b.next_delay(0.5), REBUILD_BACKOFF_BASE);
}
#[test]
fn backoff_does_not_overflow_on_a_long_outage() {
let mut b = RebuildBackoff { attempt: u32::MAX };
assert!(b.next_delay(0.5) <= REBUILD_BACKOFF_CAP);
assert_eq!(b.attempt(), u32::MAX);
}
}