Skip to main content

upcloud_api/
net.rs

1//! **The transport boundary: what is retried and what never is.**
2//!
3//! Moved here from `private-gunnar-ops/xtask/src/remote/net.rs` (lane T14,
4//! 2026-09-21) so the ONE wire implementation of [`crate::UpCloudApi`] carries
5//! the same policy every client in that crate already had — and so that crate's
6//! other HTTP clients (Cloudflare, the health probe) re-use this copy instead of
7//! keeping a second one.
8//!
9//! MEASURED 2026-09-20, the live bring-up: `GET /1.3/price` on the account
10//! answered "error sending request for url" ONCE, and a credential probe turned
11//! that one dropped connection into "provider unavailable". The token was fine.
12//! The link hiccuped.
13//!
14//! # The boundary, and it is not negotiable in the permissive direction
15//!
16//! * **TRANSPORT** — the question never reached the far end, or its answer never
17//!   came back whole: a refused connection, a reset, a timeout, a name that did
18//!   not resolve, a TLS handshake that fell over, an HTTP/1.1 framing error
19//!   mid-flight. Nobody composed that; it is the wire. RETRYABLE, with a bounded
20//!   budget and a backoff, and **every retry is printed** so a flaky link can
21//!   never be mistaken for a healthy one.
22//! * **PROTOCOL** — the far end understood the question and composed an answer:
23//!   a status code, a refusal body, a URI this crate built wrong. That is DATA.
24//!   It is never retried and never softened. A 401 is a revoked credential, a
25//!   403 is a scope, a 412 is UpCloud saying out_of_stock, a 404 is an absence —
26//!   and retrying any of them turns a clear refusal into a hang.
27//!
28//! Every client in this crate is built with `http_status_as_error(false)`, so a
29//! status code never even arrives here as an `Err`: it comes back as an ordinary
30//! response and is judged by the code that asked. That is not an accident, it is
31//! the boundary made structural — the only thing this module can see is the wire.
32
33use std::time::Duration;
34
35/// Which side of the boundary an error fell on.
36#[derive(Debug, Clone, Copy, PartialEq, Eq)]
37pub enum Side {
38    /// The wire. Bounded retry.
39    Transport,
40    /// A composed answer, or our own mistake. Final.
41    Protocol,
42}
43
44/// Classify a `ureq` error. The match is on the VARIANT, not on the text of the
45/// message, because a message is a sentence someone can reword and a variant is
46/// a decision the library made.
47pub fn side(e: &ureq::Error) -> Side {
48    match e {
49        // The wire.
50        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        // An answer, or a request this crate built wrong. Both are final: the
57        // first because it is data, the second because trying it again will
58        // build the same broken request.
59        _ => Side::Protocol,
60    }
61}
62
63/// How long the wire is given to stop hiccuping.
64#[derive(Debug, Clone, Copy)]
65pub struct Budget {
66    /// Total attempts, the first one included. 1 = no retry at all.
67    pub attempts: u32,
68    /// The wait after the first failure; doubled each time, capped at `cap`.
69    pub first: Duration,
70    pub cap: Duration,
71    /// Retry only failures that prove the request NEVER LEFT THIS BOX.
72    ///
73    /// For a GET or a DELETE this is off: they are idempotent, and asking twice
74    /// costs nothing. For a POST that CREATES something it is on, because a
75    /// connection reset after the bytes went out is ambiguous — the server may
76    /// have made the thing and lost the answer — and retrying that is how one
77    /// order becomes two servers on the invoice. A refused connection and a
78    /// name that did not resolve are not ambiguous: nothing was sent.
79    pub unsent_only: bool,
80}
81
82impl Budget {
83    /// What an API read gets: four attempts over ~7 s of waiting. Long enough
84    /// for a dropped connection and a re-dial, short enough that a cloud that
85    /// is genuinely down is named within the minute rather than leaned on.
86    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    /// What a WRITE gets: the same budget, but only for failures that prove the
91    /// request never left this box. See `unsent_only`.
92    pub const fn write() -> Budget {
93        Budget { unsent_only: true, ..Budget::api() }
94    }
95
96    /// One attempt: the boundary is still declared, and nothing is retried.
97    #[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
103/// Did this failure happen BEFORE a single byte of the request went out?
104///
105/// A connection that was refused, a name that did not resolve, a connector that
106/// gave up: nothing reached the far end, so asking again cannot duplicate
107/// anything. A reset or a timeout mid-flight is NOT in this set — the request
108/// may well have arrived and been acted on.
109pub 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
123/// Run `op`, retrying ONLY what `side` calls `Transport`, saying every retry out
124/// loud. `what` names the call in the line that is printed and in the error.
125pub 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
133/// `call`, with the clock and the voice injected so the budget can be tested
134/// without a test that sleeps.
135pub 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            // A composed answer. It is the point of the call, not an obstacle.
155            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        // Composed by the far end, or by us. Never retried.
199        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    /// TODAY'S BUG: one dropped connection, and the second attempt would have
206    /// answered. Without the retry this call is a permanent refusal.
207    #[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    /// The other direction: a real answer is NOT retried, ever. A revoked
232    /// credential must fail on the first call, not after four.
233    #[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        // Doubling, capped: 1, 2, 4 — and no sleep after the last attempt.
272        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    /// A write is retried only when nothing can possibly have landed.
277    #[test]
278    fn a_write_is_never_sent_twice_after_the_bytes_went_out() {
279        // Refused: nothing left the box, so ask again.
280        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        // Reset mid-flight: the server may have made the thing. Once, and once only.
294        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        // …and a GET of the same shape is still retried, because it is idempotent.
308        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}