use std::sync::atomic::{AtomicU64, Ordering};
use std::time::Duration;
use tracing::warn;
use crate::collect::errors::{CollectError, Result};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct RetryPolicy {
pub max_attempts: u32,
pub base_delay: Duration,
pub max_delay: Duration,
pub max_total_delay: Duration,
}
impl Default for RetryPolicy {
fn default() -> Self {
Self {
max_attempts: 4,
base_delay: Duration::from_secs(1),
max_delay: Duration::from_secs(8),
max_total_delay: Duration::from_secs(120),
}
}
}
#[derive(Debug)]
pub struct RetryBudget {
remaining_ms: AtomicU64,
}
impl RetryBudget {
pub fn new(policy: &RetryPolicy) -> Self {
Self {
remaining_ms: AtomicU64::new(policy.max_total_delay.as_millis() as u64),
}
}
pub fn take(&self, want: Duration) -> Option<Duration> {
let want_ms = want.as_millis() as u64;
if want_ms == 0 {
return Some(Duration::ZERO);
}
let mut remaining = self.remaining_ms.load(Ordering::Relaxed);
loop {
if remaining == 0 {
return None;
}
let grant = want_ms.min(remaining);
match self.remaining_ms.compare_exchange_weak(
remaining,
remaining - grant,
Ordering::Relaxed,
Ordering::Relaxed,
) {
Ok(_) => return Some(Duration::from_millis(grant)),
Err(actual) => remaining = actual,
}
}
}
}
pub fn delay_for_attempt(policy: &RetryPolicy, attempt: u32) -> Duration {
if attempt <= 1 {
return Duration::ZERO;
}
let shift = (attempt - 2).min(16);
let scaled = policy
.base_delay
.saturating_mul(1u32.checked_shl(shift).unwrap_or(u32::MAX));
scaled.min(policy.max_delay)
}
pub fn is_retryable(err: &CollectError) -> bool {
match err {
CollectError::Throttled { .. } => true,
CollectError::Http(e) => match e.status() {
Some(status) => status.is_server_error(),
None => e.is_timeout() || e.is_connect(),
},
_ => false,
}
}
fn delay_for(policy: &RetryPolicy, attempt: u32, err: &CollectError) -> Duration {
match err {
CollectError::Throttled {
retry_after: Some(hint),
..
} => *hint,
_ => delay_for_attempt(policy, attempt),
}
}
pub async fn with_retry<T, F, Fut>(
label: &str,
policy: &RetryPolicy,
budget: &RetryBudget,
mut op: F,
) -> Result<T>
where
F: FnMut() -> Fut,
Fut: std::future::Future<Output = Result<T>>,
{
let mut attempt = 1u32;
loop {
match op().await {
Ok(v) => return Ok(v),
Err(e) if attempt < policy.max_attempts && is_retryable(&e) => {
let want = delay_for(policy, attempt + 1, &e);
let Some(granted) = budget.take(want) else {
warn!(
op = label,
error = %e,
"run-wide retry budget exhausted; refusing to keep sleeping \
against a throttling server"
);
return Err(e);
};
warn!(
op = label,
attempt,
max_attempts = policy.max_attempts,
delay_ms = granted.as_millis() as u64,
honoured_retry_after = matches!(
e,
CollectError::Throttled {
retry_after: Some(_),
..
}
),
error = %e,
"transient JIRA failure; retrying after backoff"
);
tokio::time::sleep(granted).await;
attempt += 1;
}
Err(e) => return Err(e),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn delay_for_first_attempt_is_zero() {
let policy = RetryPolicy::default();
assert_eq!(delay_for_attempt(&policy, 1), Duration::ZERO);
assert_eq!(delay_for_attempt(&policy, 0), Duration::ZERO);
}
#[test]
fn delay_schedule_doubles_and_clamps() {
let policy = RetryPolicy {
max_attempts: 6,
base_delay: Duration::from_millis(100),
max_delay: Duration::from_millis(400),
max_total_delay: Duration::from_secs(10),
};
assert_eq!(delay_for_attempt(&policy, 2), Duration::from_millis(100));
assert_eq!(delay_for_attempt(&policy, 3), Duration::from_millis(200));
assert_eq!(delay_for_attempt(&policy, 4), Duration::from_millis(400));
assert_eq!(
delay_for_attempt(&policy, 5),
Duration::from_millis(400),
"the schedule must clamp at max_delay rather than growing forever"
);
}
#[test]
fn is_retryable_rejects_non_http_errors() {
assert!(!is_retryable(&CollectError::Config("bad config".into())));
assert!(!is_retryable(&CollectError::Identity("nope".into())));
}
#[test]
fn is_retryable_accepts_throttling() {
assert!(is_retryable(&CollectError::Throttled {
status: 429,
retry_after: None,
}));
}
#[test]
fn retry_after_hint_overrides_the_exponential_schedule() {
let policy = RetryPolicy::default();
let throttled = CollectError::Throttled {
status: 429,
retry_after: Some(Duration::from_secs(17)),
};
assert_eq!(delay_for(&policy, 2, &throttled), Duration::from_secs(17));
let no_hint = CollectError::Throttled {
status: 429,
retry_after: None,
};
assert_eq!(
delay_for(&policy, 2, &no_hint),
policy.base_delay,
"without a hint we fall back to the exponential schedule"
);
}
#[test]
fn budget_grants_until_exhausted() {
let budget = RetryBudget::new(&RetryPolicy {
max_total_delay: Duration::from_millis(150),
..RetryPolicy::default()
});
assert_eq!(
budget.take(Duration::from_millis(100)),
Some(Duration::from_millis(100))
);
assert_eq!(
budget.take(Duration::from_millis(100)),
Some(Duration::from_millis(50)),
"a partial grant is still progress"
);
assert_eq!(
budget.take(Duration::from_millis(100)),
None,
"an exhausted budget must refuse so the caller stops"
);
}
#[test]
fn budget_grants_a_zero_delay_without_consuming_anything() {
let budget = RetryBudget::new(&RetryPolicy {
max_total_delay: Duration::from_millis(10),
..RetryPolicy::default()
});
assert_eq!(budget.take(Duration::ZERO), Some(Duration::ZERO));
assert_eq!(
budget.take(Duration::from_millis(10)),
Some(Duration::from_millis(10)),
"the zero grant must not have spent any of the budget"
);
}
fn fast_policy() -> RetryPolicy {
RetryPolicy {
max_attempts: 4,
base_delay: Duration::from_millis(1),
max_delay: Duration::from_millis(1),
max_total_delay: Duration::from_millis(100),
}
}
#[tokio::test]
async fn with_retry_does_not_retry_permanent_errors() {
let policy = fast_policy();
let budget = RetryBudget::new(&policy);
let mut calls = 0usize;
let result: Result<()> = with_retry("test", &policy, &budget, || {
calls += 1;
async { Err(CollectError::Config("permanent".into())) }
})
.await;
assert!(result.is_err());
assert_eq!(calls, 1, "a permanent error must not be retried");
}
#[tokio::test]
async fn with_retry_returns_first_success_without_retrying() {
let policy = fast_policy();
let budget = RetryBudget::new(&policy);
let mut calls = 0usize;
let value = with_retry("test", &policy, &budget, || {
calls += 1;
async { Ok(7u32) }
})
.await
.expect("succeeds");
assert_eq!(value, 7);
assert_eq!(calls, 1);
}
#[tokio::test]
async fn with_retry_stops_sleeping_once_the_run_budget_is_spent() {
let policy = RetryPolicy {
max_attempts: 10,
base_delay: Duration::from_millis(10),
max_delay: Duration::from_millis(10),
max_total_delay: Duration::from_millis(10),
};
let budget = RetryBudget::new(&policy);
let mut calls = 0usize;
let result: Result<()> = with_retry("test", &policy, &budget, || {
calls += 1;
async {
Err(CollectError::Throttled {
status: 429,
retry_after: None,
})
}
})
.await;
assert!(result.is_err());
assert_eq!(
calls, 2,
"one sleep exhausts the 10ms budget, so the second failure is final \
even though max_attempts is 10"
);
}
}