use std::future::Future;
use std::time::Duration;
use tokio::time::Instant;
use tracing::debug;
use crate::error::{KrafkaError, Result};
use crate::util::BackoffPolicy;
pub(crate) const MAX_BACKOFF: Duration = Duration::from_secs(1);
#[derive(Debug, Clone)]
pub(crate) struct Backoff {
policy: BackoffPolicy,
}
impl Backoff {
pub(crate) fn new(initial: Duration) -> Self {
Self {
policy: BackoffPolicy {
initial_backoff: initial.min(MAX_BACKOFF),
max_backoff: MAX_BACKOFF,
backoff_multiplier: 2.0,
jitter_factor: 0.2,
},
}
}
#[inline]
pub(crate) fn delay(&self, retry: u32) -> Duration {
self.policy.calculate_backoff(retry.max(1)).min(MAX_BACKOFF)
}
}
pub(crate) async fn until_deadline<T, F, Fut>(
backoff: &Backoff,
deadline: Instant,
what: &str,
mut attempt: F,
) -> Result<T>
where
F: FnMut(u32) -> Fut,
Fut: Future<Output = Result<T>>,
{
let mut last_error: Option<KrafkaError> = None;
for number in 0u32.. {
let remaining = deadline.saturating_duration_since(Instant::now());
if remaining.is_zero() {
break;
}
match tokio::time::timeout(remaining, attempt(number)).await {
Ok(Ok(value)) => return Ok(value),
Ok(Err(error)) if !error.is_retriable() => return Err(error),
Ok(Err(error)) => {
debug!(what, attempt = number, %error, "retriable failure; backing off");
last_error = Some(error);
}
Err(_) => {
last_error = Some(KrafkaError::timeout(what));
break;
}
}
let delay = backoff
.delay(number + 1)
.min(deadline.saturating_duration_since(Instant::now()));
tokio::time::sleep(delay).await;
}
Err(last_error.unwrap_or_else(|| KrafkaError::timeout(what)))
}
#[cfg(test)]
#[allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
mod tests {
use super::*;
use crate::error::ErrorCode;
#[test]
fn the_delay_is_capped_at_one_second() {
let backoff = Backoff::new(Duration::from_millis(100));
for retry in 1..64 {
assert!(backoff.delay(retry) <= MAX_BACKOFF, "retry {retry}");
}
assert!(
backoff.delay(20) >= Duration::from_millis(800),
"it does grow"
);
}
#[test]
fn the_first_delay_is_the_configured_backoff() {
let backoff = Backoff::new(Duration::from_millis(100));
let first = backoff.delay(1);
assert!(
first >= Duration::from_millis(100) && first <= Duration::from_millis(120),
"{first:?}"
);
}
#[test]
fn a_large_initial_backoff_is_held_to_the_cap() {
let backoff = Backoff::new(Duration::from_secs(5));
assert!(backoff.delay(1) <= MAX_BACKOFF);
}
#[tokio::test]
async fn retries_a_retriable_error_until_it_succeeds() {
let backoff = Backoff::new(Duration::from_millis(1));
let deadline = Instant::now() + Duration::from_secs(5);
let result = until_deadline(&backoff, deadline, "op", |n| async move {
if n < 3 {
Err(KrafkaError::broker(ErrorCode::NotCoordinator, "moving"))
} else {
Ok(n)
}
})
.await;
assert_eq!(result.unwrap(), 3);
}
#[tokio::test]
async fn a_non_retriable_error_stops_at_once() {
let backoff = Backoff::new(Duration::from_millis(1));
let deadline = Instant::now() + Duration::from_secs(5);
let mut calls = 0;
let result: Result<()> = until_deadline(&backoff, deadline, "op", |_| {
calls += 1;
async { Err(KrafkaError::config("no")) }
})
.await;
assert!(result.is_err());
assert_eq!(calls, 1);
}
#[tokio::test]
async fn the_deadline_ends_the_loop_with_the_last_error() {
let backoff = Backoff::new(Duration::from_millis(5));
let started = Instant::now();
let result: Result<()> = until_deadline(
&backoff,
started + Duration::from_millis(100),
"op",
|_| async { Err(KrafkaError::broker(ErrorCode::NotCoordinator, "moving")) },
)
.await;
assert!(started.elapsed() < Duration::from_millis(500));
assert!(
matches!(
result,
Err(KrafkaError::Broker {
code: ErrorCode::NotCoordinator,
..
})
),
"{result:?}"
);
}
#[tokio::test]
async fn a_hanging_attempt_is_bounded_by_the_deadline() {
let backoff = Backoff::new(Duration::from_millis(5));
let started = Instant::now();
let result: Result<()> = until_deadline(
&backoff,
started + Duration::from_millis(50),
"hang",
|_| std::future::pending(),
)
.await;
assert!(started.elapsed() < Duration::from_millis(500));
assert!(
matches!(result, Err(KrafkaError::Timeout { .. })),
"{result:?}"
);
}
}