use std::fmt;
use std::future::Future;
use std::time::Duration;
use rand::RngExt;
use serde::{Deserialize, Serialize};
use crate::foundation::DbError;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct RetryPolicy {
pub max_retries: u32,
pub initial_backoff_ms: u64,
pub max_backoff_ms: u64,
pub multiplier: f64,
pub jitter: bool,
#[serde(default)]
pub overall_timeout_ms: Option<u64>,
}
impl Default for RetryPolicy {
fn default() -> Self {
Self {
max_retries: 3,
initial_backoff_ms: 100,
max_backoff_ms: 5000,
multiplier: 2.0,
jitter: true,
overall_timeout_ms: None,
}
}
}
impl RetryPolicy {
pub fn initial_backoff(&self) -> Duration {
Duration::from_millis(self.initial_backoff_ms)
}
pub fn max_backoff(&self) -> Duration {
Duration::from_millis(self.max_backoff_ms)
}
}
#[derive(Debug)]
pub enum RetryError {
Exhausted {
attempts: u32,
last_error: DbError,
},
NonRetryable(DbError),
Timeout {
timeout_ms: u64,
last_error: DbError,
},
}
impl fmt::Display for RetryError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::Exhausted { attempts, last_error } => {
write!(f, "Retry exhausted after {attempts} attempts: {last_error}")
}
Self::NonRetryable(err) => write!(f, "Non-retryable operation: {err}"),
Self::Timeout { timeout_ms, last_error } => {
write!(f, "Retry timed out after {timeout_ms}ms: {last_error}")
}
}
}
}
impl std::error::Error for RetryError {
fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
match self {
Self::Exhausted { last_error, .. } => Some(last_error),
Self::NonRetryable(err) => Some(err),
Self::Timeout { last_error, .. } => Some(last_error),
}
}
}
impl crate::i18n::error_ext::LocalizedMsg for RetryError {
fn message_key(&self) -> &'static str {
match self {
Self::Exhausted { .. } => "retry-exhausted",
Self::NonRetryable(_) => "retry-non-retryable",
Self::Timeout { .. } => "retry-timeout",
}
}
fn message_args(&self) -> Vec<(&str, String)> {
match self {
Self::Exhausted { attempts, last_error } => vec![
("attempts", attempts.to_string()),
("last_error", last_error.to_string()),
],
Self::NonRetryable(err) => vec![("error", err.to_string())],
Self::Timeout { timeout_ms, last_error } => vec![
("timeout_ms", timeout_ms.to_string()),
("last_error", last_error.to_string()),
],
}
}
}
impl From<RetryError> for DbError {
fn from(err: RetryError) -> Self {
match err {
RetryError::Exhausted { last_error, .. } => last_error,
RetryError::NonRetryable(err) => err,
RetryError::Timeout { last_error, .. } => last_error,
}
}
}
pub fn is_idempotent_operation(sql: &str) -> bool {
let trimmed = sql.trim_start().as_bytes();
trimmed.len() >= 6 && trimmed[..6].eq_ignore_ascii_case(b"SELECT")
|| trimmed.len() >= 4 && trimmed[..4].eq_ignore_ascii_case(b"SHOW")
|| trimmed.len() >= 7 && trimmed[..7].eq_ignore_ascii_case(b"EXPLAIN")
}
pub struct RetryExecutor;
impl RetryExecutor {
pub async fn execute_with_retry<F, Fut, T>(policy: &RetryPolicy, operation: F, sql: &str) -> Result<T, RetryError>
where
F: Fn() -> Fut + Send + Sync,
Fut: Future<Output = Result<T, DbError>> + Send,
T: Send,
{
if !is_idempotent_operation(sql) {
return operation().await.map_err(RetryError::NonRetryable);
}
let deadline = policy.overall_timeout_ms.map(Duration::from_millis);
let start = std::time::Instant::now();
let mut last_error = match operation().await {
Ok(val) => return Ok(val),
Err(e) => e,
};
for attempt in 0..policy.max_retries {
if let Some(timeout) = deadline
&& start.elapsed() >= timeout
{
return Err(RetryError::Timeout {
timeout_ms: timeout.as_millis() as u64,
last_error,
});
}
let backoff = Self::calculate_backoff(policy, attempt);
tokio::time::sleep(backoff).await;
match operation().await {
Ok(val) => return Ok(val),
Err(e) => last_error = e,
}
}
Err(RetryError::Exhausted {
attempts: 1 + policy.max_retries,
last_error,
})
}
fn calculate_backoff(policy: &RetryPolicy, attempt: u32) -> Duration {
let base_ms = policy.initial_backoff_ms as f64;
let safe_attempt = attempt.min(i32::MAX as u32);
let backoff_ms = base_ms * policy.multiplier.powi(safe_attempt as i32);
let capped_ms = backoff_ms.min(policy.max_backoff_ms as f64);
if policy.jitter {
let jitter_range = capped_ms * 0.25;
let jitter_offset = (rand::rng().random::<f64>() - 0.5) * 2.0 * jitter_range;
let final_ms = (capped_ms + jitter_offset).max(1.0);
Duration::from_millis(final_ms as u64)
} else {
Duration::from_millis(capped_ms as u64)
}
}
}