use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH};
use hyper::StatusCode;
#[derive(Debug, Clone, Copy)]
pub(crate) enum Failure {
Timeout,
Connect,
}
pub(crate) fn is_idempotent(method: &str) -> bool {
matches!(
method,
"GET" | "HEAD" | "PUT" | "DELETE" | "OPTIONS" | "TRACE"
)
}
pub(crate) fn may_retry(failure: Failure, method: &str, replayable: bool, tries_left: u64) -> bool {
replayable
&& tries_left > 0
&& match failure {
Failure::Timeout => is_idempotent(method),
Failure::Connect => true,
}
}
pub(crate) fn is_retriable_5xx(status: StatusCode) -> bool {
matches!(status.as_u16(), 502..=504)
}
const RETRY_BACKOFF_BASE_MS: u64 = 25;
const RETRY_BACKOFF_CAP_MS: u64 = 250;
pub(crate) async fn backoff(attempt: u32) {
let ceiling = RETRY_BACKOFF_BASE_MS
.saturating_mul(1u64 << attempt.min(16))
.min(RETRY_BACKOFF_CAP_MS);
let delay = jitter(ceiling);
if delay > 0 {
tokio::time::sleep(Duration::from_millis(delay)).await;
}
}
pub(crate) fn jitter(ceiling_ms: u64) -> u64 {
if ceiling_ms == 0 {
return 0;
}
let entropy = SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|d| u64::from(d.subsec_nanos()))
.unwrap_or(0);
entropy % (ceiling_ms + 1)
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum PerTryTimeout {
Bounded(Duration),
OverallDeadlineElapsed,
}
pub(crate) fn per_try_timeout(
overall_deadline: Option<Instant>,
per_try_bound: Duration,
now: Instant,
) -> PerTryTimeout {
match overall_deadline {
Some(deadline) if now >= deadline => PerTryTimeout::OverallDeadlineElapsed,
Some(deadline) => {
let remaining = deadline - now;
PerTryTimeout::Bounded(if per_try_bound.is_zero() {
remaining
} else {
per_try_bound.min(remaining)
})
}
None => PerTryTimeout::Bounded(per_try_bound),
}
}
#[derive(Debug, Clone, Copy)]
pub(crate) enum AttemptOutcome {
Response { retriable_5xx: bool },
TimedOut,
Failed { connect: bool },
}
pub(crate) fn should_attempt_retry(
outcome: AttemptOutcome,
method: &str,
replayable: bool,
tries_left: u64,
overall_elapsed: bool,
) -> bool {
if overall_elapsed {
return false;
}
let (retryable, failure) = match outcome {
AttemptOutcome::Response { retriable_5xx } => (retriable_5xx, Failure::Timeout),
AttemptOutcome::TimedOut => (true, Failure::Timeout),
AttemptOutcome::Failed { connect } => (connect, Failure::Connect),
};
retryable && may_retry(failure, method, replayable, tries_left)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn retriable_5xx_is_the_gateway_class_only() {
for s in [502u16, 503, 504] {
assert!(
is_retriable_5xx(StatusCode::from_u16(s).unwrap()),
"{s} is retriable"
);
}
for s in [500u16, 501, 505, 200, 404, 429] {
assert!(
!is_retriable_5xx(StatusCode::from_u16(s).unwrap()),
"{s} is not retriable"
);
}
}
#[test]
fn jitter_stays_within_the_ceiling() {
assert_eq!(jitter(0), 0, "a zero ceiling yields no wait");
for ceiling in [1u64, 25, 250, 1000] {
for _ in 0..1000 {
assert!(
jitter(ceiling) <= ceiling,
"jitter must stay within ceiling {ceiling}"
);
}
}
}
#[test]
fn idempotent_methods_per_rfc_9110() {
for m in ["GET", "HEAD", "PUT", "DELETE", "OPTIONS", "TRACE"] {
assert!(is_idempotent(m), "{m} is idempotent (RFC 9110 §9.2.2)");
}
for m in ["POST", "PATCH", "CONNECT", "get", ""] {
assert!(!is_idempotent(m), "{m} is not idempotent");
}
}
#[test]
fn may_retry_gates_on_failure_method_replayability_and_budget() {
assert!(may_retry(Failure::Timeout, "GET", true, 1));
assert!(!may_retry(Failure::Timeout, "POST", true, 1));
assert!(may_retry(Failure::Connect, "POST", true, 1));
assert!(may_retry(Failure::Connect, "GET", true, 1));
assert!(!may_retry(Failure::Timeout, "GET", false, 1));
assert!(!may_retry(Failure::Connect, "POST", false, 1));
assert!(!may_retry(Failure::Timeout, "GET", true, 0));
assert!(!may_retry(Failure::Connect, "GET", true, 0));
}
#[test]
fn per_try_timeout_reports_overall_deadline_elapsed() {
let now = Instant::now();
let deadline = now - Duration::from_millis(1);
assert_eq!(
per_try_timeout(Some(deadline), Duration::from_secs(1), now),
PerTryTimeout::OverallDeadlineElapsed
);
}
#[test]
fn per_try_timeout_bounds_by_the_tighter_of_per_try_and_remaining() {
let now = Instant::now();
let deadline = now + Duration::from_millis(50);
assert_eq!(
per_try_timeout(Some(deadline), Duration::from_secs(1), now),
PerTryTimeout::Bounded(Duration::from_millis(50))
);
assert_eq!(
per_try_timeout(Some(deadline), Duration::from_millis(10), now),
PerTryTimeout::Bounded(Duration::from_millis(10))
);
assert_eq!(
per_try_timeout(None, Duration::from_millis(10), now),
PerTryTimeout::Bounded(Duration::from_millis(10))
);
}
#[test]
fn should_attempt_retry_gates_on_outcome_method_replayability_budget_and_overall_deadline() {
struct Case {
name: &'static str,
outcome: AttemptOutcome,
method: &'static str,
replayable: bool,
tries_left: u64,
overall_elapsed: bool,
want: bool,
}
let cases = vec![
Case {
name: "retriable 5xx, idempotent -> attempt retry",
outcome: AttemptOutcome::Response {
retriable_5xx: true,
},
method: "GET",
replayable: true,
tries_left: 1,
overall_elapsed: false,
want: true,
},
Case {
name: "non-retriable status -> stop even with budget",
outcome: AttemptOutcome::Response {
retriable_5xx: false,
},
method: "GET",
replayable: true,
tries_left: 1,
overall_elapsed: false,
want: false,
},
Case {
name: "timeout, idempotent -> attempt retry",
outcome: AttemptOutcome::TimedOut,
method: "HEAD",
replayable: true,
tries_left: 1,
overall_elapsed: false,
want: true,
},
Case {
name: "timeout, non-idempotent -> stop",
outcome: AttemptOutcome::TimedOut,
method: "POST",
replayable: true,
tries_left: 1,
overall_elapsed: false,
want: false,
},
Case {
name: "overall deadline elapsed -> stop unconditionally",
outcome: AttemptOutcome::TimedOut,
method: "GET",
replayable: true,
tries_left: 5,
overall_elapsed: true,
want: false,
},
Case {
name: "connect failure, non-idempotent method -> still attempts (any method safe)",
outcome: AttemptOutcome::Failed { connect: true },
method: "POST",
replayable: true,
tries_left: 1,
overall_elapsed: false,
want: true,
},
Case {
name: "non-connect transport fault -> never retried",
outcome: AttemptOutcome::Failed { connect: false },
method: "GET",
replayable: true,
tries_left: 1,
overall_elapsed: false,
want: false,
},
Case {
name: "one-shot streamed body -> never retried regardless of everything else",
outcome: AttemptOutcome::Response {
retriable_5xx: true,
},
method: "GET",
replayable: false,
tries_left: 1,
overall_elapsed: false,
want: false,
},
Case {
name: "exhausted retry budget -> stop",
outcome: AttemptOutcome::TimedOut,
method: "GET",
replayable: true,
tries_left: 0,
overall_elapsed: false,
want: false,
},
];
for case in cases {
let got = should_attempt_retry(
case.outcome,
case.method,
case.replayable,
case.tries_left,
case.overall_elapsed,
);
assert_eq!(got, case.want, "case: {}", case.name);
}
}
}