use std::time::Duration;
use tokio_util::sync::CancellationToken;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum BotExit {
Graceful,
Transient,
Permanent,
}
pub fn classify_error(err: &anyhow::Error) -> BotExit {
let msg = err.to_string().to_ascii_lowercase();
let permanent = msg.contains("token is required")
|| msg.contains("invalid token")
|| msg.contains("not a valid bot token")
|| msg.contains("unauthorized");
if permanent {
BotExit::Permanent
} else {
BotExit::Transient
}
}
#[derive(Debug, Clone, Copy)]
pub struct BackoffPolicy {
pub base: Duration,
pub factor: u32,
pub max: Duration,
pub healthy_run: Duration,
}
impl Default for BackoffPolicy {
fn default() -> Self {
Self {
base: Duration::from_millis(500),
factor: 2,
max: Duration::from_secs(30),
healthy_run: Duration::from_secs(60),
}
}
}
impl BackoffPolicy {
pub fn delay_for(&self, failures: u32) -> Duration {
let exp = failures.saturating_sub(1);
let mut delay = self.base;
for _ in 0..exp {
match delay.checked_mul(self.factor) {
Some(d) if d <= self.max => delay = d,
_ => return self.max,
}
}
delay.min(self.max)
}
pub fn next_transient_count(&self, prior: u32, run_duration: Duration) -> u32 {
let effective_prior = if run_duration >= self.healthy_run {
0
} else {
prior
};
effective_prior.saturating_add(1)
}
}
const MAX_PERMANENT_FAILURES: u32 = 3;
pub async fn supervise<F, Fut>(factory: F, policy: BackoffPolicy, shutdown: CancellationToken)
where
F: Fn() -> Fut,
Fut: std::future::Future<Output = BotExit>,
{
let mut transient_failures: u32 = 0;
let mut permanent_failures: u32 = 0;
loop {
if shutdown.is_cancelled() {
tracing::info!("telegram supervisor: shutdown requested — not restarting bot");
return;
}
let started = tokio::time::Instant::now();
let exit = tokio::select! {
biased;
_ = shutdown.cancelled() => {
tracing::info!("telegram supervisor: shutdown during bot run — exiting");
return;
}
exit = factory() => exit,
};
let run_duration = started.elapsed();
match exit {
BotExit::Graceful => {
tracing::info!("telegram bot stopped gracefully — supervisor exiting");
return;
}
BotExit::Transient => {
permanent_failures = 0;
let prior = transient_failures;
transient_failures = policy.next_transient_count(prior, run_duration);
if prior > 0 && transient_failures == 1 {
tracing::info!(
"telegram bot ran healthily for {run_duration:?} before exiting \
transiently — resetting backoff to base"
);
}
let delay = policy.delay_for(transient_failures);
tracing::warn!(
"telegram bot exited (transient, attempt {transient_failures}); \
restarting in {delay:?}"
);
if !sleep_or_cancel(delay, &shutdown).await {
tracing::info!("telegram supervisor: shutdown during backoff — exiting");
return;
}
}
BotExit::Permanent => {
transient_failures = 0;
permanent_failures = permanent_failures.saturating_add(1);
if permanent_failures >= MAX_PERMANENT_FAILURES {
tracing::error!(
"telegram bot exited with a permanent error {permanent_failures} \
times in a row — giving up. Fix TELEGRAM_BOT_TOKEN (and bot config) \
then restart the daemon to re-enable the Telegram surface."
);
return;
}
let delay = policy.max;
tracing::error!(
"telegram bot exited with a permanent error (attempt {permanent_failures}/\
{MAX_PERMANENT_FAILURES}); retrying in {delay:?} in case config was just \
updated"
);
if !sleep_or_cancel(delay, &shutdown).await {
tracing::info!("telegram supervisor: shutdown during backoff — exiting");
return;
}
}
}
}
}
async fn sleep_or_cancel(delay: Duration, shutdown: &CancellationToken) -> bool {
tokio::select! {
biased;
_ = shutdown.cancelled() => false,
_ = tokio::time::sleep(delay) => true,
}
}
#[cfg(test)]
#[path = "supervisor_tests.rs"]
mod tests;