use std::time::{Duration, Instant};
use crate::client::RetryConfig;
use crate::util::{exponential_backoff, jittered_duration, retry_after_or_backoff};
use crate::{Error, ErrorClass, Result};
pub(crate) fn backoff_delay(
base: Duration,
max: Duration,
attempt: usize,
jitter_ratio: f64,
) -> Duration {
let delay = exponential_backoff(base, max, attempt);
jittered_duration(delay, jitter_ratio, max)
}
pub(crate) fn method_allows_retry(method: &str, retry: &RetryConfig) -> bool {
retry.allow_non_idempotent_retries || is_read_only_telegram_method(method)
}
fn is_read_only_telegram_method(method: &str) -> bool {
let method = method.trim_start_matches('/');
let Some(next) = method.as_bytes().get(3) else {
return false;
};
method.starts_with("get") && next.is_ascii_uppercase()
}
fn should_retry_method_error(
method: &str,
retry: &RetryConfig,
error: &Error,
attempt: usize,
max_attempts: usize,
) -> bool {
if !error.is_retryable() || attempt >= max_attempts {
return false;
}
error.classification() == ErrorClass::RateLimited || method_allows_retry(method, retry)
}
fn retry_deadline(total_timeout: Option<Duration>) -> Option<Instant> {
total_timeout.and_then(|timeout| Instant::now().checked_add(timeout))
}
fn remaining_total_timeout(deadline: Option<Instant>) -> Option<Duration> {
deadline.map(|deadline| deadline.saturating_duration_since(Instant::now()))
}
fn can_start_attempt(deadline: Option<Instant>) -> bool {
remaining_total_timeout(deadline).is_none_or(|remaining| !remaining.is_zero())
}
fn can_wait_before_retry(deadline: Option<Instant>, delay: Duration) -> bool {
remaining_total_timeout(deadline).is_none_or(|remaining| delay < remaining)
}
#[cfg(feature = "_async")]
pub(crate) async fn retry_method_async<T, F, Fut>(
method: &str,
retry: &RetryConfig,
total_timeout: Option<Duration>,
mut op: F,
) -> Result<T>
where
F: FnMut(Option<Duration>) -> Fut,
Fut: std::future::Future<Output = Result<T>>,
{
retry.validate()?;
let deadline = retry_deadline(total_timeout);
let max_attempts = retry.max_attempts;
let mut attempt = 0;
loop {
if !can_start_attempt(deadline) {
return Err(Error::Transport {
method: method.to_owned(),
status: None,
request_id: None,
retry_after: None,
request_path: None,
message: "total timeout elapsed before retry attempt".into(),
});
}
attempt += 1;
match op(remaining_total_timeout(deadline)).await {
Ok(value) => return Ok(value),
Err(error) => {
if !should_retry_method_error(method, retry, &error, attempt, max_attempts) {
return Err(error);
}
let delay = retry_after_or_backoff(&error, || {
backoff_delay(
retry.base_backoff,
retry.max_backoff,
attempt,
retry.jitter_ratio,
)
});
if !can_wait_before_retry(deadline, delay) {
return Err(error);
}
tokio::time::sleep(delay).await;
}
}
}
}
#[cfg(feature = "_blocking")]
pub(crate) fn retry_method_blocking<T, F>(
method: &str,
retry: &RetryConfig,
total_timeout: Option<Duration>,
mut op: F,
) -> Result<T>
where
F: FnMut(Option<Duration>) -> Result<T>,
{
retry.validate()?;
let deadline = retry_deadline(total_timeout);
let max_attempts = retry.max_attempts;
let mut attempt = 0;
loop {
if !can_start_attempt(deadline) {
return Err(Error::Transport {
method: method.to_owned(),
status: None,
request_id: None,
retry_after: None,
request_path: None,
message: "total timeout elapsed before retry attempt".into(),
});
}
attempt += 1;
match op(remaining_total_timeout(deadline)) {
Ok(value) => return Ok(value),
Err(error) => {
if !should_retry_method_error(method, retry, &error, attempt, max_attempts) {
return Err(error);
}
let delay = retry_after_or_backoff(&error, || {
backoff_delay(
retry.base_backoff,
retry.max_backoff,
attempt,
retry.jitter_ratio,
)
});
if !can_wait_before_retry(deadline, delay) {
return Err(error);
}
std::thread::sleep(delay);
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
fn retryable_transport_error(status: u16) -> Error {
Error::Transport {
method: "sendMessage".to_owned(),
status: Some(status),
request_id: None,
retry_after: None,
request_path: None,
message: "retryable transport failure".into(),
}
}
#[test]
fn method_retry_policy_is_conservative_by_default() {
let retry = RetryConfig::default();
assert!(method_allows_retry("getMe", &retry));
assert!(method_allows_retry("getAvailableGifts", &retry));
assert!(method_allows_retry("/getMe", &retry));
assert!(!method_allows_retry("sendMessage", &retry));
assert!(!method_allows_retry("getme", &retry));
}
#[test]
fn method_retry_policy_can_opt_into_mutating_methods() {
let retry = RetryConfig {
allow_non_idempotent_retries: true,
..RetryConfig::default()
};
assert!(method_allows_retry("sendMessage", &retry));
}
#[test]
fn non_idempotent_methods_skip_ambiguous_retries_by_default() {
let retry = RetryConfig::default();
let error = retryable_transport_error(502);
assert!(!should_retry_method_error(
"sendMessage",
&retry,
&error,
1,
3
));
}
#[test]
fn rate_limited_methods_retry_even_when_mutating() {
let retry = RetryConfig::default();
let error = retryable_transport_error(429);
assert!(should_retry_method_error(
"sendMessage",
&retry,
&error,
1,
3
));
}
#[test]
fn retry_after_header_does_not_bypass_non_idempotent_policy() {
let retry = RetryConfig::default();
let error = Error::Transport {
method: "sendMessage".to_owned(),
status: Some(503),
request_id: None,
retry_after: Some(Duration::from_secs(1)),
request_path: None,
message: "service unavailable".into(),
};
assert!(!should_retry_method_error(
"sendMessage",
&retry,
&error,
1,
3
));
assert!(should_retry_method_error("getMe", &retry, &error, 1, 3));
}
}