use std::future::Future;
use std::time::Duration;
use super::{BackendErrorKind, Error};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct RetryPolicy {
pub initial_backoff: Duration,
pub max_backoff: Duration,
pub retry_connection_errors: bool,
}
impl Default for RetryPolicy {
fn default() -> Self {
Self {
initial_backoff: Duration::from_secs(1),
max_backoff: Duration::from_secs(60),
retry_connection_errors: true,
}
}
}
impl RetryPolicy {
fn should_retry(&self, error: &Error) -> bool {
match error {
Error::Backend(e) => match e.kind {
BackendErrorKind::Transient => true,
BackendErrorKind::Connection => self.retry_connection_errors,
BackendErrorKind::Permanent => false,
},
_ => false,
}
}
}
pub(crate) async fn with_retry<T, F, Fut>(
policy: &RetryPolicy,
operation: &str,
mut work: F,
) -> Result<T, Error>
where
F: FnMut() -> Fut,
Fut: Future<Output = Result<T, Error>> + Send,
{
let mut backoff = policy.initial_backoff;
let mut attempt: u32 = 0;
loop {
match work().await {
Ok(value) => return Ok(value),
Err(error) => {
attempt += 1;
if !policy.should_retry(&error) {
return Err(error);
}
let delay = jitter(backoff);
tracing::warn!(
operation,
attempt,
delay_ms = delay.as_millis() as u64,
error = %error,
"system database operation failed; retrying"
);
tokio::time::sleep(delay).await;
backoff = (backoff * 2).min(policy.max_backoff);
}
}
}
}
fn jitter(backoff: Duration) -> Duration {
let bits = uuid::Uuid::new_v4().as_u128() as u32;
let factor = 0.5 + f64::from(bits) / f64::from(u32::MAX).next_up();
backoff.mul_f64(factor)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::sysdb::BackendError;
use std::sync::atomic::{AtomicU32, Ordering};
fn backend(kind: BackendErrorKind) -> Error {
Error::Backend(BackendError {
message: "boom".to_owned(),
sqlstate: None,
kind,
})
}
fn fast() -> RetryPolicy {
RetryPolicy {
initial_backoff: Duration::from_millis(1),
max_backoff: Duration::from_millis(4),
..RetryPolicy::default()
}
}
#[tokio::test]
async fn a_transient_failure_is_retried_until_it_succeeds() {
let calls = AtomicU32::new(0);
let calls = &calls;
let result = with_retry(&fast(), "test", move || async move {
let seen = calls.fetch_add(1, Ordering::Relaxed) + 1;
if seen < 3 {
Err(backend(BackendErrorKind::Transient))
} else {
Ok(seen)
}
})
.await;
assert_eq!(result.unwrap(), 3);
assert_eq!(
calls.load(Ordering::Relaxed),
3,
"should have failed twice before succeeding"
);
}
#[tokio::test]
async fn a_permanent_failure_is_returned_at_once() {
let calls = AtomicU32::new(0);
let calls = &calls;
let result: Result<(), _> = with_retry(&fast(), "test", move || async move {
calls.fetch_add(1, Ordering::Relaxed);
Err(backend(BackendErrorKind::Permanent))
})
.await;
assert!(result.is_err());
assert_eq!(
calls.load(Ordering::Relaxed),
1,
"a rejected statement must not be repeated"
);
}
#[tokio::test]
async fn a_semantic_error_is_not_retried() {
let calls = AtomicU32::new(0);
let calls = &calls;
let result: Result<(), _> = with_retry(&fast(), "test", move || async move {
calls.fetch_add(1, Ordering::Relaxed);
Err(Error::ConflictingWorkflow {
workflow_id: "wf-1".to_owned(),
detail: "different function".to_owned(),
})
})
.await;
assert!(matches!(result, Err(Error::ConflictingWorkflow { .. })));
assert_eq!(calls.load(Ordering::Relaxed), 1);
}
#[tokio::test]
async fn opting_out_covers_connection_errors_but_not_contention() {
let policy = RetryPolicy {
retry_connection_errors: false,
..fast()
};
let calls = AtomicU32::new(0);
let calls = &calls;
let result: Result<(), _> = with_retry(&policy, "test", move || async move {
calls.fetch_add(1, Ordering::Relaxed);
Err(backend(BackendErrorKind::Connection))
})
.await;
assert!(result.is_err());
assert_eq!(
calls.load(Ordering::Relaxed),
1,
"the opt-out should have applied"
);
let calls = AtomicU32::new(0);
let calls = &calls;
let result = with_retry(&policy, "test", move || async move {
let seen = calls.fetch_add(1, Ordering::Relaxed) + 1;
if seen < 2 {
Err(backend(BackendErrorKind::Transient))
} else {
Ok(())
}
})
.await;
assert!(result.is_ok());
assert_eq!(
calls.load(Ordering::Relaxed),
2,
"contention ignores the opt-out"
);
}
#[test]
fn jitter_stays_within_half_and_one_and_a_half() {
for _ in 0..1_000 {
let d = jitter(Duration::from_millis(1_000));
assert!(
(500..1_500).contains(&d.as_millis()),
"jittered delay {d:?} left the [0.5, 1.5) band",
);
}
}
}