1use std::time::Duration;
34
35#[derive(Debug, Clone, Copy, PartialEq, Eq)]
37pub enum Side {
38 Transport,
40 Protocol,
42}
43
44pub fn side(e: &ureq::Error) -> Side {
48 match e {
49 ureq::Error::Io(_)
51 | ureq::Error::Timeout(_)
52 | ureq::Error::HostNotFound
53 | ureq::Error::ConnectionFailed
54 | ureq::Error::Tls(_)
55 | ureq::Error::Protocol(_) => Side::Transport,
56 _ => Side::Protocol,
60 }
61}
62
63#[derive(Debug, Clone, Copy)]
65pub struct Budget {
66 pub attempts: u32,
68 pub first: Duration,
70 pub cap: Duration,
71 pub unsent_only: bool,
80}
81
82impl Budget {
83 pub const fn api() -> Budget {
87 Budget { attempts: 4, first: Duration::from_secs(1), cap: Duration::from_secs(4), unsent_only: false }
88 }
89
90 pub const fn write() -> Budget {
93 Budget { unsent_only: true, ..Budget::api() }
94 }
95
96 #[allow(dead_code)]
98 pub const fn once() -> Budget {
99 Budget { attempts: 1, first: Duration::from_secs(0), cap: Duration::from_secs(0), unsent_only: false }
100 }
101}
102
103pub fn never_sent(e: &ureq::Error) -> bool {
110 match e {
111 ureq::Error::HostNotFound | ureq::Error::ConnectionFailed => true,
112 ureq::Error::Io(io) => matches!(
113 io.kind(),
114 std::io::ErrorKind::ConnectionRefused
115 | std::io::ErrorKind::NetworkUnreachable
116 | std::io::ErrorKind::HostUnreachable
117 | std::io::ErrorKind::AddrNotAvailable
118 ),
119 _ => false,
120 }
121}
122
123pub fn call<T>(
126 what: &str,
127 budget: Budget,
128 op: impl FnMut() -> Result<T, ureq::Error>,
129) -> Result<T, ureq::Error> {
130 call_with(what, budget, &mut |l| eprintln!("{l}"), &mut std::thread::sleep, op)
131}
132
133pub fn call_with<T>(
136 what: &str,
137 budget: Budget,
138 say: &mut dyn FnMut(&str),
139 sleep: &mut dyn FnMut(Duration),
140 mut op: impl FnMut() -> Result<T, ureq::Error>,
141) -> Result<T, ureq::Error> {
142 let mut wait = budget.first;
143 for attempt in 1..=budget.attempts.max(1) {
144 let e = match op() {
145 Ok(v) => {
146 if attempt > 1 {
147 say(&format!(" ok {what}: answered on attempt {attempt} of {}", budget.attempts));
148 }
149 return Ok(v);
150 }
151 Err(e) => e,
152 };
153 if side(&e) == Side::Protocol {
154 return Err(e);
156 }
157 if budget.unsent_only && !never_sent(&e) {
158 say(&format!(
159 " RED {what}: the wire failed after the request went out ({e}) — NOT retried, \
160 because a write that may have landed must not be sent twice"
161 ));
162 return Err(e);
163 }
164 if attempt == budget.attempts.max(1) {
165 say(&format!(
166 " RED {what}: the wire failed all {} attempts — {e}",
167 budget.attempts.max(1)
168 ));
169 return Err(e);
170 }
171 say(&format!(
172 " … {what}: transport failure on attempt {attempt} of {} ({e}) — retrying in {} s",
173 budget.attempts,
174 wait.as_secs_f32()
175 ));
176 sleep(wait);
177 wait = (wait * 2).min(budget.cap);
178 }
179 unreachable!("the loop returns on the last attempt")
180}
181
182#[cfg(test)]
183mod tests {
184 use super::*;
185 use std::cell::RefCell;
186
187 fn io(kind: std::io::ErrorKind) -> ureq::Error {
188 ureq::Error::Io(std::io::Error::from(kind))
189 }
190
191 #[test]
192 fn the_wire_is_transport_and_an_answer_is_protocol() {
193 assert_eq!(side(&io(std::io::ErrorKind::ConnectionRefused)), Side::Transport);
194 assert_eq!(side(&io(std::io::ErrorKind::ConnectionReset)), Side::Transport);
195 assert_eq!(side(&ureq::Error::HostNotFound), Side::Transport);
196 assert_eq!(side(&ureq::Error::ConnectionFailed), Side::Transport);
197 assert_eq!(side(&ureq::Error::Tls("handshake")), Side::Transport);
198 assert_eq!(side(&ureq::Error::StatusCode(401)), Side::Protocol);
200 assert_eq!(side(&ureq::Error::StatusCode(403)), Side::Protocol);
201 assert_eq!(side(&ureq::Error::StatusCode(412)), Side::Protocol);
202 assert_eq!(side(&ureq::Error::BadUri("nonsense".into())), Side::Protocol);
203 }
204
205 #[test]
208 fn one_hiccup_then_an_answer_is_an_answer_and_it_is_said_out_loud() {
209 let n = RefCell::new(0);
210 let said = RefCell::new(Vec::new());
211 let mut slept = Vec::new();
212 let got = call_with(
213 "GET /1.3/price",
214 Budget::api(),
215 &mut |l| said.borrow_mut().push(l.to_string()),
216 &mut |d| slept.push(d),
217 || {
218 *n.borrow_mut() += 1;
219 if *n.borrow() == 1 { Err(io(std::io::ErrorKind::ConnectionReset)) } else { Ok(200u16) }
220 },
221 )
222 .expect("the second attempt answered");
223 assert_eq!(got, 200);
224 assert_eq!(*n.borrow(), 2);
225 assert_eq!(slept, vec![Duration::from_secs(1)], "one backoff, and it is the first");
226 let said = said.borrow();
227 assert!(said.iter().any(|l| l.contains("transport failure on attempt 1")), "{said:?}");
228 assert!(said.iter().any(|l| l.contains("answered on attempt 2")), "a retry is never silent: {said:?}");
229 }
230
231 #[test]
234 fn a_composed_refusal_is_tried_exactly_once() {
235 for code in [401u16, 403, 404, 412] {
236 let n = RefCell::new(0);
237 let err = call_with(
238 "POST /1.3/server",
239 Budget::api(),
240 &mut |_| {},
241 &mut |_| panic!("a protocol answer must never sleep"),
242 || {
243 *n.borrow_mut() += 1;
244 Err::<(), _>(ureq::Error::StatusCode(code))
245 },
246 )
247 .unwrap_err();
248 assert!(matches!(err, ureq::Error::StatusCode(c) if c == code));
249 assert_eq!(*n.borrow(), 1, "{code} was retried");
250 }
251 }
252
253 #[test]
254 fn a_wire_that_never_comes_back_is_named_red_after_the_whole_budget() {
255 let n = RefCell::new(0);
256 let said = RefCell::new(Vec::new());
257 let mut slept = Vec::new();
258 let err = call_with(
259 "GET /zones",
260 Budget::api(),
261 &mut |l| said.borrow_mut().push(l.to_string()),
262 &mut |d| slept.push(d),
263 || {
264 *n.borrow_mut() += 1;
265 Err::<(), _>(ureq::Error::ConnectionFailed)
266 },
267 )
268 .unwrap_err();
269 assert!(matches!(err, ureq::Error::ConnectionFailed));
270 assert_eq!(*n.borrow(), 4);
271 assert_eq!(slept, vec![Duration::from_secs(1), Duration::from_secs(2), Duration::from_secs(4)]);
273 assert!(said.borrow().iter().any(|l| l.contains("failed all 4 attempts")), "{:?}", said.borrow());
274 }
275
276 #[test]
278 fn a_write_is_never_sent_twice_after_the_bytes_went_out() {
279 let n = RefCell::new(0);
281 let _ = call_with(
282 "POST /1.3/server",
283 Budget::write(),
284 &mut |_| {},
285 &mut |_| {},
286 || {
287 *n.borrow_mut() += 1;
288 Err::<(), _>(io(std::io::ErrorKind::ConnectionRefused))
289 },
290 );
291 assert_eq!(*n.borrow(), 4, "a refused connection created nothing");
292
293 let n = RefCell::new(0);
295 let _ = call_with(
296 "POST /1.3/server",
297 Budget::write(),
298 &mut |_| {},
299 &mut |_| panic!("an ambiguous write must not sleep and try again"),
300 || {
301 *n.borrow_mut() += 1;
302 Err::<(), _>(io(std::io::ErrorKind::ConnectionReset))
303 },
304 );
305 assert_eq!(*n.borrow(), 1);
306
307 let n = RefCell::new(0);
309 let _ = call_with(
310 "GET /1.3/server",
311 Budget::api(),
312 &mut |_| {},
313 &mut |_| {},
314 || {
315 *n.borrow_mut() += 1;
316 Err::<(), _>(io(std::io::ErrorKind::ConnectionReset))
317 },
318 );
319 assert_eq!(*n.borrow(), 4);
320 }
321
322 #[test]
323 fn a_budget_of_one_retries_nothing() {
324 let n = RefCell::new(0);
325 let _ = call_with(
326 "GET /account",
327 Budget::once(),
328 &mut |_| {},
329 &mut |_| panic!("no sleep with no retry"),
330 || {
331 *n.borrow_mut() += 1;
332 Err::<(), _>(ureq::Error::ConnectionFailed)
333 },
334 );
335 assert_eq!(*n.borrow(), 1);
336 }
337}