use crate::error::FaucetError;
use crate::resilience::classify::classify;
use crate::resilience::policy::RetryPolicy;
use std::future::Future;
use tokio_util::sync::CancellationToken;
#[derive(Debug, Clone)]
pub struct RetryMetrics {
pub pipeline: String,
pub row: String,
pub op: &'static str,
}
pub async fn execute_with_policy<F, Fut, T>(
policy: &RetryPolicy,
cancel: Option<&CancellationToken>,
op: F,
) -> Result<T, FaucetError>
where
F: FnMut() -> Fut,
Fut: Future<Output = Result<T, FaucetError>>,
{
run_with_policy(policy, cancel, None, op).await
}
pub async fn execute_with_policy_metered<F, Fut, T>(
policy: &RetryPolicy,
cancel: Option<&CancellationToken>,
metrics: &RetryMetrics,
op: F,
) -> Result<T, FaucetError>
where
F: FnMut() -> Fut,
Fut: Future<Output = Result<T, FaucetError>>,
{
run_with_policy(policy, cancel, Some(metrics), op).await
}
async fn run_with_policy<F, Fut, T>(
policy: &RetryPolicy,
cancel: Option<&CancellationToken>,
metrics: Option<&RetryMetrics>,
mut op: F,
) -> Result<T, FaucetError>
where
F: FnMut() -> Fut,
Fut: Future<Output = Result<T, FaucetError>>,
{
let max_attempts = policy.max_attempts.max(1);
let mut attempt = 0u32;
loop {
match op().await {
Ok(val) => return Ok(val),
Err(e) if policy.is_retriable(&e) && attempt + 1 < max_attempts => {
let base = policy.backoff.delay(policy.base, policy.max, attempt);
let wait = if policy.jitter {
crate::retry::apply_jitter(base)
} else {
base
};
tracing::warn!(
"operation failed (attempt {}/{}), retrying in {wait:?}: {e}",
attempt + 1,
max_attempts
);
if let Some(m) = metrics {
let class = classify(&e).map(|c| c.as_str()).unwrap_or("unknown");
crate::observability::resilience::retry(&m.pipeline, &m.row, m.op, class);
crate::observability::resilience::retry_sleep(
&m.pipeline,
&m.row,
m.op,
wait.as_secs_f64(),
);
}
if !wait.is_zero() {
match cancel {
Some(token) => {
tokio::select! {
biased;
_ = token.cancelled() => {
if let Some(m) = metrics {
crate::observability::resilience::giveup(
&m.pipeline, &m.row, m.op,
);
}
return Err(e);
}
_ = tokio::time::sleep(wait) => {}
}
}
None => tokio::time::sleep(wait).await,
}
}
attempt += 1;
}
Err(e) => {
if attempt > 0
&& let Some(m) = metrics
{
crate::observability::resilience::giveup(&m.pipeline, &m.row, m.op);
}
return Err(e);
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::resilience::policy::BackoffKind;
use std::sync::Arc;
use std::sync::atomic::{AtomicU32, Ordering};
use std::time::Duration;
fn fast_policy(max_attempts: u32) -> RetryPolicy {
RetryPolicy {
max_attempts,
backoff: BackoffKind::None,
base: Duration::from_millis(0),
max: Duration::from_millis(0),
jitter: false,
..RetryPolicy::default()
}
}
#[tokio::test]
async fn retries_then_succeeds() {
let calls = Arc::new(AtomicU32::new(0));
let c = calls.clone();
let r = execute_with_policy(&fast_policy(5), None, move || {
let n = c.fetch_add(1, Ordering::SeqCst);
async move {
if n < 2 {
Err::<i32, _>(FaucetError::HttpStatus {
status: 503,
url: "u".into(),
body: "".into(),
})
} else {
Ok(42)
}
}
})
.await;
assert_eq!(r.unwrap(), 42);
assert_eq!(calls.load(Ordering::SeqCst), 3);
}
#[tokio::test]
async fn gives_up_after_max_attempts() {
let calls = Arc::new(AtomicU32::new(0));
let c = calls.clone();
let r = execute_with_policy(&fast_policy(3), None, move || {
c.fetch_add(1, Ordering::SeqCst);
async {
Err::<i32, _>(FaucetError::HttpStatus {
status: 503,
url: "u".into(),
body: "".into(),
})
}
})
.await;
assert!(r.is_err());
assert_eq!(calls.load(Ordering::SeqCst), 3, "1 initial + 2 retries");
}
#[tokio::test]
async fn max_attempts_zero_runs_op_exactly_once() {
let calls = Arc::new(AtomicU32::new(0));
let c = calls.clone();
let r = execute_with_policy(&fast_policy(0), None, move || {
c.fetch_add(1, Ordering::SeqCst);
async {
Err::<i32, _>(FaucetError::HttpStatus {
status: 503,
url: "u".into(),
body: "".into(),
})
}
})
.await;
assert!(r.is_err());
assert_eq!(
calls.load(Ordering::SeqCst),
1,
"max_attempts=0 clamps to 1: one execution, no retry"
);
}
#[tokio::test]
async fn non_retriable_fails_immediately() {
let calls = Arc::new(AtomicU32::new(0));
let c = calls.clone();
let r = execute_with_policy(&fast_policy(5), None, move || {
c.fetch_add(1, Ordering::SeqCst);
async { Err::<i32, _>(FaucetError::Auth("nope".into())) }
})
.await;
assert!(r.is_err());
assert_eq!(calls.load(Ordering::SeqCst), 1);
}
#[tokio::test]
async fn jittered_nonzero_backoff_with_no_cancel_retries() {
let policy = RetryPolicy {
max_attempts: 3,
backoff: BackoffKind::Fixed,
base: Duration::from_millis(2),
max: Duration::from_millis(2),
jitter: true,
..RetryPolicy::default()
};
let calls = Arc::new(AtomicU32::new(0));
let c = calls.clone();
let r = execute_with_policy(&policy, None, move || {
let n = c.fetch_add(1, Ordering::SeqCst);
async move {
if n < 1 {
Err::<i32, _>(FaucetError::HttpStatus {
status: 503,
url: "u".into(),
body: "".into(),
})
} else {
Ok(9)
}
}
})
.await;
assert_eq!(r.unwrap(), 9);
assert_eq!(calls.load(Ordering::SeqCst), 2);
}
#[tokio::test]
async fn cancel_during_backoff_returns_last_error_promptly() {
let policy = RetryPolicy {
max_attempts: 10,
backoff: BackoffKind::Fixed,
base: Duration::from_secs(30),
max: Duration::from_secs(30),
jitter: false,
..RetryPolicy::default()
};
let token = CancellationToken::new();
token.cancel(); let r = execute_with_policy(&policy, Some(&token), || async {
Err::<i32, _>(FaucetError::HttpStatus {
status: 503,
url: "u".into(),
body: "".into(),
})
})
.await;
assert!(r.is_err());
}
fn metrics() -> RetryMetrics {
RetryMetrics {
pipeline: "p".into(),
row: "r".into(),
op: "sink_write",
}
}
#[tokio::test]
async fn metered_retries_then_succeeds_same_as_bare() {
let calls = Arc::new(AtomicU32::new(0));
let c = calls.clone();
let m = metrics();
let r = execute_with_policy_metered(&fast_policy(5), None, &m, move || {
let n = c.fetch_add(1, Ordering::SeqCst);
async move {
if n < 2 {
Err::<i32, _>(FaucetError::HttpStatus {
status: 503,
url: "u".into(),
body: "".into(),
})
} else {
Ok(42)
}
}
})
.await;
assert_eq!(r.unwrap(), 42);
assert_eq!(calls.load(Ordering::SeqCst), 3);
}
#[tokio::test]
async fn metered_gives_up_after_max_attempts_same_as_bare() {
let calls = Arc::new(AtomicU32::new(0));
let c = calls.clone();
let m = metrics();
let r = execute_with_policy_metered(&fast_policy(3), None, &m, move || {
c.fetch_add(1, Ordering::SeqCst);
async {
Err::<i32, _>(FaucetError::HttpStatus {
status: 503,
url: "u".into(),
body: "".into(),
})
}
})
.await;
assert!(r.is_err());
assert_eq!(calls.load(Ordering::SeqCst), 3, "1 initial + 2 retries");
}
#[tokio::test]
async fn metered_non_retriable_fails_immediately_same_as_bare() {
let calls = Arc::new(AtomicU32::new(0));
let c = calls.clone();
let m = metrics();
let r = execute_with_policy_metered(&fast_policy(5), None, &m, move || {
c.fetch_add(1, Ordering::SeqCst);
async { Err::<i32, _>(FaucetError::Auth("nope".into())) }
})
.await;
assert!(r.is_err());
assert_eq!(calls.load(Ordering::SeqCst), 1);
}
#[tokio::test]
async fn metered_cancel_during_backoff_returns_promptly() {
let policy = RetryPolicy {
max_attempts: 10,
backoff: BackoffKind::Fixed,
base: Duration::from_secs(30),
max: Duration::from_secs(30),
jitter: false,
..RetryPolicy::default()
};
let token = CancellationToken::new();
token.cancel();
let m = metrics();
let r = execute_with_policy_metered(&policy, Some(&token), &m, || async {
Err::<i32, _>(FaucetError::HttpStatus {
status: 503,
url: "u".into(),
body: "".into(),
})
})
.await;
assert!(r.is_err());
}
}