use crate::config::RetryConfig;
use crate::error::{Error, Result};
use rand::RngExt;
use std::future::Future;
use std::time::Duration;
use tracing::debug;
const MIN_RETRY_AFTER: Duration = Duration::from_millis(100);
#[derive(Clone, Debug)]
pub struct RetryPolicy {
config: RetryConfig,
}
impl RetryPolicy {
pub fn new(config: &RetryConfig) -> Self {
Self {
config: config.clone(),
}
}
pub async fn execute<F, Fut, T>(&self, f: F) -> Result<T>
where
F: FnMut() -> Fut,
Fut: Future<Output = Result<T>>,
{
self.execute_until(None, f).await
}
pub async fn execute_until<F, Fut, T>(
&self,
deadline: Option<tokio::time::Instant>,
mut f: F,
) -> Result<T>
where
F: FnMut() -> Fut,
Fut: Future<Output = Result<T>>,
{
let mut retries = 0;
loop {
match f().await {
Ok(result) => {
if retries > 0 {
metrics::counter!(
crate::metrics::RETRY_ATTEMPTS_TOTAL,
"outcome" => "succeeded",
)
.increment(retries as u64);
}
return Ok(result);
}
Err(error) => {
if !self.should_retry(&error, retries) {
debug!(
"Not retrying (attempt {} of {})",
retries, self.config.max_retries
);
if retries > 0 {
metrics::counter!(
crate::metrics::RETRY_ATTEMPTS_TOTAL,
"outcome" => "exhausted",
)
.increment(retries as u64);
}
return Err(error);
}
let delay = if let Some(retry_after) = error.retry_after() {
debug!(
"Rate limited by server, waiting {}s (attempt {}/{})",
retry_after.as_secs(),
retries + 1,
self.config.max_retries
);
retry_after.max(MIN_RETRY_AFTER)
} else {
let delay = self.calculate_backoff(retries);
debug!(
"Retrying in {:?} (attempt {}/{})",
delay,
retries + 1,
self.config.max_retries
);
delay
};
if deadline.is_some_and(|d| tokio::time::Instant::now() + delay >= d) {
debug!("Retry would outlast the caller's deadline; giving up");
return Err(error);
}
tokio::time::sleep(delay).await;
retries += 1;
}
}
}
}
fn should_retry(&self, error: &Error, retries: u32) -> bool {
if retries >= self.config.max_retries {
return false;
}
match error {
Error::Http { status, .. } | Error::Broker { status, .. } => {
self.config.retry_on_status.contains(&status.as_u16())
}
_ => error.is_retryable(),
}
}
fn calculate_backoff(&self, attempt: u32) -> Duration {
let multiplier = self.config.backoff_multiplier;
let initial = self.config.initial_backoff.as_secs_f64();
let exp = multiplier.powi(attempt as i32);
let base_delay = initial * exp;
let max_secs = self.config.max_backoff.as_secs_f64();
let capped_delay = base_delay.min(max_secs);
let mut rng = rand::rng();
let jittered_secs = rng.random_range(0.0..=capped_delay);
Duration::from_secs_f64(jittered_secs)
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::config::RetryConfig;
#[tokio::test]
async fn test_retry_on_retryable_error() {
let config = RetryConfig {
max_retries: 3,
initial_backoff: Duration::from_millis(10),
max_backoff: Duration::from_millis(100),
backoff_multiplier: 2.0,
retry_on_status: vec![500, 502, 503],
};
let policy = RetryPolicy::new(&config);
let mut attempts = 0;
let result: Result<i32> = policy
.execute(|| {
attempts += 1;
async move {
if attempts < 3 {
Err(Error::ConnectionClosed)
} else {
Ok(42)
}
}
})
.await;
assert!(result.is_ok(), "Should succeed after retries");
assert_eq!(attempts, 3, "Should attempt 3 times");
}
#[tokio::test]
async fn test_no_retry_on_non_retryable_error() {
let config = RetryConfig::default();
let policy = RetryPolicy::new(&config);
let mut attempts = 0;
let result: Result<i32> = policy
.execute(|| {
attempts += 1;
async move { Err(Error::InvalidParameter("bad param".to_string())) }
})
.await;
assert!(
result.is_err(),
"Should fail immediately for invalid parameter"
);
assert_eq!(attempts, 1, "Should only attempt once");
}
#[tokio::test]
async fn test_max_retries_respected() {
let config = RetryConfig {
max_retries: 2,
initial_backoff: Duration::from_millis(1),
max_backoff: Duration::from_millis(10),
backoff_multiplier: 2.0,
retry_on_status: vec![500, 502, 503],
};
let policy = RetryPolicy::new(&config);
let mut attempts = 0;
let result: Result<i32> = policy
.execute(|| {
attempts += 1;
async move {
Err(Error::ConnectionClosed)
}
})
.await;
assert!(result.is_err(), "Should fail after max retries");
assert_eq!(attempts, 3, "Should attempt initial + 2 retries = 3 total");
}
}