Skip to main content

proxima_primitives/pipe/
retry_rules.rs

1//! Retry decision as pure config. Tier-split backing store for the set of
2//! retryable statuses: an unbounded `alloc::collections::BTreeSet` under
3//! `proxima_alloc`, a fixed-capacity sorted `heapless::Vec` under no-alloc. The
4//! cap (`RETRY_STATUS_CAP`) is baked from `proxima-primitives.toml` by build.rs.
5
6#[cfg(proxima_alloc)]
7use alloc::collections::BTreeSet;
8
9use crate::pipe::capabilities::Retryable;
10#[cfg(not(proxima_alloc))]
11use crate::pipe::sized::RETRY_STATUS_CAP;
12
13/// The retry decision as pure config — which statuses/errors to retry, and
14/// whether to restrict to idempotent inputs. This is payload-agnostic data
15/// evaluated against the capability traits (`Retryable`, `Idempotent`).
16#[derive(Debug, Clone)]
17pub struct RetryRules {
18    /// Statuses that trigger a retry. Sorted, deduplicated. Unbounded under
19    /// `proxima_alloc`; capped at `RETRY_STATUS_CAP` under no-alloc.
20    pub retry_on_status: StatusSet,
21    pub retry_on_error: bool,
22    pub idempotent_only: bool,
23}
24
25/// The backing store for [`RetryRules::retry_on_status`], tier-selected at
26/// build time: a heap `BTreeSet` when an allocator is present, a sorted const-cap
27/// `heapless::Vec` when not. Both expose `insert` / `contains` / `extend` over
28/// `u16`, so a caller never sees which tier it got.
29#[cfg(proxima_alloc)]
30pub type StatusSet = BTreeSet<u16>;
31
32/// No-alloc backing store: a sorted, deduplicated fixed-capacity vector. Sorted
33/// invariant lets `contains` binary-search; `insert` past the cap is dropped
34/// (the default set fits, and an over-cap config is a sizing misconfiguration,
35/// not a runtime fault).
36#[cfg(not(proxima_alloc))]
37#[derive(Debug, Clone, Default)]
38pub struct StatusSet {
39    inner: heapless::Vec<u16, RETRY_STATUS_CAP>,
40}
41
42#[cfg(not(proxima_alloc))]
43impl StatusSet {
44    /// Empty set.
45    #[must_use]
46    pub fn new() -> Self {
47        Self {
48            inner: heapless::Vec::new(),
49        }
50    }
51
52    /// Insert `status`, keeping the vector sorted and deduplicated. A status that
53    /// would exceed `RETRY_STATUS_CAP` is dropped.
54    pub fn insert(&mut self, status: u16) -> bool {
55        match self.inner.binary_search(&status) {
56            Ok(_) => false,
57            Err(position) => self.inner.insert(position, status).is_ok(),
58        }
59    }
60
61    /// Whether `status` is in the set. Binary search over the sorted vector.
62    #[must_use]
63    pub fn contains(&self, status: &u16) -> bool {
64        self.inner.binary_search(status).is_ok()
65    }
66}
67
68// the alloc tier is a BTreeSet, which already has both of these; the no-alloc
69// tier needs them explicitly or a shared call site only compiles on one tier.
70#[cfg(not(proxima_alloc))]
71impl Extend<u16> for StatusSet {
72    fn extend<I: IntoIterator<Item = u16>>(&mut self, statuses: I) {
73        for status in statuses {
74            self.insert(status);
75        }
76    }
77}
78
79#[cfg(not(proxima_alloc))]
80impl IntoIterator for StatusSet {
81    type Item = u16;
82    type IntoIter = <heapless::Vec<u16, RETRY_STATUS_CAP> as IntoIterator>::IntoIter;
83
84    fn into_iter(self) -> Self::IntoIter {
85        self.inner.into_iter()
86    }
87}
88
89impl Default for RetryRules {
90    fn default() -> Self {
91        let mut retry_on_status = StatusSet::default();
92        for status in [502u16, 503, 504] {
93            retry_on_status.insert(status);
94        }
95        Self {
96            retry_on_status,
97            retry_on_error: true,
98            idempotent_only: false,
99        }
100    }
101}
102
103impl RetryRules {
104    pub fn should_retry<Out: Retryable, Err>(&self, outcome: &Result<Out, Err>) -> bool {
105        match outcome {
106            Ok(out) => out
107                .retry_status()
108                .is_some_and(|status| self.retry_on_status.contains(&status)),
109            Err(_) => self.retry_on_error,
110        }
111    }
112}
113
114#[cfg(test)]
115#[allow(clippy::unwrap_used, clippy::expect_used)]
116mod tests {
117    use super::*;
118
119    /// Statusful outcome probe — its `retry_status` decides whether the rules fire.
120    struct Outcome(Option<u16>);
121
122    impl Retryable for Outcome {
123        fn retry_status(&self) -> Option<u16> {
124            self.0
125        }
126        fn is_success(&self) -> bool {
127            self.0.is_none()
128        }
129    }
130
131    #[test]
132    fn default_retries_the_gateway_statuses() {
133        let rules = RetryRules::default();
134        for status in [502u16, 503, 504] {
135            assert!(
136                rules.retry_on_status.contains(&status),
137                "{status} should retry"
138            );
139        }
140        assert!(
141            !rules.retry_on_status.contains(&200),
142            "200 should not retry"
143        );
144    }
145
146    #[test]
147    fn should_retry_matches_status_membership() {
148        let rules = RetryRules::default();
149        let ok: Result<Outcome, ()> = Ok(Outcome(Some(503)));
150        let not_listed: Result<Outcome, ()> = Ok(Outcome(Some(418)));
151        let statusless: Result<Outcome, ()> = Ok(Outcome(None));
152        assert!(rules.should_retry(&ok), "503 is in the default set");
153        assert!(!rules.should_retry(&not_listed), "418 is not");
154        assert!(
155            !rules.should_retry(&statusless),
156            "no status => no status retry"
157        );
158    }
159
160    #[test]
161    fn should_retry_on_error_follows_the_flag() {
162        let err: Result<Outcome, ()> = Err(());
163        assert!(
164            RetryRules::default().should_retry(&err),
165            "default retries on error"
166        );
167        let no_error_retry = RetryRules {
168            retry_on_error: false,
169            ..RetryRules::default()
170        };
171        assert!(
172            !no_error_retry.should_retry(&err),
173            "flag off => no error retry"
174        );
175    }
176
177    #[test]
178    fn insert_is_idempotent_and_sorted() {
179        let mut set = StatusSet::default();
180        set.insert(503);
181        set.insert(502);
182        set.insert(503);
183        assert!(set.contains(&502) && set.contains(&503));
184        assert!(!set.contains(&504));
185    }
186}