use super::*;
use std::sync::Arc;
use std::sync::atomic::{AtomicUsize, Ordering};
#[test]
fn classify_missing_token_is_permanent() {
let err = anyhow::anyhow!("TELEGRAM_BOT_TOKEN is required (or pass --check)");
assert_eq!(classify_error(&err), BotExit::Permanent);
let invalid = anyhow::anyhow!("Not a valid bot token");
assert_eq!(classify_error(&invalid), BotExit::Permanent);
}
#[test]
fn classify_other_is_transient() {
let conflict = anyhow::anyhow!("terminated by other getUpdates request (409 Conflict)");
assert_eq!(classify_error(&conflict), BotExit::Transient);
let network = anyhow::anyhow!("error sending request: connection reset");
assert_eq!(classify_error(&network), BotExit::Transient);
}
#[test]
fn backoff_grows_and_caps() {
let policy = BackoffPolicy {
base: Duration::from_millis(500),
factor: 2,
max: Duration::from_secs(30),
healthy_run: Duration::from_secs(60),
};
assert_eq!(policy.delay_for(0), Duration::from_millis(500));
assert_eq!(policy.delay_for(1), Duration::from_millis(500));
assert_eq!(policy.delay_for(2), Duration::from_secs(1));
assert_eq!(policy.delay_for(3), Duration::from_secs(2));
assert_eq!(policy.delay_for(6), Duration::from_secs(16));
assert_eq!(policy.delay_for(7), Duration::from_secs(30));
assert_eq!(policy.delay_for(100), Duration::from_secs(30));
}
#[test]
fn default_policy_is_bounded() {
let p = BackoffPolicy::default();
assert_eq!(p.base, Duration::from_millis(500));
assert_eq!(p.max, Duration::from_secs(30));
assert_eq!(p.healthy_run, Duration::from_secs(60));
assert!(p.delay_for(u32::MAX) <= p.max);
}
#[test]
fn healthy_run_resets_transient_count() {
let policy = BackoffPolicy {
base: Duration::from_millis(1),
factor: 2,
max: Duration::from_millis(8),
healthy_run: Duration::from_millis(50),
};
let short = Duration::from_millis(0);
let healthy = Duration::from_millis(50);
assert_eq!(policy.next_transient_count(0, short), 1);
assert_eq!(policy.next_transient_count(1, short), 2);
assert_eq!(policy.next_transient_count(7, short), 8);
assert_eq!(policy.delay_for(8), policy.max);
assert_eq!(policy.next_transient_count(8, healthy), 1);
assert_eq!(policy.next_transient_count(u32::MAX, healthy), 1);
assert_eq!(policy.delay_for(1), policy.base);
assert_eq!(
policy.next_transient_count(5, policy.healthy_run),
1,
"a run lasting exactly healthy_run must reset the counter"
);
assert_eq!(
policy.next_transient_count(5, policy.healthy_run - Duration::from_millis(1)),
6,
"a sub-threshold run must keep the backoff escalating"
);
}
#[tokio::test]
async fn healthy_run_resets_transient_backoff() {
let policy = BackoffPolicy {
base: Duration::from_millis(2),
factor: 4,
max: Duration::from_millis(400),
healthy_run: Duration::from_millis(20),
};
let starts: Arc<std::sync::Mutex<Vec<tokio::time::Instant>>> =
Arc::new(std::sync::Mutex::new(Vec::new()));
let attempts = Arc::new(AtomicUsize::new(0));
let a = Arc::clone(&attempts);
let s = Arc::clone(&starts);
let healthy_for = policy.healthy_run;
let factory = move || {
let a = Arc::clone(&a);
let s = Arc::clone(&s);
async move {
let n = a.fetch_add(1, Ordering::SeqCst);
s.lock().unwrap().push(tokio::time::Instant::now());
if n == 3 {
tokio::time::sleep(healthy_for + Duration::from_millis(5)).await;
BotExit::Transient
} else if n < 3 {
BotExit::Transient
} else {
BotExit::Graceful
}
}
};
supervise(factory, policy, CancellationToken::new()).await;
assert_eq!(attempts.load(Ordering::SeqCst), 5);
let starts = starts.lock().unwrap();
assert_eq!(starts.len(), 5);
let escalated_gap = starts[3].duration_since(starts[2]);
assert!(
escalated_gap >= Duration::from_millis(25),
"pre-reset backoff should be escalated (>=~32ms), saw {escalated_gap:?}"
);
let post_healthy_total = starts[4].duration_since(starts[3]);
let backoff_component = post_healthy_total.saturating_sub(healthy_for);
assert!(
backoff_component < Duration::from_millis(20),
"post-healthy backoff must reset to ~base, isolated backoff was {backoff_component:?} \
(total {post_healthy_total:?})"
);
}
fn fast_policy() -> BackoffPolicy {
BackoffPolicy {
base: Duration::from_millis(1),
factor: 2,
max: Duration::from_millis(8),
healthy_run: Duration::from_secs(60),
}
}
#[tokio::test]
async fn restarts_after_transient_then_succeeds() {
let attempts = Arc::new(AtomicUsize::new(0));
let a = Arc::clone(&attempts);
let factory = move || {
let a = Arc::clone(&a);
async move {
let n = a.fetch_add(1, Ordering::SeqCst);
if n < 2 {
BotExit::Transient
} else {
BotExit::Graceful
}
}
};
supervise(factory, fast_policy(), CancellationToken::new()).await;
assert_eq!(
attempts.load(Ordering::SeqCst),
3,
"expected 2 restarts after transient failures, then a graceful exit"
);
}
#[tokio::test]
async fn gives_up_after_permanent_budget() {
let attempts = Arc::new(AtomicUsize::new(0));
let a = Arc::clone(&attempts);
let factory = move || {
let a = Arc::clone(&a);
async move {
a.fetch_add(1, Ordering::SeqCst);
BotExit::Permanent
}
};
supervise(factory, fast_policy(), CancellationToken::new()).await;
assert_eq!(
attempts.load(Ordering::SeqCst),
MAX_PERMANENT_FAILURES as usize,
"permanent failures must give up at the budget, not loop forever"
);
}
#[tokio::test]
async fn transient_resets_permanent_counter() {
let script = Arc::new([
BotExit::Permanent,
BotExit::Permanent,
BotExit::Transient,
BotExit::Permanent,
BotExit::Permanent,
BotExit::Graceful,
]);
let attempts = Arc::new(AtomicUsize::new(0));
let a = Arc::clone(&attempts);
let s = Arc::clone(&script);
let factory = move || {
let a = Arc::clone(&a);
let s = Arc::clone(&s);
async move {
let n = a.fetch_add(1, Ordering::SeqCst);
s[n]
}
};
supervise(factory, fast_policy(), CancellationToken::new()).await;
assert_eq!(attempts.load(Ordering::SeqCst), 6);
}
#[tokio::test]
async fn stops_on_cancellation() {
let attempts = Arc::new(AtomicUsize::new(0));
let a = Arc::clone(&attempts);
let factory = move || {
let a = Arc::clone(&a);
async move {
a.fetch_add(1, Ordering::SeqCst);
BotExit::Transient
}
};
let token = CancellationToken::new();
token.cancel();
supervise(factory, fast_policy(), token).await;
assert_eq!(
attempts.load(Ordering::SeqCst),
0,
"a pre-cancelled supervisor must not start the bot"
);
}
#[tokio::test]
async fn backoff_sleep_is_cancellable() {
let attempts = Arc::new(AtomicUsize::new(0));
let a = Arc::clone(&attempts);
let token = CancellationToken::new();
let cancel_token = token.clone();
let policy = BackoffPolicy {
base: Duration::from_secs(3600),
factor: 2,
max: Duration::from_secs(3600),
healthy_run: Duration::from_secs(60),
};
let factory = move || {
let a = Arc::clone(&a);
let cancel_token = cancel_token.clone();
async move {
let n = a.fetch_add(1, Ordering::SeqCst);
if n == 0 {
cancel_token.cancel();
}
BotExit::Transient
}
};
supervise(factory, policy, token).await;
assert_eq!(
attempts.load(Ordering::SeqCst),
1,
"supervisor must exit out of the backoff sleep on cancellation"
);
}