Skip to main content

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}