use std::time::Duration;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Side {
Transport,
Protocol,
}
pub fn side(e: &ureq::Error) -> Side {
match e {
ureq::Error::Io(_)
| ureq::Error::Timeout(_)
| ureq::Error::HostNotFound
| ureq::Error::ConnectionFailed
| ureq::Error::Tls(_)
| ureq::Error::Protocol(_) => Side::Transport,
_ => Side::Protocol,
}
}
#[derive(Debug, Clone, Copy)]
pub struct Budget {
pub attempts: u32,
pub first: Duration,
pub cap: Duration,
pub unsent_only: bool,
}
impl Budget {
pub const fn api() -> Budget {
Budget { attempts: 4, first: Duration::from_secs(1), cap: Duration::from_secs(4), unsent_only: false }
}
pub const fn write() -> Budget {
Budget { unsent_only: true, ..Budget::api() }
}
#[allow(dead_code)]
pub const fn once() -> Budget {
Budget { attempts: 1, first: Duration::from_secs(0), cap: Duration::from_secs(0), unsent_only: false }
}
}
pub fn never_sent(e: &ureq::Error) -> bool {
match e {
ureq::Error::HostNotFound | ureq::Error::ConnectionFailed => true,
ureq::Error::Io(io) => matches!(
io.kind(),
std::io::ErrorKind::ConnectionRefused
| std::io::ErrorKind::NetworkUnreachable
| std::io::ErrorKind::HostUnreachable
| std::io::ErrorKind::AddrNotAvailable
),
_ => false,
}
}
pub fn call<T>(
what: &str,
budget: Budget,
op: impl FnMut() -> Result<T, ureq::Error>,
) -> Result<T, ureq::Error> {
call_with(what, budget, &mut |l| eprintln!("{l}"), &mut std::thread::sleep, op)
}
pub fn call_with<T>(
what: &str,
budget: Budget,
say: &mut dyn FnMut(&str),
sleep: &mut dyn FnMut(Duration),
mut op: impl FnMut() -> Result<T, ureq::Error>,
) -> Result<T, ureq::Error> {
let mut wait = budget.first;
for attempt in 1..=budget.attempts.max(1) {
let e = match op() {
Ok(v) => {
if attempt > 1 {
say(&format!(" ok {what}: answered on attempt {attempt} of {}", budget.attempts));
}
return Ok(v);
}
Err(e) => e,
};
if side(&e) == Side::Protocol {
return Err(e);
}
if budget.unsent_only && !never_sent(&e) {
say(&format!(
" RED {what}: the wire failed after the request went out ({e}) — NOT retried, \
because a write that may have landed must not be sent twice"
));
return Err(e);
}
if attempt == budget.attempts.max(1) {
say(&format!(
" RED {what}: the wire failed all {} attempts — {e}",
budget.attempts.max(1)
));
return Err(e);
}
say(&format!(
" … {what}: transport failure on attempt {attempt} of {} ({e}) — retrying in {} s",
budget.attempts,
wait.as_secs_f32()
));
sleep(wait);
wait = (wait * 2).min(budget.cap);
}
unreachable!("the loop returns on the last attempt")
}
#[cfg(test)]
mod tests {
use super::*;
use std::cell::RefCell;
fn io(kind: std::io::ErrorKind) -> ureq::Error {
ureq::Error::Io(std::io::Error::from(kind))
}
#[test]
fn the_wire_is_transport_and_an_answer_is_protocol() {
assert_eq!(side(&io(std::io::ErrorKind::ConnectionRefused)), Side::Transport);
assert_eq!(side(&io(std::io::ErrorKind::ConnectionReset)), Side::Transport);
assert_eq!(side(&ureq::Error::HostNotFound), Side::Transport);
assert_eq!(side(&ureq::Error::ConnectionFailed), Side::Transport);
assert_eq!(side(&ureq::Error::Tls("handshake")), Side::Transport);
assert_eq!(side(&ureq::Error::StatusCode(401)), Side::Protocol);
assert_eq!(side(&ureq::Error::StatusCode(403)), Side::Protocol);
assert_eq!(side(&ureq::Error::StatusCode(412)), Side::Protocol);
assert_eq!(side(&ureq::Error::BadUri("nonsense".into())), Side::Protocol);
}
#[test]
fn one_hiccup_then_an_answer_is_an_answer_and_it_is_said_out_loud() {
let n = RefCell::new(0);
let said = RefCell::new(Vec::new());
let mut slept = Vec::new();
let got = call_with(
"GET /1.3/price",
Budget::api(),
&mut |l| said.borrow_mut().push(l.to_string()),
&mut |d| slept.push(d),
|| {
*n.borrow_mut() += 1;
if *n.borrow() == 1 { Err(io(std::io::ErrorKind::ConnectionReset)) } else { Ok(200u16) }
},
)
.expect("the second attempt answered");
assert_eq!(got, 200);
assert_eq!(*n.borrow(), 2);
assert_eq!(slept, vec![Duration::from_secs(1)], "one backoff, and it is the first");
let said = said.borrow();
assert!(said.iter().any(|l| l.contains("transport failure on attempt 1")), "{said:?}");
assert!(said.iter().any(|l| l.contains("answered on attempt 2")), "a retry is never silent: {said:?}");
}
#[test]
fn a_composed_refusal_is_tried_exactly_once() {
for code in [401u16, 403, 404, 412] {
let n = RefCell::new(0);
let err = call_with(
"POST /1.3/server",
Budget::api(),
&mut |_| {},
&mut |_| panic!("a protocol answer must never sleep"),
|| {
*n.borrow_mut() += 1;
Err::<(), _>(ureq::Error::StatusCode(code))
},
)
.unwrap_err();
assert!(matches!(err, ureq::Error::StatusCode(c) if c == code));
assert_eq!(*n.borrow(), 1, "{code} was retried");
}
}
#[test]
fn a_wire_that_never_comes_back_is_named_red_after_the_whole_budget() {
let n = RefCell::new(0);
let said = RefCell::new(Vec::new());
let mut slept = Vec::new();
let err = call_with(
"GET /zones",
Budget::api(),
&mut |l| said.borrow_mut().push(l.to_string()),
&mut |d| slept.push(d),
|| {
*n.borrow_mut() += 1;
Err::<(), _>(ureq::Error::ConnectionFailed)
},
)
.unwrap_err();
assert!(matches!(err, ureq::Error::ConnectionFailed));
assert_eq!(*n.borrow(), 4);
assert_eq!(slept, vec![Duration::from_secs(1), Duration::from_secs(2), Duration::from_secs(4)]);
assert!(said.borrow().iter().any(|l| l.contains("failed all 4 attempts")), "{:?}", said.borrow());
}
#[test]
fn a_write_is_never_sent_twice_after_the_bytes_went_out() {
let n = RefCell::new(0);
let _ = call_with(
"POST /1.3/server",
Budget::write(),
&mut |_| {},
&mut |_| {},
|| {
*n.borrow_mut() += 1;
Err::<(), _>(io(std::io::ErrorKind::ConnectionRefused))
},
);
assert_eq!(*n.borrow(), 4, "a refused connection created nothing");
let n = RefCell::new(0);
let _ = call_with(
"POST /1.3/server",
Budget::write(),
&mut |_| {},
&mut |_| panic!("an ambiguous write must not sleep and try again"),
|| {
*n.borrow_mut() += 1;
Err::<(), _>(io(std::io::ErrorKind::ConnectionReset))
},
);
assert_eq!(*n.borrow(), 1);
let n = RefCell::new(0);
let _ = call_with(
"GET /1.3/server",
Budget::api(),
&mut |_| {},
&mut |_| {},
|| {
*n.borrow_mut() += 1;
Err::<(), _>(io(std::io::ErrorKind::ConnectionReset))
},
);
assert_eq!(*n.borrow(), 4);
}
#[test]
fn a_budget_of_one_retries_nothing() {
let n = RefCell::new(0);
let _ = call_with(
"GET /account",
Budget::once(),
&mut |_| {},
&mut |_| panic!("no sleep with no retry"),
|| {
*n.borrow_mut() += 1;
Err::<(), _>(ureq::Error::ConnectionFailed)
},
);
assert_eq!(*n.borrow(), 1);
}
}