use anyhow::Result;
use std::future::Future;
use std::time::Duration;
#[derive(Debug, Clone)]
pub struct RetryConfig {
pub max_retries: usize,
pub initial_backoff: Duration,
pub max_backoff: Duration,
}
impl Default for RetryConfig {
fn default() -> Self {
Self {
max_retries: 3,
initial_backoff: Duration::from_millis(100),
max_backoff: Duration::from_secs(5),
}
}
}
impl RetryConfig {
pub fn new(max_retries: usize, initial_backoff: Duration, max_backoff: Duration) -> Self {
Self {
max_retries,
initial_backoff,
max_backoff,
}
}
pub fn no_retry() -> Self {
Self {
max_retries: 0,
initial_backoff: Duration::from_millis(0),
max_backoff: Duration::from_millis(0),
}
}
pub fn fast() -> Self {
Self {
max_retries: 1,
initial_backoff: Duration::from_millis(50),
max_backoff: Duration::from_millis(500),
}
}
pub fn robust() -> Self {
Self {
max_retries: 5,
initial_backoff: Duration::from_millis(200),
max_backoff: Duration::from_secs(10),
}
}
}
pub async fn retry_with_backoff<F, Fut, T>(config: &RetryConfig, operation: F) -> Result<T>
where
F: Fn() -> Fut,
Fut: Future<Output = Result<T>>,
{
let mut retries = 0;
let mut backoff = config.initial_backoff;
loop {
match operation().await {
Ok(result) => {
if retries > 0 {
tracing::debug!("Operation succeeded after {} retry(ies)", retries);
}
return Ok(result);
}
Err(e) => {
if !is_retriable(&e) {
tracing::debug!("Non-retriable error encountered: {}", e);
return Err(e);
}
if retries >= config.max_retries {
tracing::debug!(
"Max retries ({}) exhausted, giving up: {}",
config.max_retries,
e
);
return Err(e);
}
retries += 1;
tracing::warn!(
"Attempt {}/{} failed: {}, retrying in {:?}",
retries,
config.max_retries,
e,
backoff
);
tokio::time::sleep(backoff).await;
backoff = std::cmp::min(backoff * 2, config.max_backoff);
}
}
}
}
fn is_retriable(error: &anyhow::Error) -> bool {
if let Some(io_err) = error.downcast_ref::<std::io::Error>() {
return is_io_error_retriable(io_err);
}
let error_msg = error.to_string().to_lowercase();
let retriable_patterns = [
"timeout",
"timed out",
"deadline",
"connection reset",
"reset by peer",
"connection aborted",
"broken pipe",
"network unreachable",
"host unreachable",
"temporary failure",
"try again",
];
let non_retriable_patterns = [
"connection refused",
"refused",
"failed to lookup",
"name or service not known",
"invalid dns name",
"no such host",
"permission denied",
"address not available",
];
for pattern in &non_retriable_patterns {
if error_msg.contains(pattern) {
return false;
}
}
for pattern in &retriable_patterns {
if error_msg.contains(pattern) {
return true;
}
}
false
}
fn is_io_error_retriable(error: &std::io::Error) -> bool {
use std::io::ErrorKind;
match error.kind() {
ErrorKind::TimedOut => true,
ErrorKind::ConnectionReset => true,
ErrorKind::ConnectionAborted => true,
ErrorKind::BrokenPipe => true,
ErrorKind::Interrupted => true,
ErrorKind::WouldBlock => true,
ErrorKind::ConnectionRefused => false,
ErrorKind::NotFound => false,
ErrorKind::PermissionDenied => false,
ErrorKind::AddrNotAvailable => false,
ErrorKind::AddrInUse => false,
ErrorKind::InvalidInput => false,
ErrorKind::InvalidData => false,
_ => {
let msg = error.to_string().to_lowercase();
if msg.contains("network is down") || msg.contains("os error 50") {
tracing::debug!(
"Detected ENETDOWN (error 50) - network saturation, will retry with backoff"
);
return true;
}
msg.contains("network unreachable")
|| msg.contains("host unreachable")
|| msg.contains("no route to host")
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::io::{Error, ErrorKind};
#[test]
fn test_retry_config_default() {
let config = RetryConfig::default();
assert_eq!(config.max_retries, 3);
assert_eq!(config.initial_backoff, Duration::from_millis(100));
assert_eq!(config.max_backoff, Duration::from_secs(5));
}
#[test]
fn test_retry_config_presets() {
let no_retry = RetryConfig::no_retry();
assert_eq!(no_retry.max_retries, 0);
let fast = RetryConfig::fast();
assert_eq!(fast.max_retries, 1);
assert_eq!(fast.initial_backoff, Duration::from_millis(50));
let robust = RetryConfig::robust();
assert_eq!(robust.max_retries, 5);
assert_eq!(robust.initial_backoff, Duration::from_millis(200));
}
#[test]
fn test_is_io_error_retriable() {
assert!(is_io_error_retriable(&Error::new(
ErrorKind::TimedOut,
"timeout"
)));
assert!(is_io_error_retriable(&Error::new(
ErrorKind::ConnectionReset,
"reset"
)));
assert!(is_io_error_retriable(&Error::new(
ErrorKind::ConnectionAborted,
"aborted"
)));
assert!(is_io_error_retriable(&Error::new(
ErrorKind::BrokenPipe,
"broken"
)));
assert!(is_io_error_retriable(&Error::new(
ErrorKind::Other,
"Network is down (os error 50)"
)));
assert!(!is_io_error_retriable(&Error::new(
ErrorKind::ConnectionRefused,
"refused"
)));
assert!(!is_io_error_retriable(&Error::new(
ErrorKind::NotFound,
"not found"
)));
assert!(!is_io_error_retriable(&Error::new(
ErrorKind::PermissionDenied,
"denied"
)));
assert!(!is_io_error_retriable(&Error::new(
ErrorKind::InvalidInput,
"invalid"
)));
}
#[test]
fn test_is_retriable_by_message() {
let timeout_err = anyhow::anyhow!("Connection timed out");
assert!(is_retriable(&timeout_err));
let reset_err = anyhow::anyhow!("Connection reset by peer");
assert!(is_retriable(&reset_err));
let refused_err = anyhow::anyhow!("Connection refused");
assert!(!is_retriable(&refused_err));
let dns_err = anyhow::anyhow!("Failed to lookup host");
assert!(!is_retriable(&dns_err));
let unknown_err = anyhow::anyhow!("Unknown error");
assert!(!is_retriable(&unknown_err));
}
#[tokio::test]
async fn test_retry_success_on_first_attempt() {
let config = RetryConfig::default();
use std::sync::atomic::{AtomicUsize, Ordering};
let attempts = AtomicUsize::new(0);
let result = retry_with_backoff(&config, || async {
attempts.fetch_add(1, Ordering::SeqCst);
Ok::<_, anyhow::Error>(42)
})
.await;
assert!(result.is_ok());
assert_eq!(result.unwrap(), 42);
assert_eq!(attempts.load(Ordering::SeqCst), 1);
}
#[tokio::test]
async fn test_retry_success_after_retries() {
let config = RetryConfig::default();
use std::sync::atomic::{AtomicUsize, Ordering};
let attempts = AtomicUsize::new(0);
let result = retry_with_backoff(&config, || async {
let current = attempts.fetch_add(1, Ordering::SeqCst) + 1;
if current < 3 {
Err(anyhow::anyhow!("Connection timed out"))
} else {
Ok::<_, anyhow::Error>(42)
}
})
.await;
assert!(result.is_ok());
assert_eq!(result.unwrap(), 42);
assert_eq!(attempts.load(Ordering::SeqCst), 3);
}
#[tokio::test]
async fn test_retry_exhaustion() {
let config = RetryConfig {
max_retries: 2,
initial_backoff: Duration::from_millis(1),
max_backoff: Duration::from_millis(10),
};
use std::sync::atomic::{AtomicUsize, Ordering};
let attempts = AtomicUsize::new(0);
let result = retry_with_backoff(&config, || async {
attempts.fetch_add(1, Ordering::SeqCst);
Err::<(), _>(anyhow::anyhow!("Connection timed out"))
})
.await;
assert!(result.is_err());
assert_eq!(attempts.load(Ordering::SeqCst), 3);
}
#[tokio::test]
async fn test_no_retry_on_non_retriable() {
let config = RetryConfig::default();
use std::sync::atomic::{AtomicUsize, Ordering};
let attempts = AtomicUsize::new(0);
let result = retry_with_backoff(&config, || async {
attempts.fetch_add(1, Ordering::SeqCst);
Err::<(), _>(anyhow::anyhow!("Connection refused"))
})
.await;
assert!(result.is_err());
assert_eq!(attempts.load(Ordering::SeqCst), 1);
}
#[tokio::test]
async fn test_exponential_backoff() {
let config = RetryConfig {
max_retries: 3,
initial_backoff: Duration::from_millis(10),
max_backoff: Duration::from_millis(50),
};
let start = std::time::Instant::now();
use std::sync::atomic::{AtomicUsize, Ordering};
let attempts = AtomicUsize::new(0);
let _result = retry_with_backoff(&config, || async {
attempts.fetch_add(1, Ordering::SeqCst);
Err::<(), _>(anyhow::anyhow!("timeout"))
})
.await;
let elapsed = start.elapsed();
assert!(elapsed >= Duration::from_millis(70));
assert!(elapsed < Duration::from_millis(150));
assert_eq!(attempts.load(Ordering::SeqCst), 4); }
}