use std::time::Duration;
use super::RatchetConfig;
use super::error::LightClientError;
pub(crate) async fn step(
config: &RatchetConfig,
err: LightClientError,
attempt: &mut u32,
) -> Result<(), LightClientError> {
if !is_retryable(&err) || *attempt >= config.max_retries {
return Err(err);
}
tokio::time::sleep(backoff_delay(config, *attempt)).await;
*attempt = attempt.saturating_add(1);
Ok(())
}
fn is_retryable(err: &LightClientError) -> bool {
let LightClientError::Rpc(status) = err else {
return false;
};
matches!(
status.code(),
tonic::Code::Unavailable
| tonic::Code::DeadlineExceeded
| tonic::Code::ResourceExhausted
| tonic::Code::Aborted
)
}
fn backoff_delay(config: &RatchetConfig, attempt: u32) -> Duration {
let shift = attempt.min(20);
let base = config
.base_retry_delay
.saturating_mul(1u32 << shift)
.min(config.max_retry_delay);
base.saturating_add(pseudo_jitter(attempt, config.retry_jitter))
}
fn pseudo_jitter(attempt: u32, ceiling: Duration) -> Duration {
if ceiling.is_zero() {
return Duration::ZERO;
}
let mix = u64::from(attempt).wrapping_mul(0x9E37_79B9_7F4A_7C15) as u128;
let ceiling_ms = ceiling.as_millis().max(1);
let offset_ms = (mix % ceiling_ms) as u64;
Duration::from_millis(offset_ms)
}
#[cfg(test)]
mod tests {
use super::*;
fn no_sleep_config(max_retries: u32) -> RatchetConfig {
RatchetConfig {
max_retries,
base_retry_delay: Duration::ZERO,
max_retry_delay: Duration::ZERO,
retry_jitter: Duration::ZERO,
..RatchetConfig::default()
}
}
#[tokio::test]
async fn step_retries_until_cap_then_surfaces_last_error() {
let config = no_sleep_config(3);
let mut attempt = 0u32;
for _ in 0..3 {
let err = LightClientError::Rpc(tonic::Status::unavailable("down"));
step(&config, err, &mut attempt)
.await
.expect("retry allowed");
}
let err = LightClientError::Rpc(tonic::Status::unavailable("down"));
let result = step(&config, err, &mut attempt).await;
assert!(matches!(result, Err(LightClientError::Rpc(_))));
assert_eq!(attempt, 3);
}
#[tokio::test]
async fn step_passes_non_retryable_errors_through() {
let config = no_sleep_config(5);
let mut attempt = 0u32;
let err = LightClientError::Rpc(tonic::Status::invalid_argument("nope"));
let result = step(&config, err, &mut attempt).await;
assert!(matches!(result, Err(LightClientError::Rpc(_))));
assert_eq!(attempt, 0);
}
#[tokio::test]
async fn step_disabled_retry_refuses_immediately() {
let config = no_sleep_config(0);
let mut attempt = 0u32;
let err = LightClientError::Rpc(tonic::Status::unavailable("down"));
let result = step(&config, err, &mut attempt).await;
assert!(matches!(result, Err(LightClientError::Rpc(_))));
assert_eq!(attempt, 0);
}
#[test]
fn backoff_delay_caps_at_max() {
let config = RatchetConfig {
base_retry_delay: Duration::from_millis(100),
max_retry_delay: Duration::from_millis(800),
retry_jitter: Duration::ZERO,
..RatchetConfig::default()
};
assert_eq!(backoff_delay(&config, 0), Duration::from_millis(100));
assert_eq!(backoff_delay(&config, 1), Duration::from_millis(200));
assert_eq!(backoff_delay(&config, 2), Duration::from_millis(400));
assert_eq!(backoff_delay(&config, 3), Duration::from_millis(800));
assert_eq!(backoff_delay(&config, 4), Duration::from_millis(800));
}
}