donglora_client/retry.rs
1//! TX retry policy + outcome reporting.
2//!
3//! `DongLoRa Protocol v2` surfaces CAD-detected channel activity as a distinct
4//! `TX_DONE(CHANNEL_BUSY)` result (`PROTOCOL.md §6.10`). The spec
5//! recommends "randomized backoff and retry with a new tag" —
6//! [`Dongle::tx_with_retry`](crate::Dongle::tx_with_retry) implements
7//! that policy and returns a [`TxOutcome`] capturing every attempt so
8//! callers (the bridge TUI especially) can surface retry counts.
9
10use std::time::Duration;
11
12use donglora_protocol::TxDonePayload;
13
14use crate::errors::ClientError;
15
16/// Policy for [`crate::Dongle::tx_with_retry`].
17///
18/// Defaults match the example in `PROTOCOL.md §C.5.5`: 3 attempts,
19/// randomized 20-100 ms backoff on the first retry, doubling up to
20/// 500 ms cap. Only `CHANNEL_BUSY` (CAD) and `EBUSY` (TX queue full)
21/// trigger a retry — every other error propagates immediately.
22#[derive(Debug, Clone)]
23pub struct RetryPolicy {
24 pub max_attempts: u8,
25 /// Lower bound of the initial randomized backoff (ms).
26 pub backoff_ms_min: u32,
27 /// Upper bound of the initial randomized backoff (ms). The jitter
28 /// range is `[0, backoff_ms_max - backoff_ms_min]`.
29 pub backoff_ms_max: u32,
30 /// Multiplier applied to the backoff floor on each subsequent retry
31 /// (standard exponential backoff).
32 pub backoff_multiplier: f32,
33 /// Absolute ceiling on the backoff floor (ms). Prevents runaway
34 /// delays at high attempt counts.
35 pub backoff_cap_ms: u32,
36 /// Per-attempt command deadline. Must accommodate CAD + airtime on
37 /// the slowest configuration likely to be in play.
38 pub per_attempt_timeout: Duration,
39 /// If true, bypass CAD (sends `skip_cad = 1`). Usually false —
40 /// retrying without CAD defeats the purpose of the retry.
41 pub skip_cad: bool,
42}
43
44impl Default for RetryPolicy {
45 fn default() -> Self {
46 Self {
47 max_attempts: 3,
48 backoff_ms_min: 20,
49 backoff_ms_max: 100,
50 backoff_multiplier: 2.0,
51 backoff_cap_ms: 500,
52 per_attempt_timeout: Duration::from_secs(5),
53 skip_cad: false,
54 }
55 }
56}
57
58impl RetryPolicy {
59 /// Sample one jitter value in `[0, backoff_ms_max - backoff_ms_min]`.
60 pub(crate) fn jitter_ms(&self) -> u32 {
61 use rand::Rng;
62 let spread = self.backoff_ms_max.saturating_sub(self.backoff_ms_min);
63 if spread == 0 {
64 return 0;
65 }
66 rand::rng().random_range(0..=spread)
67 }
68}
69
70/// Result of one attempt within a retry loop.
71#[derive(Debug)]
72pub struct TxAttempt {
73 /// 1-indexed attempt number.
74 pub attempt: u8,
75 /// `Ok` with the wire `TX_DONE` payload on success, `Err` otherwise.
76 pub result: Result<TxDonePayload, ClientError>,
77 /// Wall-clock time this attempt took (including CAD + airtime or
78 /// timeout).
79 pub elapsed: Duration,
80}
81
82/// Aggregate outcome of a retry loop.
83#[derive(Debug)]
84pub struct TxOutcome {
85 /// The final successful attempt's reported airtime. For retries, the
86 /// earlier attempts' airtimes are 0 (CAD-busy doesn't go on the
87 /// air), so this is also the total airtime used.
88 pub final_airtime_us: u32,
89 /// Every attempt, in order.
90 pub attempts: Vec<TxAttempt>,
91}
92
93impl TxOutcome {
94 /// How many attempts were needed (equals `attempts.len()`).
95 #[must_use]
96 pub fn attempts_used(&self) -> u8 {
97 self.attempts.len() as u8
98 }
99
100 /// True if this outcome involved at least one retry.
101 #[must_use]
102 pub fn had_retries(&self) -> bool {
103 self.attempts.len() > 1
104 }
105}