use std::time::Duration;
use crate::actions::Level;
use crate::telegram::error::{Attempt, Disposition, RetryReason, classify};
pub const DEFAULT_MAX_ATTEMPTS: u32 = 4;
pub const DEFAULT_MAX_RETRY_AFTER: u64 = 60;
pub trait Clock {
fn sleep(&self, duration: Duration);
}
pub struct SystemClock;
impl Clock for SystemClock {
fn sleep(&self, duration: Duration) {
std::thread::sleep(duration);
}
}
#[derive(Debug, Clone, Copy)]
pub struct RetryPolicy {
pub max_attempts: u32,
pub max_retry_after: u64,
pub jitter: bool,
}
impl Default for RetryPolicy {
fn default() -> Self {
RetryPolicy {
max_attempts: DEFAULT_MAX_ATTEMPTS,
max_retry_after: DEFAULT_MAX_RETRY_AFTER,
jitter: true,
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Outcome {
pub ok: bool,
pub message_id: Option<i64>,
pub http_status: u16,
pub error: Option<String>,
pub attempts: u32,
}
pub fn execute<F, C>(
mut attempt_fn: F,
policy: RetryPolicy,
clock: &C,
log: &dyn Fn(Level, &str),
) -> Outcome
where
F: FnMut(u32) -> Attempt,
C: Clock + ?Sized,
{
let mut jitter = Jitter::new();
let mut last_status = 0u16;
let mut last_error = String::from("send failed");
for attempt in 1..=policy.max_attempts {
let result = attempt_fn(attempt);
last_status = result.status;
last_error = result.error_text();
match classify(&result) {
Disposition::Success => {
return Outcome {
ok: true,
message_id: result.message_id(),
http_status: 200,
error: None,
attempts: attempt,
};
}
Disposition::Terminal => {
log(
Level::Error,
&format!("Telegram returned {} (no retry): {last_error}", result.status),
);
return Outcome {
ok: false,
message_id: None,
http_status: result.status,
error: Some(last_error),
attempts: attempt,
};
}
Disposition::Retry(reason) => {
if attempt == policy.max_attempts {
break;
}
let delay = match reason {
RetryReason::RateLimited => {
let requested = result.retry_after();
let capped = requested.min(policy.max_retry_after);
if capped < requested {
log(
Level::Warning,
&format!("429 retry_after={requested}s capped at {capped}s"),
);
}
Duration::from_secs(capped)
}
RetryReason::Transient => backoff(attempt, policy.jitter, &mut jitter),
};
log(
Level::Warning,
&format!(
"attempt {attempt}/{} failed (http={}, {last_error}); retrying in {:.1}s",
policy.max_attempts,
result.status,
delay.as_secs_f64()
),
);
clock.sleep(delay);
}
}
}
log(
Level::Warning,
&format!(
"send failed after {} attempts (last http_status={last_status})",
policy.max_attempts
),
);
Outcome {
ok: false,
message_id: None,
http_status: last_status,
error: Some(last_error),
attempts: policy.max_attempts,
}
}
fn backoff(attempt: u32, use_jitter: bool, jitter: &mut Jitter) -> Duration {
let base_ms = 1000u64 << (attempt - 1);
if !use_jitter {
return Duration::from_millis(base_ms);
}
Duration::from_millis(base_ms + jitter.next_below(base_ms / 2))
}
struct Jitter(u64);
impl Jitter {
fn new() -> Self {
let seed = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.subsec_nanos() as u64 ^ (d.as_secs().rotate_left(17)))
.unwrap_or(0x9E37_79B9_7F4A_7C15);
Jitter(seed | 1)
}
fn next_below(&mut self, bound: u64) -> u64 {
if bound == 0 {
return 0;
}
self.0 ^= self.0 << 13;
self.0 ^= self.0 >> 7;
self.0 ^= self.0 << 17;
self.0 % bound
}
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
use std::cell::RefCell;
#[derive(Default)]
struct FakeClock {
slept: RefCell<Vec<Duration>>,
}
impl Clock for FakeClock {
fn sleep(&self, duration: Duration) {
self.slept.borrow_mut().push(duration);
}
}
fn silent() -> impl Fn(Level, &str) {
|_, _| {}
}
fn ok_attempt() -> Attempt {
Attempt {
status: 200,
body: Some(json!({"ok": true, "result": {"message_id": 7}})),
retry_after_header: None,
network_error: None,
}
}
fn status_attempt(status: u16, description: &str) -> Attempt {
Attempt {
status,
body: Some(json!({"ok": false, "description": description})),
retry_after_header: None,
network_error: None,
}
}
fn policy() -> RetryPolicy {
RetryPolicy { jitter: false, ..RetryPolicy::default() }
}
#[test]
fn first_attempt_success_does_not_sleep() {
let clock = FakeClock::default();
let out = execute(|_| ok_attempt(), policy(), &clock, &silent());
assert_eq!(
out,
Outcome { ok: true, message_id: Some(7), http_status: 200, error: None, attempts: 1 }
);
assert!(clock.slept.borrow().is_empty());
}
#[test]
fn transient_failures_back_off_exponentially_then_give_up() {
let clock = FakeClock::default();
let out = execute(|_| status_attempt(503, "busy"), policy(), &clock, &silent());
assert!(!out.ok);
assert_eq!(out.attempts, 4);
assert_eq!(out.http_status, 503);
assert_eq!(out.error.as_deref(), Some("HTTP 503: busy"));
assert_eq!(
*clock.slept.borrow(),
vec![Duration::from_secs(1), Duration::from_secs(2), Duration::from_secs(4)]
);
}
#[test]
fn recovers_on_a_later_attempt() {
let clock = FakeClock::default();
let out = execute(
|n| if n < 3 { status_attempt(500, "oops") } else { ok_attempt() },
policy(),
&clock,
&silent(),
);
assert!(out.ok);
assert_eq!(out.attempts, 3);
assert_eq!(clock.slept.borrow().len(), 2);
}
#[test]
fn terminal_status_stops_immediately() {
let clock = FakeClock::default();
let out = execute(|_| status_attempt(401, "Unauthorized"), policy(), &clock, &silent());
assert_eq!(out.attempts, 1);
assert_eq!(out.http_status, 401);
assert_eq!(out.error.as_deref(), Some("Unauthorized"));
assert!(clock.slept.borrow().is_empty());
}
#[test]
fn rate_limit_honours_retry_after_and_the_cap() {
let clock = FakeClock::default();
let attempt = || Attempt {
status: 429,
body: Some(
json!({"ok": false, "description": "Too Many Requests", "parameters": {"retry_after": 300}}),
),
retry_after_header: None,
network_error: None,
};
let out = execute(
|_| attempt(),
RetryPolicy { max_retry_after: 30, ..policy() },
&clock,
&silent(),
);
assert!(!out.ok);
assert_eq!(*clock.slept.borrow(), vec![Duration::from_secs(30); 3]);
}
#[test]
fn network_failures_report_status_zero() {
let clock = FakeClock::default();
let out = execute(|_| Attempt::network("timed out"), policy(), &clock, &silent());
assert_eq!(out.http_status, 0);
assert_eq!(out.error.as_deref(), Some("network error (timed out)"));
}
#[test]
fn jitter_stays_within_half_the_base_delay() {
let mut j = Jitter::new();
for attempt in 1..=3u32 {
let base = Duration::from_millis(1000u64 << (attempt - 1));
let d = backoff(attempt, true, &mut j);
assert!(d >= base, "{d:?} < {base:?}");
assert!(d < base + base / 2, "{d:?} too large for base {base:?}");
}
}
}