use std::error::Error as StdError;
use std::fmt;
use std::io;
use std::ops::ControlFlow;
use std::time::Duration;
use crate::log::StageLogger;
#[derive(Clone, Copy)]
pub struct RetryLog<'a> {
desc: &'a str,
log: &'a StageLogger,
}
impl<'a> RetryLog<'a> {
pub fn new(desc: &'a str, log: &'a StageLogger) -> Self {
Self { desc, log }
}
pub fn desc(&self) -> &str {
self.desc
}
fn warn_retry(&self, attempt: u32, max: u32, cause: &dyn fmt::Display, delay: Duration) {
self.log.warn(&format!(
"{} attempt {}/{} failed ({}); retrying in {}",
self.desc,
attempt,
max,
cause,
crate::progress::format_elapsed(delay)
));
}
fn warn_giving_up(&self, attempts: u32) {
self.log.warn(&format!(
"{} failed after {} attempt(s), giving up",
self.desc, attempts
));
}
fn note_succeeded(&self, attempts: u32) {
self.log.status(&format!(
"{} succeeded after {} attempt(s)",
self.desc, attempts
)); }
}
#[derive(Debug, Clone, Copy)]
pub struct RetryPolicy {
pub max_attempts: u32,
pub base_delay: Duration,
pub max_delay: Duration,
}
impl RetryPolicy {
pub const UPLOAD: RetryPolicy = RetryPolicy {
max_attempts: 10,
base_delay: Duration::from_millis(50),
max_delay: Duration::from_secs(30),
};
pub const PREFLIGHT: RetryPolicy = RetryPolicy {
max_attempts: 3,
base_delay: Duration::from_millis(200),
max_delay: Duration::from_secs(1),
};
pub const GUARD_PROBE: RetryPolicy = RetryPolicy {
max_attempts: 3,
base_delay: Duration::from_secs(1),
max_delay: Duration::from_secs(30),
};
pub fn delay_for(&self, next_attempt: u32) -> Duration {
let exp = next_attempt.saturating_sub(2);
let mult = 1u64.checked_shl(exp).unwrap_or(u64::MAX);
let ms = (self.base_delay.as_millis() as u64).saturating_mul(mult);
std::cmp::min(Duration::from_millis(ms), self.max_delay)
}
pub fn with_idempotent_floor(self) -> RetryPolicy {
self.with_floor(IDEMPOTENT_PUT_ATTEMPTS)
}
pub fn with_floor(self, min: u32) -> RetryPolicy {
RetryPolicy {
max_attempts: self.max_attempts.max(min),
..self
}
}
pub fn budget_exhausted(&self, next_attempt: u32, deadline: std::time::Instant) -> bool {
match std::time::Instant::now().checked_add(self.delay_for(next_attempt)) {
Some(projected) => projected > deadline,
None => true,
}
}
}
pub const IDEMPOTENT_PUT_ATTEMPTS: u32 = 3;
pub const DEFAULT_MAX_ELAPSED: Duration = Duration::from_secs(15 * 60);
static RETRY_BACKOFF_MILLIS: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
static PER_SCOPE_RETRY: std::sync::Mutex<std::collections::BTreeMap<String, ScopeRetry>> =
std::sync::Mutex::new(std::collections::BTreeMap::new());
static CURRENT_SCOPE: std::sync::Mutex<Option<String>> = std::sync::Mutex::new(None);
const UNATTRIBUTED_SCOPE: &str = "(unattributed)";
#[derive(Clone, Copy, Default)]
struct ScopeRetry {
retries: u32,
backoff_ms: u64,
}
#[must_use = "the scope only applies while the guard is alive"]
pub struct RetryScope {
prev: Option<String>,
}
impl RetryScope {
pub fn enter(name: impl Into<String>) -> Self {
let mut cur = CURRENT_SCOPE.lock().unwrap_or_else(|e| e.into_inner());
let prev = cur.replace(name.into());
RetryScope { prev }
}
}
impl Drop for RetryScope {
fn drop(&mut self) {
*CURRENT_SCOPE.lock().unwrap_or_else(|e| e.into_inner()) = self.prev.take();
}
}
pub fn record_retry_backoff(d: Duration) {
let ms = u64::try_from(d.as_millis()).unwrap_or(u64::MAX);
RETRY_BACKOFF_MILLIS.fetch_add(ms, std::sync::atomic::Ordering::Relaxed);
let key = CURRENT_SCOPE
.lock()
.unwrap_or_else(|e| e.into_inner())
.clone()
.unwrap_or_else(|| UNATTRIBUTED_SCOPE.to_string());
let mut map = PER_SCOPE_RETRY.lock().unwrap_or_else(|e| e.into_inner());
let entry = map.entry(key).or_default();
entry.retries = entry.retries.saturating_add(1);
entry.backoff_ms = entry.backoff_ms.saturating_add(ms);
}
pub fn sleep_backoff_blocking(d: Duration) {
if d.is_zero() {
return;
}
record_retry_backoff(d);
std::thread::sleep(d);
}
pub async fn sleep_backoff_async(d: Duration) {
if d.is_zero() {
return;
}
record_retry_backoff(d);
tokio::time::sleep(d).await;
}
pub fn total_retry_backoff() -> Duration {
Duration::from_millis(RETRY_BACKOFF_MILLIS.load(std::sync::atomic::Ordering::Relaxed))
}
pub fn retry_scope_breakdown() -> Vec<(String, u32, Duration)> {
let map = PER_SCOPE_RETRY.lock().unwrap_or_else(|e| e.into_inner());
let mut rows: Vec<(String, u32, Duration)> = map
.iter()
.map(|(k, v)| (k.clone(), v.retries, Duration::from_millis(v.backoff_ms)))
.collect();
rows.sort_by(|a, b| b.2.cmp(&a.2).then_with(|| a.0.cmp(&b.0)));
rows
}
pub fn retry_sync<T, E, F>(rlog: RetryLog<'_>, policy: &RetryPolicy, op: F) -> Result<T, E>
where
E: fmt::Display,
F: FnMut(u32) -> Result<T, ControlFlow<E, E>>,
{
retry_sync_deadline(rlog, policy, None, op)
}
pub fn retry_sync_deadline<T, E, F>(
rlog: RetryLog<'_>,
policy: &RetryPolicy,
deadline: Option<std::time::Instant>,
mut op: F,
) -> Result<T, E>
where
E: fmt::Display,
F: FnMut(u32) -> Result<T, ControlFlow<E, E>>,
{
retry_steps_sync(rlog, policy.max_attempts, deadline, |attempt| {
controlflow_to_step(policy, attempt, op(attempt))
})
}
fn controlflow_to_step<T, E: fmt::Display>(
policy: &RetryPolicy,
attempt: u32,
result: Result<T, ControlFlow<E, E>>,
) -> RetryStep<T, E> {
match result {
Ok(v) => RetryStep::Done(v),
Err(ControlFlow::Break(e)) => RetryStep::Fail(e),
Err(ControlFlow::Continue(e)) => {
let cause = e.to_string();
RetryStep::Retry {
error: e,
delay: policy.delay_for(attempt + 1),
cause,
}
}
}
}
pub async fn retry_async<T, E, F, Fut>(
rlog: RetryLog<'_>,
policy: &RetryPolicy,
op: F,
) -> Result<T, E>
where
E: fmt::Display,
F: FnMut(u32) -> Fut,
Fut: std::future::Future<Output = Result<T, ControlFlow<E, E>>>,
{
retry_async_deadline(rlog, policy, None, op).await
}
pub async fn retry_async_deadline<T, E, F, Fut>(
rlog: RetryLog<'_>,
policy: &RetryPolicy,
deadline: Option<std::time::Instant>,
mut op: F,
) -> Result<T, E>
where
E: fmt::Display,
F: FnMut(u32) -> Fut,
Fut: std::future::Future<Output = Result<T, ControlFlow<E, E>>>,
{
retry_steps_async(rlog, policy.max_attempts, deadline, |attempt| {
let fut = op(attempt);
async move { controlflow_to_step(policy, attempt, fut.await) }
})
.await
}
pub enum RetryStep<T, E> {
Done(T),
DoneQuiet(T),
Fail(E),
Retry {
error: E,
delay: Duration,
cause: String,
},
}
pub fn retry_steps_sync<T, E, F>(
rlog: RetryLog<'_>,
max_attempts: u32,
deadline: Option<std::time::Instant>,
mut op: F,
) -> Result<T, E>
where
F: FnMut(u32) -> RetryStep<T, E>,
{
let max = max_attempts.max(1);
let mut attempt: u32 = 1;
loop {
match op(attempt) {
RetryStep::Done(v) => {
if attempt > 1 {
rlog.note_succeeded(attempt);
}
return Ok(v);
}
RetryStep::DoneQuiet(v) => return Ok(v),
RetryStep::Fail(e) => return Err(e),
RetryStep::Retry {
error,
delay,
cause,
} => {
if attempt >= max || deadline_exhausted(deadline, delay) {
rlog.warn_giving_up(attempt);
return Err(error);
}
rlog.warn_retry(attempt, max, &cause, delay);
sleep_backoff_blocking(delay);
}
}
attempt += 1;
}
}
pub async fn retry_steps_async<T, E, F, Fut>(
rlog: RetryLog<'_>,
max_attempts: u32,
deadline: Option<std::time::Instant>,
mut op: F,
) -> Result<T, E>
where
F: FnMut(u32) -> Fut,
Fut: std::future::Future<Output = RetryStep<T, E>>,
{
let max = max_attempts.max(1);
let mut attempt: u32 = 1;
loop {
match op(attempt).await {
RetryStep::Done(v) => {
if attempt > 1 {
rlog.note_succeeded(attempt);
}
return Ok(v);
}
RetryStep::DoneQuiet(v) => return Ok(v),
RetryStep::Fail(e) => return Err(e),
RetryStep::Retry {
error,
delay,
cause,
} => {
if attempt >= max || deadline_exhausted(deadline, delay) {
rlog.warn_giving_up(attempt);
return Err(error);
}
rlog.warn_retry(attempt, max, &cause, delay);
sleep_backoff_async(delay).await;
}
}
attempt += 1;
}
}
fn deadline_exhausted(deadline: Option<std::time::Instant>, delay: Duration) -> bool {
deadline.is_some_and(|d| {
std::time::Instant::now()
.checked_add(delay)
.is_none_or(|projected| projected > d)
})
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum SuccessClass {
Strict,
AllowRedirects,
}
pub fn retry_http_blocking<F, M>(
rlog: RetryLog<'_>,
policy: &RetryPolicy,
success_class: SuccessClass,
send: F,
error_msg: M,
) -> anyhow::Result<(reqwest::StatusCode, String)>
where
F: FnMut(u32) -> Result<reqwest::blocking::Response, reqwest::Error>,
M: Fn(reqwest::StatusCode, &str) -> String,
{
retry_http_blocking_deadline(rlog, policy, None, success_class, send, error_msg)
}
pub fn retry_http_blocking_deadline<F, M>(
rlog: RetryLog<'_>,
policy: &RetryPolicy,
deadline: Option<std::time::Instant>,
success_class: SuccessClass,
mut send: F,
error_msg: M,
) -> anyhow::Result<(reqwest::StatusCode, String)>
where
F: FnMut(u32) -> Result<reqwest::blocking::Response, reqwest::Error>,
M: Fn(reqwest::StatusCode, &str) -> String,
{
use anyhow::Context as _;
retry_sync_deadline(rlog, policy, deadline, |attempt| {
match send(attempt) {
Ok(resp) => {
let status = resp.status();
let succeeded = match success_class {
SuccessClass::Strict => status.is_success(),
SuccessClass::AllowRedirects => status.is_success() || status.is_redirection(),
};
let body = resp
.text()
.unwrap_or_else(|e| format!("<failed to read body: {e}>"));
if succeeded {
Ok((status, body))
} else {
let msg = error_msg(status, &body);
let inner = anyhow::anyhow!("{msg}");
let wrapped = anyhow::Error::new(HttpError::new(
std::io::Error::other(inner.to_string()),
status.as_u16(),
))
.context(inner);
if is_retriable(wrapped.as_ref()) {
Err(ControlFlow::Continue(wrapped))
} else {
Err(ControlFlow::Break(wrapped))
}
}
}
Err(e) => {
let err = anyhow::Error::new(HttpError::from_response(e, None))
.context(format!("{}: HTTP transport error", rlog.desc()));
if is_retriable(err.as_ref()) {
Err(ControlFlow::Continue(err))
} else {
Err(ControlFlow::Break(err))
}
}
}
})
.with_context(|| format!("{}: exhausted retry attempts", rlog.desc()))
}
pub fn retry_http_blocking_bytes<F, M>(
rlog: RetryLog<'_>,
policy: &RetryPolicy,
success_class: SuccessClass,
send: F,
error_msg: M,
) -> anyhow::Result<(reqwest::StatusCode, Vec<u8>)>
where
F: FnMut(u32) -> Result<reqwest::blocking::Response, reqwest::Error>,
M: Fn(reqwest::StatusCode, &str) -> String,
{
retry_http_blocking_bytes_deadline(rlog, policy, None, success_class, send, error_msg)
}
pub fn retry_http_blocking_bytes_deadline<F, M>(
rlog: RetryLog<'_>,
policy: &RetryPolicy,
deadline: Option<std::time::Instant>,
success_class: SuccessClass,
mut send: F,
error_msg: M,
) -> anyhow::Result<(reqwest::StatusCode, Vec<u8>)>
where
F: FnMut(u32) -> Result<reqwest::blocking::Response, reqwest::Error>,
M: Fn(reqwest::StatusCode, &str) -> String,
{
use anyhow::Context as _;
retry_sync_deadline(rlog, policy, deadline, |attempt| match send(attempt) {
Ok(resp) => {
let status = resp.status();
let succeeded = match success_class {
SuccessClass::Strict => status.is_success(),
SuccessClass::AllowRedirects => status.is_success() || status.is_redirection(),
};
let bytes = resp
.bytes()
.map(|b| b.to_vec())
.unwrap_or_else(|e| format!("<failed to read body: {e}>").into_bytes());
if succeeded {
Ok((status, bytes))
} else {
let body_text = String::from_utf8_lossy(&bytes).into_owned();
let msg = error_msg(status, &body_text);
let inner = anyhow::anyhow!("{msg}");
let wrapped = anyhow::Error::new(HttpError::new(
std::io::Error::other(inner.to_string()),
status.as_u16(),
))
.context(inner);
if is_retriable(wrapped.as_ref()) {
Err(ControlFlow::Continue(wrapped))
} else {
Err(ControlFlow::Break(wrapped))
}
}
}
Err(e) => {
let err = anyhow::Error::new(HttpError::from_response(e, None))
.context(format!("{}: HTTP transport error", rlog.desc()));
if is_retriable(err.as_ref()) {
Err(ControlFlow::Continue(err))
} else {
Err(ControlFlow::Break(err))
}
}
})
.with_context(|| format!("{}: exhausted retry attempts", rlog.desc()))
}
pub async fn retry_http_async<F, Fut, M>(
rlog: RetryLog<'_>,
policy: &RetryPolicy,
success_class: SuccessClass,
send: F,
error_msg: M,
) -> anyhow::Result<reqwest::Response>
where
F: FnMut(u32) -> Fut,
Fut: std::future::Future<Output = Result<reqwest::Response, reqwest::Error>>,
M: Fn(reqwest::StatusCode, &str) -> String,
{
retry_http_async_deadline(rlog, policy, None, success_class, send, error_msg).await
}
pub async fn retry_http_async_deadline<F, Fut, M>(
rlog: RetryLog<'_>,
policy: &RetryPolicy,
deadline: Option<std::time::Instant>,
success_class: SuccessClass,
mut send: F,
error_msg: M,
) -> anyhow::Result<reqwest::Response>
where
F: FnMut(u32) -> Fut,
Fut: std::future::Future<Output = Result<reqwest::Response, reqwest::Error>>,
M: Fn(reqwest::StatusCode, &str) -> String,
{
use anyhow::Context as _;
retry_async_deadline(rlog, policy, deadline, |attempt| {
let fut = send(attempt);
let error_msg = &error_msg;
async move {
match fut.await {
Ok(resp) => {
let status = resp.status();
let succeeded = match success_class {
SuccessClass::Strict => status.is_success(),
SuccessClass::AllowRedirects => {
status.is_success() || status.is_redirection()
}
};
if succeeded {
Ok(resp)
} else {
let body = resp
.text()
.await
.unwrap_or_else(|e| format!("<failed to read body: {e}>"));
let msg = error_msg(status, &body);
let inner = anyhow::anyhow!("{msg}");
let wrapped = anyhow::Error::new(HttpError::new(
std::io::Error::other(inner.to_string()),
status.as_u16(),
))
.context(inner);
if is_retriable(wrapped.as_ref()) {
Err(ControlFlow::Continue(wrapped))
} else {
Err(ControlFlow::Break(wrapped))
}
}
}
Err(e) => {
let err = anyhow::Error::new(HttpError::from_response(e, None))
.context(format!("{}: HTTP transport error", rlog.desc()));
if is_retriable(err.as_ref()) {
Err(ControlFlow::Continue(err))
} else {
Err(ControlFlow::Break(err))
}
}
}
}
})
.await
.with_context(|| format!("{}: exhausted retry attempts", rlog.desc()))
}
pub fn classify_http_sync(
result: reqwest::Result<reqwest::blocking::Response>,
) -> Result<reqwest::blocking::Response, ControlFlow<anyhow::Error, anyhow::Error>> {
use anyhow::anyhow;
match result {
Ok(resp) => {
let status = resp.status();
if status.is_success() || status.is_redirection() {
Ok(resp)
} else if status.is_server_error() {
Err(ControlFlow::Continue(anyhow!(
"HTTP {} {}",
status.as_u16(),
status.canonical_reason().unwrap_or("server error")
)))
} else {
Err(ControlFlow::Break(anyhow!(
"HTTP {} {}",
status.as_u16(),
status.canonical_reason().unwrap_or("client error")
)))
}
}
Err(e) => Err(ControlFlow::Continue(anyhow!(e))),
}
}
#[derive(Debug)]
pub struct HttpError {
source: Box<dyn StdError + Send + Sync + 'static>,
pub status: u16,
}
impl HttpError {
pub fn new<E>(source: E, status: u16) -> Self
where
E: StdError + Send + Sync + 'static,
{
Self {
source: Box::new(source),
status,
}
}
pub fn from_response<E>(err: E, resp: Option<&reqwest::Response>) -> Self
where
E: StdError + Send + Sync + 'static,
{
Self::new(err, resp.map(|r| r.status().as_u16()).unwrap_or(0))
}
}
pub fn http_status(err: &anyhow::Error) -> u16 {
err.chain()
.find_map(|e| e.downcast_ref::<HttpError>().map(|h| h.status))
.unwrap_or(0)
}
impl fmt::Display for HttpError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
fmt::Display::fmt(&self.source, f)
}
}
impl StdError for HttpError {
fn source(&self) -> Option<&(dyn StdError + 'static)> {
Some(&*self.source)
}
}
#[derive(Debug)]
pub struct Retriable(Box<dyn StdError + Send + Sync + 'static>);
impl Retriable {
pub fn new<E>(source: E) -> Self
where
E: StdError + Send + Sync + 'static,
{
Self(Box::new(source))
}
}
impl fmt::Display for Retriable {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
fmt::Display::fmt(&self.0, f)
}
}
impl StdError for Retriable {
fn source(&self) -> Option<&(dyn StdError + 'static)> {
Some(&*self.0)
}
}
pub fn is_network_error(err: &(dyn StdError + 'static)) -> bool {
let mut cur: Option<&(dyn StdError + 'static)> = Some(err);
while let Some(e) = cur {
if let Some(io_err) = e.downcast_ref::<io::Error>() {
match io_err.kind() {
io::ErrorKind::UnexpectedEof
| io::ErrorKind::TimedOut
| io::ErrorKind::ConnectionRefused
| io::ErrorKind::ConnectionReset
| io::ErrorKind::ConnectionAborted
| io::ErrorKind::BrokenPipe => return true,
_ => {}
}
let m = io_err.to_string().to_lowercase();
if m == "eof" || m == "unexpected eof" {
return true;
}
}
let s = e.to_string().to_lowercase();
if NETWORK_ERROR_NEEDLES.iter().any(|n| s.contains(n)) {
return true;
}
cur = e.source();
}
false
}
const NETWORK_ERROR_NEEDLES: &[&str] = &[
"connection reset",
"network is unreachable",
"connection closed",
"connection refused",
"tls handshake timeout",
"i/o timeout",
"broken pipe",
"timeout awaiting response headers",
"context deadline exceeded",
"operation timed out",
"the network connection was aborted",
"an existing connection was forcibly closed",
"dns error",
"failed to lookup address",
"no such host is known",
];
pub fn is_retriable(err: &(dyn StdError + 'static)) -> bool {
let mut cur: Option<&(dyn StdError + 'static)> = Some(err);
while let Some(e) = cur {
if e.is::<Retriable>() {
return true;
}
if let Some(http) = e.downcast_ref::<HttpError>()
&& status_is_retriable(http.status)
{
return true;
}
cur = e.source();
}
is_network_error(err)
}
pub fn status_is_retriable(status: u16) -> bool {
status >= 500 || status == 429
}
pub fn is_retriable_opt(err: Option<&(dyn StdError + 'static)>) -> bool {
err.is_some_and(is_retriable)
}
pub fn jitter_duration(base: Duration) -> Duration {
let nanos = base.as_nanos() as u64;
let window = nanos / 5;
if window == 0 {
return base;
}
static JITTER_SEQ: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
let clock = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.subsec_nanos() as u64)
.unwrap_or(0);
let seq = JITTER_SEQ.fetch_add(0x9E37_79B9_7F4A_7C15, std::sync::atomic::Ordering::Relaxed);
let seed = clock ^ seq;
let offset = seed % (window * 2);
let jittered = nanos.saturating_sub(window).saturating_add(offset);
Duration::from_nanos(jittered)
}
#[cfg(test)]
mod tests {
use super::*;
use std::sync::atomic::{AtomicU32, Ordering};
use crate::test_helpers::{test_logger, test_retry_log as tlog};
#[test]
fn backoff_accumulator_is_monotonic_and_sleep_helper_records() {
let before = total_retry_backoff();
record_retry_backoff(Duration::from_millis(250));
assert!(
total_retry_backoff().saturating_sub(before) >= Duration::from_millis(250),
"record_retry_backoff must add at least its duration"
);
let before_sleep = total_retry_backoff();
let start = std::time::Instant::now();
sleep_backoff_blocking(Duration::from_millis(30));
assert!(
start.elapsed() >= Duration::from_millis(30),
"helper must sleep"
);
assert!(
total_retry_backoff().saturating_sub(before_sleep) >= Duration::from_millis(30),
"sleep_backoff_blocking must record its sleep"
);
}
#[test]
fn retry_scope_attributes_backoff_to_its_label() {
let scope_name = "test-scope-attributes-2f9c";
let read = |name: &str| -> (u32, Duration) {
retry_scope_breakdown()
.into_iter()
.find(|(k, _, _)| k == name)
.map(|(_, r, d)| (r, d))
.unwrap_or((0, Duration::ZERO))
};
let (r0, d0) = read(scope_name);
{
let _scope = RetryScope::enter(scope_name);
record_retry_backoff(Duration::from_millis(40));
record_retry_backoff(Duration::from_millis(60));
}
let (r1, d1) = read(scope_name);
assert!(r1 >= r0 + 2, "two records must add at least two retries");
assert!(
d1.saturating_sub(d0) >= Duration::from_millis(100),
"scope backoff must sum the recorded sleeps"
);
record_retry_backoff(Duration::from_millis(10));
assert_eq!(
read(scope_name).0,
r1,
"records outside the scope must not attribute to it"
);
}
fn fast_policy() -> RetryPolicy {
RetryPolicy {
max_attempts: 4,
base_delay: Duration::from_millis(1),
max_delay: Duration::from_millis(5),
}
}
#[test]
fn preflight_policy_is_shallow() {
let p = RetryPolicy::PREFLIGHT;
assert_eq!(p.max_attempts, 3);
assert_eq!(p.base_delay, Duration::from_millis(200));
assert_eq!(p.max_delay, Duration::from_secs(1));
let total_sleep: Duration = (2..=p.max_attempts).map(|n| p.delay_for(n)).sum();
assert!(
total_sleep < Duration::from_secs(1),
"preflight backoff sleeps must stay sub-second, got {total_sleep:?}"
);
}
#[test]
fn guard_probe_policy_is_shallow_and_capped() {
let p = RetryPolicy::GUARD_PROBE;
assert_eq!(p.max_attempts, 3);
assert_eq!(p.base_delay, Duration::from_secs(1));
assert_eq!(p.max_delay, Duration::from_secs(30));
for n in 2..=p.max_attempts {
assert!(p.delay_for(n) <= Duration::from_secs(30));
}
let total_sleep: Duration = (2..=p.max_attempts).map(|n| p.delay_for(n)).sum();
assert!(
total_sleep <= Duration::from_secs(3),
"guard probe backoff must stay in seconds, got {total_sleep:?}"
);
}
#[test]
fn http_status_extracts_status_from_chain() {
let wrapped = anyhow::Error::new(HttpError::new(std::io::Error::other("boom"), 429))
.context("outer context");
assert_eq!(http_status(&wrapped), 429);
}
#[test]
fn http_status_is_zero_without_http_error() {
let plain = anyhow::anyhow!("not an http error");
assert_eq!(http_status(&plain), 0);
}
#[test]
fn idempotent_floor_raises_low_cap_and_preserves_high_cap() {
let raised = RetryPolicy {
max_attempts: 1,
base_delay: Duration::from_millis(1),
max_delay: Duration::from_millis(5),
}
.with_idempotent_floor();
assert_eq!(
raised.max_attempts, IDEMPOTENT_PUT_ATTEMPTS,
"a single-attempt cap must be raised to the idempotent floor"
);
let preserved = RetryPolicy {
max_attempts: 7,
base_delay: Duration::from_millis(1),
max_delay: Duration::from_millis(5),
}
.with_idempotent_floor();
assert_eq!(
preserved.max_attempts, 7,
"an operator-set cap above the floor must be preserved, not lowered"
);
}
#[test]
fn jitter_returns_base_when_window_rounds_to_zero() {
for n in 0..5u64 {
let base = Duration::from_nanos(n);
assert_eq!(
jitter_duration(base),
base,
"sub-5ns base {n} must pass through unjittered"
);
}
}
#[test]
fn jitter_stays_within_plus_minus_twenty_percent() {
let base = Duration::from_millis(100);
let jittered = jitter_duration(base);
let lo = base.mul_f64(0.8);
let hi = base.mul_f64(1.2);
assert!(
jittered >= lo && jittered < hi,
"jittered {jittered:?} outside [{lo:?}, {hi:?})"
);
}
#[test]
fn jitter_spreads_consecutive_draws_even_with_a_pinned_clock() {
let base = Duration::from_millis(100);
let draws: Vec<Duration> = (0..8).map(|_| jitter_duration(base)).collect();
assert!(
draws.windows(2).any(|w| w[0] != w[1]),
"8 consecutive jitter draws were all identical: {draws:?}"
);
}
#[test]
fn delay_progression_caps_at_max() {
let p = RetryPolicy {
max_attempts: 10,
base_delay: Duration::from_millis(100),
max_delay: Duration::from_millis(500),
};
assert_eq!(p.delay_for(2), Duration::from_millis(100));
assert_eq!(p.delay_for(3), Duration::from_millis(200));
assert_eq!(p.delay_for(4), Duration::from_millis(400));
assert_eq!(p.delay_for(5), Duration::from_millis(500)); assert_eq!(p.delay_for(8), Duration::from_millis(500)); }
#[test]
fn sync_succeeds_on_first_attempt() {
let calls = AtomicU32::new(0);
let result: Result<&str, &str> = retry_sync(tlog(), &fast_policy(), |_| {
calls.fetch_add(1, Ordering::SeqCst);
Ok("ok")
});
assert_eq!(result, Ok("ok"));
assert_eq!(calls.load(Ordering::SeqCst), 1);
}
#[test]
fn sync_retries_until_success() {
let calls = AtomicU32::new(0);
let result: Result<u32, &str> = retry_sync(tlog(), &fast_policy(), |attempt| {
calls.fetch_add(1, Ordering::SeqCst);
if attempt < 3 {
Err(ControlFlow::Continue("transient"))
} else {
Ok(attempt)
}
});
assert_eq!(result, Ok(3));
assert_eq!(calls.load(Ordering::SeqCst), 3);
}
#[test]
fn sync_break_stops_immediately() {
let calls = AtomicU32::new(0);
let result: Result<(), &str> = retry_sync(tlog(), &fast_policy(), |_| {
calls.fetch_add(1, Ordering::SeqCst);
Err(ControlFlow::Break("fatal"))
});
assert_eq!(result, Err("fatal"));
assert_eq!(calls.load(Ordering::SeqCst), 1);
}
#[test]
fn sync_returns_last_error_after_exhaustion() {
let calls = AtomicU32::new(0);
let result: Result<(), String> = retry_sync(tlog(), &fast_policy(), |attempt| {
calls.fetch_add(1, Ordering::SeqCst);
Err(ControlFlow::Continue(format!("fail {attempt}")))
});
assert_eq!(result, Err("fail 4".to_string()));
assert_eq!(calls.load(Ordering::SeqCst), 4);
}
fn captured() -> (StageLogger, crate::log::LogCapture) {
StageLogger::with_capture("test", crate::log::Verbosity::Normal)
}
const TINY: Duration = Duration::from_millis(1);
#[test]
fn steps_sync_first_try_done_is_silent() {
let (log, cap) = captured();
let out: Result<u32, &str> =
retry_steps_sync(RetryLog::new("op", &log), 4, None, |_| RetryStep::Done(7));
assert_eq!(out, Ok(7));
assert_eq!(cap.total_count(), 0, "a clean first attempt must not log");
}
#[test]
fn steps_sync_retry_then_done_emits_succeeded() {
let (log, cap) = captured();
let out: Result<u32, &str> =
retry_steps_sync(RetryLog::new("op", &log), 5, None, |attempt| {
if attempt < 3 {
RetryStep::Retry {
error: "transient",
delay: TINY,
cause: format!("blip {attempt}"),
}
} else {
RetryStep::Done(attempt)
}
});
assert_eq!(out, Ok(3));
assert_eq!(cap.warn_count(), 2, "one warn per retried attempt");
assert!(
cap.all_messages()
.iter()
.any(|(lvl, m)| *lvl == crate::log::LogLevel::Status
&& m.contains("op succeeded after 3 attempt(s)")),
"recovery after retries must emit a succeeded status line: {:?}",
cap.all_messages()
);
}
#[test]
fn steps_sync_done_quiet_recovers_without_succeeded_line() {
let (log, cap) = captured();
let out: Result<u32, &str> =
retry_steps_sync(RetryLog::new("op", &log), 5, None, |attempt| {
if attempt < 3 {
RetryStep::Retry {
error: "transient",
delay: TINY,
cause: "blip".into(),
}
} else {
RetryStep::DoneQuiet(attempt)
}
});
assert_eq!(out, Ok(3));
assert_eq!(cap.warn_count(), 2, "per-attempt warns still fire");
assert!(
!cap.all_messages()
.iter()
.any(|(_, m)| m.contains("succeeded after")),
"DoneQuiet must suppress the recovery line: {:?}",
cap.all_messages()
);
}
#[test]
fn zero_delay_retry_is_not_counted_as_a_backoff_sleep() {
let (log, _cap) = captured();
let scope = "zero-delay-accounting-probe";
let _guard = RetryScope::enter(scope);
let out: Result<u32, &str> =
retry_steps_sync(RetryLog::new("op", &log), 5, None, |attempt| {
if attempt < 3 {
RetryStep::Retry {
error: "transient",
delay: Duration::ZERO,
cause: "inline wait already served".into(),
}
} else {
RetryStep::Done(attempt)
}
});
assert_eq!(out, Ok(3));
let recorded = retry_scope_breakdown()
.into_iter()
.find(|(name, _, _)| name == scope);
assert!(
recorded.is_none(),
"two zero-delay retries must record no backoff sleeps: {recorded:?}"
);
}
#[test]
fn steps_sync_fail_fast_is_terminal_and_quiet() {
let (log, cap) = captured();
let calls = AtomicU32::new(0);
let out: Result<(), &str> = retry_steps_sync(RetryLog::new("op", &log), 5, None, |_| {
calls.fetch_add(1, Ordering::SeqCst);
RetryStep::Fail("fatal")
});
assert_eq!(out, Err("fatal"));
assert_eq!(calls.load(Ordering::SeqCst), 1, "Fail must not retry");
assert_eq!(
cap.warn_count(),
0,
"a fast-fail owns its own reason; the engine emits no giving-up line"
);
}
#[test]
fn steps_sync_exhaustion_emits_giving_up() {
let (log, cap) = captured();
let calls = AtomicU32::new(0);
let out: Result<(), String> =
retry_steps_sync(RetryLog::new("op", &log), 3, None, |attempt| {
calls.fetch_add(1, Ordering::SeqCst);
RetryStep::Retry {
error: format!("fail {attempt}"),
delay: TINY,
cause: "blip".into(),
}
});
assert_eq!(out, Err("fail 3".to_string()));
assert_eq!(calls.load(Ordering::SeqCst), 3);
assert!(
cap.warn_messages()
.iter()
.any(|m| m.contains("op failed after 3 attempt(s), giving up")),
"exhausting the ladder must emit a giving-up warn: {:?}",
cap.warn_messages()
);
}
#[test]
fn steps_sync_caller_delay_honors_deadline() {
let (log, _cap) = captured();
let calls = AtomicU32::new(0);
let deadline = std::time::Instant::now();
let out: Result<(), &str> =
retry_steps_sync(RetryLog::new("op", &log), 10, Some(deadline), |_| {
calls.fetch_add(1, Ordering::SeqCst);
RetryStep::Retry {
error: "transient",
delay: Duration::from_secs(10),
cause: "blip".into(),
}
});
assert_eq!(out, Err("transient"));
assert_eq!(
calls.load(Ordering::SeqCst),
1,
"a delay that overshoots the deadline stops after one attempt"
);
}
#[tokio::test]
async fn steps_async_retry_then_done_emits_succeeded() {
let (log, cap) = captured();
let out: Result<u32, &str> =
retry_steps_async(RetryLog::new("op", &log), 5, None, |attempt| async move {
if attempt < 2 {
RetryStep::Retry {
error: "transient",
delay: TINY,
cause: "blip".into(),
}
} else {
RetryStep::Done(attempt)
}
})
.await;
assert_eq!(out, Ok(2));
assert_eq!(cap.warn_count(), 1);
assert!(
cap.all_messages()
.iter()
.any(|(lvl, m)| *lvl == crate::log::LogLevel::Status
&& m.contains("op succeeded after 2 attempt(s)"))
);
}
#[test]
fn deadline_already_elapsed_stops_after_one_attempt_without_sleeping() {
let policy = RetryPolicy {
max_attempts: 10,
base_delay: Duration::from_secs(10),
max_delay: Duration::from_secs(300),
};
let deadline = std::time::Instant::now();
let calls = AtomicU32::new(0);
let start = std::time::Instant::now();
let result: Result<(), &str> = retry_sync_deadline(tlog(), &policy, Some(deadline), |_| {
calls.fetch_add(1, Ordering::SeqCst);
Err(ControlFlow::Continue("transient"))
});
assert_eq!(result, Err("transient"));
assert_eq!(
calls.load(Ordering::SeqCst),
1,
"budget-exhausted retry must call op exactly once"
);
assert!(
start.elapsed() < Duration::from_secs(1),
"deadline check must skip the 10s backoff sleep, took {:?}",
start.elapsed()
);
}
#[test]
fn deadline_none_matches_retry_sync_on_success() {
let calls = AtomicU32::new(0);
let result: Result<u32, &str> =
retry_sync_deadline(tlog(), &fast_policy(), None, |attempt| {
calls.fetch_add(1, Ordering::SeqCst);
if attempt < 2 {
Err(ControlFlow::Continue("transient"))
} else {
Ok(attempt)
}
});
assert_eq!(result, Ok(2));
assert_eq!(calls.load(Ordering::SeqCst), 2);
let sync_calls = AtomicU32::new(0);
let sync_result: Result<u32, &str> = retry_sync(tlog(), &fast_policy(), |attempt| {
sync_calls.fetch_add(1, Ordering::SeqCst);
if attempt < 2 {
Err(ControlFlow::Continue("transient"))
} else {
Ok(attempt)
}
});
assert_eq!(sync_result, result);
assert_eq!(sync_calls.load(Ordering::SeqCst), 2);
}
#[test]
fn deadline_far_in_future_does_not_change_behavior() {
let deadline = std::time::Instant::now() + Duration::from_secs(3600);
let calls = AtomicU32::new(0);
let result: Result<u32, &str> =
retry_sync_deadline(tlog(), &fast_policy(), Some(deadline), |attempt| {
calls.fetch_add(1, Ordering::SeqCst);
if attempt < 3 {
Err(ControlFlow::Continue("transient"))
} else {
Ok(attempt)
}
});
assert_eq!(result, Ok(3));
assert_eq!(calls.load(Ordering::SeqCst), 3);
}
#[test]
fn budget_exhausted_fires_on_a_past_deadline_and_not_a_future_one() {
let policy = RetryPolicy {
max_attempts: 10,
base_delay: Duration::from_millis(1),
max_delay: Duration::from_millis(1),
};
let now = std::time::Instant::now();
assert!(policy.budget_exhausted(2, now - Duration::from_secs(1)));
assert!(!policy.budget_exhausted(2, now + Duration::from_secs(3600)));
}
#[test]
fn budget_exhausted_saturates_instead_of_panicking_on_uncapped_backoff() {
let policy = RetryPolicy {
max_attempts: 100,
base_delay: Duration::from_secs(30),
max_delay: Duration::MAX,
};
let now = std::time::Instant::now();
assert!(policy.budget_exhausted(64, now + Duration::from_secs(3600)));
}
#[tokio::test]
async fn async_deadline_none_is_unbounded_and_exhausts_by_count() {
let policy = RetryPolicy {
max_attempts: 3,
base_delay: Duration::from_millis(1),
max_delay: Duration::from_millis(1),
};
let calls = std::sync::Arc::new(AtomicU32::new(0));
let calls_inner = calls.clone();
let result: Result<(), &str> = retry_async(tlog(), &policy, move |_| {
let c = calls_inner.clone();
async move {
c.fetch_add(1, Ordering::SeqCst);
Err(ControlFlow::Continue("transient"))
}
})
.await;
assert_eq!(result, Err("transient"));
assert_eq!(calls.load(Ordering::SeqCst), 3);
}
#[tokio::test]
async fn async_deadline_already_elapsed_stops_after_one_attempt() {
let policy = RetryPolicy {
max_attempts: 10,
base_delay: Duration::from_secs(10),
max_delay: Duration::from_secs(300),
};
let deadline = std::time::Instant::now();
let calls = std::sync::Arc::new(AtomicU32::new(0));
let calls_inner = calls.clone();
let start = std::time::Instant::now();
let result: Result<(), &str> =
retry_async_deadline(tlog(), &policy, Some(deadline), move |_| {
let c = calls_inner.clone();
async move {
c.fetch_add(1, Ordering::SeqCst);
Err(ControlFlow::Continue("transient"))
}
})
.await;
assert_eq!(result, Err("transient"));
assert_eq!(calls.load(Ordering::SeqCst), 1);
assert!(start.elapsed() < Duration::from_secs(1));
}
#[tokio::test]
async fn async_retries_until_success() {
let calls = std::sync::Arc::new(AtomicU32::new(0));
let calls_inner = calls.clone();
let result: Result<u32, &str> = retry_async(tlog(), &fast_policy(), move |attempt| {
let c = calls_inner.clone();
async move {
c.fetch_add(1, Ordering::SeqCst);
if attempt < 2 {
Err(ControlFlow::Continue("transient"))
} else {
Ok(attempt)
}
}
})
.await;
assert_eq!(result, Ok(2));
assert_eq!(calls.load(Ordering::SeqCst), 2);
}
#[derive(Debug)]
struct StrErr(&'static str);
impl fmt::Display for StrErr {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(self.0)
}
}
impl StdError for StrErr {}
#[derive(Debug)]
struct OwnedErr(String);
impl fmt::Display for OwnedErr {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(&self.0)
}
}
impl StdError for OwnedErr {}
#[test]
fn network_error_substrings_match() {
for s in [
"connection reset by peer",
"network is unreachable",
"connection closed unexpectedly",
"connection refused",
"tls handshake timeout",
"i/o timeout",
"CONNECTION RESET",
"TLS Handshake Timeout",
"write: broken pipe",
"net/http: timeout awaiting response headers",
"context deadline exceeded",
"client error (Connect): dns error: failed to lookup address information: Name or service not known",
"dns error: nodename nor servname provided, or not known",
"dns error: No such host is known. (os error 11001)",
] {
let e = OwnedErr(s.to_string());
assert!(is_network_error(&e), "expected network error: {s:?}");
}
}
#[test]
fn network_error_io_eof_kinds() {
let e = io::Error::from(io::ErrorKind::UnexpectedEof);
assert!(is_network_error(&e));
let e2 = io::Error::other("EOF");
assert!(is_network_error(&e2));
}
#[test]
fn is_network_error_classifies_io_timedout() {
let e = io::Error::from(io::ErrorKind::TimedOut);
assert!(is_network_error(&e));
assert!(is_retriable(&e));
}
#[test]
fn is_network_error_classifies_io_connection_refused() {
let e = io::Error::from(io::ErrorKind::ConnectionRefused);
assert!(is_network_error(&e));
assert!(is_retriable(&e));
}
#[test]
fn is_network_error_classifies_io_connection_reset() {
let e = io::Error::from(io::ErrorKind::ConnectionReset);
assert!(is_network_error(&e));
assert!(is_retriable(&e));
}
#[test]
fn is_network_error_classifies_io_connection_aborted() {
let e = io::Error::from(io::ErrorKind::ConnectionAborted);
assert!(is_network_error(&e));
assert!(is_retriable(&e));
}
#[test]
fn is_network_error_classifies_io_broken_pipe() {
let e = io::Error::from(io::ErrorKind::BrokenPipe);
assert!(is_network_error(&e));
assert!(is_retriable(&e));
}
#[test]
fn is_network_error_classifies_operation_timed_out_substring() {
let other_kind = io::Error::other("operation timed out");
assert!(is_network_error(&other_kind));
assert!(is_retriable(&other_kind));
let kind_only = io::Error::from(io::ErrorKind::TimedOut);
assert!(is_network_error(&kind_only));
assert!(is_retriable(&kind_only));
}
#[test]
fn network_error_wrapped_unexpected_eof() {
#[derive(Debug)]
struct Wrap(io::Error);
impl fmt::Display for Wrap {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "read failed")
}
}
impl StdError for Wrap {
fn source(&self) -> Option<&(dyn StdError + 'static)> {
Some(&self.0)
}
}
let inner = io::Error::from(io::ErrorKind::UnexpectedEof);
let outer = Wrap(inner);
assert!(is_network_error(&outer));
}
#[test]
fn network_error_non_network_strings_reject() {
for s in [
"file not found",
"permission denied",
"dial tcp: lookup example.com: no such host",
"",
] {
let e = OwnedErr(s.to_string());
assert!(!is_network_error(&e), "expected NOT network error: {s:?}");
}
}
#[test]
fn retriable_opt_nil_passthrough() {
assert!(!is_retriable_opt(None));
}
#[test]
fn http_error_500_retriable() {
let e = HttpError::new(StrErr("internal server error"), 500);
assert!(is_retriable(&e));
}
#[test]
fn http_error_502_503_retriable() {
for s in [502u16, 503] {
let e = HttpError::new(StrErr("bad gateway"), s);
assert!(is_retriable(&e), "status {s} should be retriable");
}
}
#[test]
fn http_error_429_retriable() {
let e = HttpError::new(StrErr("rate limited"), 429);
assert!(is_retriable(&e));
}
#[test]
fn http_error_4xx_not_retriable() {
for s in [400u16, 401, 403, 404, 422] {
let e = HttpError::new(StrErr("client err"), s);
assert!(!is_retriable(&e), "status {s} should NOT be retriable");
}
}
#[test]
fn http_error_zero_status_routes_via_message() {
let net = HttpError::new(StrErr("connection reset"), 0);
assert!(is_retriable(&net));
let non_net = HttpError::new(StrErr("dial failed"), 0);
assert!(!is_retriable(&non_net));
}
#[test]
fn http_error_unwrap_chain_visible() {
let inner = StrErr("inner");
let e = HttpError::new(inner, 503);
assert!(e.source().is_some());
}
#[test]
fn from_response_nil_resp_yields_status_zero() {
let inner = io::Error::other("connect: dial tcp");
let e = HttpError::from_response(inner, None);
assert_eq!(e.status, 0);
}
#[test]
fn from_response_unwrap_chain_visible() {
let inner = io::Error::other("connection reset by peer");
let e = HttpError::from_response(inner, None);
assert!(
e.source().is_some(),
"inner error must be reachable via source()"
);
assert!(is_retriable(&e));
}
#[test]
fn retriable_wrapper_is_retriable() {
let e = Retriable::new(StrErr("retry me"));
assert!(is_retriable(&e));
}
#[test]
fn retriable_wrapper_overrides_4xx() {
let inner = HttpError::new(StrErr("exists"), 422);
let outer = Retriable::new(inner);
assert!(is_retriable(&outer));
}
#[test]
fn retriable_wrapper_unwrap_chain_visible() {
let inner = StrErr("inner");
let e = Retriable::new(inner);
assert!(e.source().is_some());
}
#[test]
fn plain_error_not_retriable() {
let e = StrErr("something");
assert!(!is_retriable(&e));
}
#[test]
fn anyhow_error_threadable() {
let e: anyhow::Error = anyhow::anyhow!("connection refused");
assert!(is_retriable(e.as_ref()));
let e2: anyhow::Error = anyhow::anyhow!("permission denied");
assert!(!is_retriable(e2.as_ref()));
}
#[test]
fn is_retriable_chain_walks_to_http_error() {
let inner = HttpError::new(StrErr("bad gateway"), 503);
let wrapped: anyhow::Error = anyhow::Error::new(inner).context("publish failed");
assert!(is_retriable(wrapped.as_ref()));
}
#[test]
fn classifier_5xx_via_anyhow_chain_uses_as_ref() {
let wrapped: anyhow::Error =
anyhow::Error::new(HttpError::new(std::io::Error::other("503"), 503))
.context("publish");
assert!(
is_retriable(wrapped.as_ref()),
"5xx HttpError reached via as_ref() must classify retriable"
);
}
#[test]
fn classifier_root_cause_walks_past_http_error_drift_guard() {
let wrapped: anyhow::Error =
anyhow::Error::new(HttpError::new(std::io::Error::other("503"), 503))
.context("publish");
assert!(
!is_retriable(wrapped.root_cause()),
"root_cause() walks past HttpError; 5xx must NOT be detected via the leaf"
);
}
#[test]
fn classifier_429_via_anyhow_chain_uses_as_ref() {
let wrapped: anyhow::Error =
anyhow::Error::new(HttpError::new(std::io::Error::other("429"), 429))
.context("publish");
assert!(is_retriable(wrapped.as_ref()));
assert!(!is_retriable(wrapped.root_cause()));
}
use crate::test_helpers::responder::spawn_oneshot_http_responder;
#[test]
fn retry_http_blocking_success_returns_first_attempt() {
let (addr, calls) =
spawn_oneshot_http_responder(vec!["HTTP/1.1 200 OK\r\nContent-Length: 2\r\n\r\nok"]);
let client = reqwest::blocking::Client::builder()
.timeout(Duration::from_secs(2))
.build()
.expect("client");
let policy = RetryPolicy {
max_attempts: 3,
base_delay: Duration::from_millis(1),
max_delay: Duration::from_millis(2),
};
let result = retry_http_blocking(
RetryLog::new("test", test_logger()),
&policy,
SuccessClass::Strict,
|_| client.get(format!("http://{addr}/")).send(),
|_, _| String::from("should not be called on success"),
);
let (status, body) = result.expect("success");
assert_eq!(status.as_u16(), 200);
assert_eq!(body, "ok");
assert_eq!(calls.load(Ordering::SeqCst), 1, "single attempt");
}
#[test]
fn retry_http_blocking_retries_5xx_then_succeeds() {
let (addr, calls) = spawn_oneshot_http_responder(vec![
"HTTP/1.1 503 Service Unavailable\r\nContent-Length: 0\r\n\r\n",
"HTTP/1.1 200 OK\r\nContent-Length: 2\r\n\r\nok",
]);
let client = reqwest::blocking::Client::builder()
.timeout(Duration::from_secs(2))
.build()
.expect("client");
let policy = RetryPolicy {
max_attempts: 3,
base_delay: Duration::from_millis(1),
max_delay: Duration::from_millis(2),
};
let result = retry_http_blocking(
RetryLog::new("test", test_logger()),
&policy,
SuccessClass::Strict,
|_| client.get(format!("http://{addr}/")).send(),
|status, body| format!("{status}: {body}"),
);
let (status, _) = result.expect("eventually succeeds");
assert_eq!(status.as_u16(), 200);
assert_eq!(calls.load(Ordering::SeqCst), 2, "one retry then success");
}
#[test]
fn retry_http_blocking_deadline_past_stops_after_one_attempt() {
let (addr, calls) = spawn_oneshot_http_responder(vec![
"HTTP/1.1 503 Service Unavailable\r\nContent-Length: 0\r\n\r\n",
"HTTP/1.1 200 OK\r\nContent-Length: 2\r\n\r\nok",
]);
let client = reqwest::blocking::Client::builder()
.timeout(Duration::from_secs(2))
.build()
.expect("client");
let policy = RetryPolicy {
max_attempts: 3,
base_delay: Duration::from_secs(10),
max_delay: Duration::from_secs(300),
};
let deadline = std::time::Instant::now();
let result = retry_http_blocking_deadline(
RetryLog::new("test", test_logger()),
&policy,
Some(deadline),
SuccessClass::Strict,
|_| client.get(format!("http://{addr}/")).send(),
|status, body| format!("{status}: {body}"),
);
assert!(result.is_err(), "past deadline must fail on the 503");
assert_eq!(
calls.load(Ordering::SeqCst),
1,
"past deadline stops before the second attempt"
);
}
#[test]
fn retry_http_blocking_4xx_fast_fails_no_retry() {
let (addr, calls) = spawn_oneshot_http_responder(vec![
"HTTP/1.1 404 Not Found\r\nContent-Length: 9\r\n\r\nnot found",
]);
let client = reqwest::blocking::Client::builder()
.timeout(Duration::from_secs(2))
.build()
.expect("client");
let policy = RetryPolicy {
max_attempts: 5,
base_delay: Duration::from_millis(1),
max_delay: Duration::from_millis(2),
};
let result = retry_http_blocking(
RetryLog::new("myscope", test_logger()),
&policy,
SuccessClass::Strict,
|_| client.get(format!("http://{addr}/")).send(),
|status, body| format!("custom error: {status} body={body}"),
);
let err = result.expect_err("4xx must fast-fail");
let chain = format!("{err:#}");
assert!(
chain.contains("custom error"),
"error formatter must be invoked on non-success; got: {chain}"
);
assert!(chain.contains("404"), "status must be in chain: {chain}");
assert_eq!(
calls.load(Ordering::SeqCst),
1,
"4xx must NOT retry (only one connection accepted)"
);
}
#[test]
fn retry_http_blocking_redirect_class_alters_success_predicate() {
let (addr, _calls) = spawn_oneshot_http_responder(vec![
"HTTP/1.1 307 Temporary Redirect\r\nLocation: /next\r\nContent-Length: 0\r\n\r\n",
]);
let client = reqwest::blocking::Client::builder()
.timeout(Duration::from_secs(2))
.redirect(reqwest::redirect::Policy::none())
.build()
.expect("client");
let policy = RetryPolicy {
max_attempts: 3,
base_delay: Duration::from_millis(1),
max_delay: Duration::from_millis(2),
};
let result = retry_http_blocking(
RetryLog::new("test", test_logger()),
&policy,
SuccessClass::AllowRedirects,
|_| client.get(format!("http://{addr}/")).send(),
|_, _| String::from("should not be called on 3xx with AllowRedirects"),
);
let (status, _) = result.expect("3xx is success under AllowRedirects");
assert_eq!(status.as_u16(), 307);
}
#[test]
fn retry_http_blocking_bytes_preserves_non_utf8_body() {
let body: Vec<u8> = vec![0x1f, 0x8b, 0x08, 0x00, 0x80, 0xff, 0xfe, 0x00];
let listener = std::net::TcpListener::bind("127.0.0.1:0").expect("bind ephemeral port");
let addr = listener.local_addr().expect("local_addr");
let body_for_thread = body.clone();
std::thread::spawn(move || {
use std::io::{Read, Write};
if let Ok((mut stream, _)) = listener.accept() {
let mut buf = [0u8; 1024];
let _ = stream.read(&mut buf);
let header = format!(
"HTTP/1.1 200 OK\r\nContent-Length: {}\r\nConnection: close\r\n\r\n",
body_for_thread.len()
);
let _ = stream.write_all(header.as_bytes());
let _ = stream.write_all(&body_for_thread);
let _ = stream.flush();
let _ = stream.shutdown(std::net::Shutdown::Both);
}
});
let client = reqwest::blocking::Client::builder()
.timeout(Duration::from_secs(2))
.build()
.expect("client");
let policy = RetryPolicy {
max_attempts: 1,
base_delay: Duration::from_millis(1),
max_delay: Duration::from_millis(2),
};
let result = retry_http_blocking_bytes(
RetryLog::new("test", test_logger()),
&policy,
SuccessClass::Strict,
|_| client.get(format!("http://{addr}/")).send(),
|_, _| String::from("should not be called on success"),
);
let (status, bytes) = result.expect("success");
assert_eq!(status.as_u16(), 200);
assert_eq!(bytes, body, "binary body must round-trip byte-for-byte");
}
#[test]
fn retry_http_blocking_bytes_4xx_fast_fails_no_retry() {
let (addr, calls) = spawn_oneshot_http_responder(vec![
"HTTP/1.1 404 Not Found\r\nContent-Length: 9\r\n\r\nnot found",
]);
let client = reqwest::blocking::Client::builder()
.timeout(Duration::from_secs(2))
.build()
.expect("client");
let policy = RetryPolicy {
max_attempts: 5,
base_delay: Duration::from_millis(1),
max_delay: Duration::from_millis(2),
};
let result = retry_http_blocking_bytes(
RetryLog::new("myscope", test_logger()),
&policy,
SuccessClass::Strict,
|_| client.get(format!("http://{addr}/")).send(),
|status, body| format!("custom error: {status} body={body}"),
);
let err = result.expect_err("4xx must fast-fail");
let chain = format!("{err:#}");
assert!(
chain.contains("custom error") && chain.contains("not found"),
"error formatter must see the (lossily-decoded) error body: {chain}"
);
assert_eq!(
calls.load(Ordering::SeqCst),
1,
"4xx must NOT retry (only one connection accepted)"
);
}
#[test]
fn retry_http_blocking_bytes_retries_5xx_then_succeeds() {
let (addr, calls) = spawn_oneshot_http_responder(vec![
"HTTP/1.1 503 Service Unavailable\r\nContent-Length: 0\r\n\r\n",
"HTTP/1.1 200 OK\r\nContent-Length: 2\r\n\r\nok",
]);
let client = reqwest::blocking::Client::builder()
.timeout(Duration::from_secs(2))
.build()
.expect("client");
let policy = RetryPolicy {
max_attempts: 3,
base_delay: Duration::from_millis(1),
max_delay: Duration::from_millis(2),
};
let result = retry_http_blocking_bytes(
RetryLog::new("test", test_logger()),
&policy,
SuccessClass::Strict,
|_| client.get(format!("http://{addr}/")).send(),
|status, body| format!("{status}: {body}"),
);
let (status, bytes) = result.expect("eventually succeeds");
assert_eq!(status.as_u16(), 200);
assert_eq!(bytes, b"ok");
assert_eq!(calls.load(Ordering::SeqCst), 2, "one retry then success");
}
#[tokio::test]
async fn retry_http_async_success_returns_first_attempt() {
let (addr, calls) =
spawn_oneshot_http_responder(vec!["HTTP/1.1 200 OK\r\nContent-Length: 2\r\n\r\nok"]);
let client = reqwest::Client::builder()
.timeout(Duration::from_secs(2))
.build()
.expect("client");
let policy = RetryPolicy {
max_attempts: 3,
base_delay: Duration::from_millis(1),
max_delay: Duration::from_millis(2),
};
let result = retry_http_async(
RetryLog::new("test", test_logger()),
&policy,
SuccessClass::Strict,
|_| client.get(format!("http://{addr}/")).send(),
|_, _| String::from("should not be called on success"),
)
.await;
let resp = result.expect("success");
assert_eq!(resp.status().as_u16(), 200);
let body = resp.text().await.expect("body");
assert_eq!(body, "ok");
assert_eq!(calls.load(Ordering::SeqCst), 1, "single attempt");
}
#[tokio::test]
async fn retry_http_async_retries_5xx_then_succeeds() {
let (addr, calls) = spawn_oneshot_http_responder(vec![
"HTTP/1.1 503 Service Unavailable\r\nContent-Length: 0\r\n\r\n",
"HTTP/1.1 200 OK\r\nContent-Length: 2\r\n\r\nok",
]);
let client = reqwest::Client::builder()
.timeout(Duration::from_secs(2))
.build()
.expect("client");
let policy = RetryPolicy {
max_attempts: 3,
base_delay: Duration::from_millis(1),
max_delay: Duration::from_millis(2),
};
let result = retry_http_async(
RetryLog::new("test", test_logger()),
&policy,
SuccessClass::Strict,
|_| client.get(format!("http://{addr}/")).send(),
|status, body| format!("{status}: {body}"),
)
.await;
let resp = result.expect("eventually succeeds");
assert_eq!(resp.status().as_u16(), 200);
assert_eq!(calls.load(Ordering::SeqCst), 2, "one retry then success");
}
#[tokio::test]
async fn retry_http_async_4xx_fast_fails_no_retry() {
let (addr, calls) = spawn_oneshot_http_responder(vec![
"HTTP/1.1 404 Not Found\r\nContent-Length: 9\r\n\r\nnot found",
]);
let client = reqwest::Client::builder()
.timeout(Duration::from_secs(2))
.build()
.expect("client");
let policy = RetryPolicy {
max_attempts: 5,
base_delay: Duration::from_millis(1),
max_delay: Duration::from_millis(2),
};
let result = retry_http_async(
RetryLog::new("myscope", test_logger()),
&policy,
SuccessClass::Strict,
|_| client.get(format!("http://{addr}/")).send(),
|status, body| format!("custom error: {status} body={body}"),
)
.await;
let err = result.expect_err("4xx must fast-fail");
let chain = format!("{err:#}");
assert!(
chain.contains("custom error"),
"error formatter must be invoked on non-success; got: {chain}"
);
assert!(chain.contains("404"), "status must be in chain: {chain}");
assert_eq!(
calls.load(Ordering::SeqCst),
1,
"4xx must NOT retry (only one connection accepted)"
);
}
#[tokio::test]
async fn retry_http_async_429_retries_then_succeeds() {
let (addr, calls) = spawn_oneshot_http_responder(vec![
"HTTP/1.1 429 Too Many Requests\r\nContent-Length: 0\r\n\r\n",
"HTTP/1.1 200 OK\r\nContent-Length: 2\r\n\r\nok",
]);
let client = reqwest::Client::builder()
.timeout(Duration::from_secs(2))
.build()
.expect("client");
let policy = RetryPolicy {
max_attempts: 3,
base_delay: Duration::from_millis(1),
max_delay: Duration::from_millis(2),
};
let result = retry_http_async(
RetryLog::new("test", test_logger()),
&policy,
SuccessClass::Strict,
|_| client.get(format!("http://{addr}/")).send(),
|status, body| format!("{status}: {body}"),
)
.await;
let resp = result.expect("429 retried then success");
assert_eq!(resp.status().as_u16(), 200);
assert_eq!(calls.load(Ordering::SeqCst), 2);
}
const TRANSPORT_FAIL_URL: &str = "http://nonexistent.invalid/";
#[test]
fn retry_http_blocking_transport_error_retries_then_fails() {
let attempts = std::sync::Arc::new(AtomicU32::new(0));
let attempts_inner = attempts.clone();
let client = reqwest::blocking::Client::builder()
.timeout(Duration::from_millis(500))
.build()
.expect("client");
let policy = RetryPolicy {
max_attempts: 3,
base_delay: Duration::from_millis(1),
max_delay: Duration::from_millis(2),
};
let result = retry_http_blocking(
RetryLog::new("test-transport", test_logger()),
&policy,
SuccessClass::Strict,
|_| {
attempts_inner.fetch_add(1, Ordering::SeqCst);
client.get(TRANSPORT_FAIL_URL).send()
},
|_, _| String::from("non-success branch should not be reached"),
);
let err = result.expect_err("transport error must surface as Err");
let chain = format!("{err:#}");
assert!(
attempts.load(Ordering::SeqCst) > 1,
"transport error must be retried; got {} attempts; chain={chain}",
attempts.load(Ordering::SeqCst)
);
assert!(
chain.contains("test-transport"),
"label must surface in error chain; got: {chain}"
);
}
#[tokio::test]
async fn retry_http_async_transport_error_retries_then_fails() {
let attempts = std::sync::Arc::new(AtomicU32::new(0));
let attempts_inner = attempts.clone();
let client = reqwest::Client::builder()
.timeout(Duration::from_millis(500))
.build()
.expect("client");
let policy = RetryPolicy {
max_attempts: 3,
base_delay: Duration::from_millis(1),
max_delay: Duration::from_millis(2),
};
let result = retry_http_async(
RetryLog::new("test-transport-async", test_logger()),
&policy,
SuccessClass::Strict,
|_| {
attempts_inner.fetch_add(1, Ordering::SeqCst);
client.get(TRANSPORT_FAIL_URL).send()
},
|_, _| String::from("non-success branch should not be reached"),
)
.await;
let err = result.expect_err("transport error must surface as Err");
assert!(
attempts.load(Ordering::SeqCst) > 1,
"transport error must be retried; got {} attempts",
attempts.load(Ordering::SeqCst)
);
let chain = format!("{err:#}");
assert!(
chain.contains("test-transport-async"),
"label must surface in error chain; got: {chain}"
);
}
}