use std::time::Duration;
use crate::client::RetryOverride;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum RetrySafety {
Idempotent,
NonIdempotent,
}
impl RetrySafety {
pub fn for_method(method: &str) -> Self {
match method {
"GET" | "HEAD" | "OPTIONS" | "PUT" | "DELETE" => RetrySafety::Idempotent,
_ => RetrySafety::NonIdempotent,
}
}
pub fn effective(self, override_: Option<RetryOverride>) -> Self {
match override_ {
Some(RetryOverride::AssumeIdempotent) => RetrySafety::Idempotent,
None => self,
}
}
}
pub const RETRYABLE_STATUSES: &[u16] = &[408, 425, 429, 500, 502, 503, 504];
pub const NON_RETRYABLE_QUOTA_CODES: &[u16] = &[
1113, 1308, 1309, 1310, 1311, 1313, 1314, 1315, 1316, 1317, 1318, 1319, 1320, 1321,
];
pub const NON_RETRYABLE_VALIDATION_CODES: &[u16] =
&[1210, 1211, 1212, 1213, 1214, 1215, 1221, 1222, 1261, 1301];
pub const RETRYABLE_SERVER_CODES: &[u16] = &[1200, 1230, 1234];
pub const RETRYABLE_RATE_CODES: &[u16] = &[1302, 1305];
pub fn is_retryable_outcome(status: u16, business_code: Option<u16>) -> bool {
if let Some(code) = business_code
&& (NON_RETRYABLE_QUOTA_CODES.contains(&code)
|| NON_RETRYABLE_VALIDATION_CODES.contains(&code))
{
return false;
}
if business_code.is_some_and(|code| {
RETRYABLE_RATE_CODES.contains(&code) || RETRYABLE_SERVER_CODES.contains(&code)
}) {
return true;
}
RETRYABLE_STATUSES.contains(&status)
}
pub trait JitterSource: Send + Sync {
fn jitter(&self, upper: Duration) -> Duration;
}
pub fn full_jitter_cap(retry_index: u32) -> Duration {
let base_ms: u64 = 200;
let cap_ms: u64 = 8_000;
let scaled = base_ms.saturating_mul(1u64 << retry_index.min(20));
Duration::from_millis(scaled.min(cap_ms))
}
pub fn backoff_delay(retry_index: u32, jitter: &dyn JitterSource) -> Duration {
jitter.jitter(full_jitter_cap(retry_index))
}
pub fn parse_retry_after(value: &str) -> Option<Duration> {
let trimmed = value.trim();
trimmed
.parse::<u64>()
.ok()
.filter(|seconds| *seconds > 0)
.map(Duration::from_secs)
}
pub fn reconcile_retry_after(hint: Option<Duration>, computed: Duration) -> Duration {
match hint {
Some(h) if h >= computed => h,
_ => computed,
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::sync::atomic::{AtomicU64, Ordering};
struct CountingJitter(AtomicU64);
impl JitterSource for CountingJitter {
fn jitter(&self, upper: Duration) -> Duration {
let n = self.0.fetch_add(1, Ordering::SeqCst);
upper / u32::try_from(n.saturating_add(1)).unwrap_or(u32::MAX)
}
}
#[test]
fn method_classification_is_fixed() {
assert_eq!(RetrySafety::for_method("GET"), RetrySafety::Idempotent);
assert_eq!(RetrySafety::for_method("PUT"), RetrySafety::Idempotent);
assert_eq!(RetrySafety::for_method("DELETE"), RetrySafety::Idempotent);
assert_eq!(RetrySafety::for_method("POST"), RetrySafety::NonIdempotent);
assert_eq!(RetrySafety::for_method("PATCH"), RetrySafety::NonIdempotent);
}
#[test]
fn override_makes_post_idempotent() {
let s = RetrySafety::for_method("POST").effective(Some(RetryOverride::AssumeIdempotent));
assert_eq!(s, RetrySafety::Idempotent);
}
#[test]
fn non_retryable_codes_override_status() {
assert!(!is_retryable_outcome(429, Some(1113)));
assert!(!is_retryable_outcome(429, Some(1210)));
assert!(is_retryable_outcome(429, None));
for code in RETRYABLE_RATE_CODES {
assert!(is_retryable_outcome(200, Some(*code)));
assert!(is_retryable_outcome(400, Some(*code)));
}
for code in RETRYABLE_SERVER_CODES {
assert!(is_retryable_outcome(200, Some(*code)));
}
}
#[test]
fn excluded_statuses_not_retried() {
assert!(!is_retryable_outcome(501, None));
assert!(!is_retryable_outcome(505, None));
assert!(!is_retryable_outcome(400, None));
assert!(!is_retryable_outcome(404, None));
for status in 100_u16..600 {
assert_eq!(
is_retryable_outcome(status, None),
matches!(status, 408 | 425 | 429 | 500 | 502 | 503 | 504),
"HTTP {status}"
);
}
}
#[test]
fn full_jitter_cap_saturates_at_8s() {
assert_eq!(full_jitter_cap(0), Duration::from_millis(200));
assert_eq!(full_jitter_cap(1), Duration::from_millis(400));
assert_eq!(full_jitter_cap(2), Duration::from_millis(800));
assert_eq!(full_jitter_cap(5), Duration::from_millis(6400));
assert_eq!(full_jitter_cap(6), Duration::from_secs(8));
assert_eq!(full_jitter_cap(100), Duration::from_secs(8));
for retry in 0..30 {
assert!(full_jitter_cap(retry) <= Duration::from_secs(8));
}
for retry in 0..6 {
assert!(full_jitter_cap(retry + 1) >= full_jitter_cap(retry));
}
}
#[test]
fn backoff_is_bounded_by_cap() {
let j = CountingJitter(AtomicU64::new(0));
for n in 0..10 {
let d = backoff_delay(n, &j);
assert!(d <= full_jitter_cap(n), "retry {n} delay {d:?} exceeds cap");
}
}
#[test]
fn parse_retry_after_integer_seconds() {
assert_eq!(parse_retry_after("120"), Some(Duration::from_secs(120)));
assert_eq!(parse_retry_after("0"), None);
assert_eq!(parse_retry_after("garbage"), None);
assert_eq!(parse_retry_after("Sun, 06 Nov 1994 08:49:37 GMT"), None);
assert_eq!(parse_retry_after(" 5 "), Some(Duration::from_secs(5)));
}
#[test]
fn reconcile_takes_larger_hint() {
let computed = Duration::from_secs(2);
assert_eq!(reconcile_retry_after(None, computed), computed);
assert_eq!(
reconcile_retry_after(Some(Duration::from_secs(10)), computed),
Duration::from_secs(10)
);
assert_eq!(
reconcile_retry_after(Some(Duration::from_secs(1)), computed),
computed
);
}
}