Skip to main content

guardian_shared/
retry.rs

1use std::error::Error;
2use std::future::Future;
3use std::time::Duration;
4
5pub const BASE_DELAY_MS: u64 = 500;
6pub const MAX_DELAY_MS: u64 = 8_000;
7
8#[derive(Clone, Debug, PartialEq, Eq)]
9pub struct RetryPolicy {
10    max_attempts: u32,
11}
12
13impl RetryPolicy {
14    #[must_use]
15    pub fn new(max_attempts: u32) -> Self {
16        Self {
17            max_attempts: max_attempts.max(1),
18        }
19    }
20
21    #[must_use]
22    pub fn single_attempt() -> Self {
23        Self::new(1)
24    }
25
26    #[must_use]
27    pub fn max_attempts(&self) -> u32 {
28        self.max_attempts
29    }
30
31    #[must_use]
32    pub fn retries_enabled(&self) -> bool {
33        self.max_attempts > 1
34    }
35}
36
37/// How a read selects its attempt budget. Callers holding a lease or other
38/// structural retry (e.g. a canonicalization pass) must pin reads to a
39/// single attempt; the choice is deliberate at every read site.
40#[derive(Clone, Copy, Debug, PartialEq, Eq)]
41pub enum RpcReadMode {
42    Configured,
43    SingleAttempt,
44}
45
46#[async_trait::async_trait]
47pub trait RetryRuntime: Send + Sync {
48    async fn sleep(&self, duration: Duration);
49    fn unit_random(&self) -> f64;
50}
51
52pub struct ProductionRetryRuntime;
53
54#[async_trait::async_trait]
55impl RetryRuntime for ProductionRetryRuntime {
56    async fn sleep(&self, duration: Duration) {
57        tokio::time::sleep(duration).await;
58    }
59
60    fn unit_random(&self) -> f64 {
61        rand::random()
62    }
63}
64
65/// Runs `op` under an attempt budget: the budget is consulted before the
66/// error is classified, transient failures back off with [`retry_delay`],
67/// and permanent failures or the final attempt return the error unchanged.
68/// `on_retry` fires once per retry, before the backoff sleep.
69pub async fn run_retries<T, E, F, Fut>(
70    max_attempts: u32,
71    runtime: &dyn RetryRuntime,
72    is_transient: impl Fn(&E) -> bool,
73    on_retry: impl Fn(u32, &E),
74    op: F,
75) -> Result<T, E>
76where
77    F: Fn() -> Fut,
78    Fut: Future<Output = Result<T, E>>,
79{
80    let attempts = max_attempts.max(1);
81    for attempt in 0..attempts {
82        match op().await {
83            Ok(value) => return Ok(value),
84            Err(error) if attempt + 1 < attempts && is_transient(&error) => {
85                on_retry(attempt, &error);
86                runtime
87                    .sleep(retry_delay(attempt, runtime.unit_random()))
88                    .await;
89            }
90            Err(error) => return Err(error),
91        }
92    }
93    unreachable!("the attempt budget always admits at least one attempt")
94}
95
96#[derive(Clone, Copy, Debug, PartialEq, Eq)]
97pub enum StructuredEvidence {
98    Transient,
99    Permanent,
100    Indeterminate,
101}
102
103/// Accumulates evidence under the one precedence rule of the classifier:
104/// permanent anywhere vetoes transient anywhere.
105#[derive(Default)]
106struct EvidenceLedger {
107    transient: bool,
108    permanent: bool,
109}
110
111impl EvidenceLedger {
112    fn note(&mut self, evidence: StructuredEvidence) {
113        match evidence {
114            StructuredEvidence::Transient => self.transient = true,
115            StructuredEvidence::Permanent => self.permanent = true,
116            StructuredEvidence::Indeterminate => {}
117        }
118    }
119
120    fn verdict(&self) -> Option<StructuredEvidence> {
121        if self.permanent {
122            Some(StructuredEvidence::Permanent)
123        } else if self.transient {
124            Some(StructuredEvidence::Transient)
125        } else {
126            None
127        }
128    }
129}
130
131/// Classifies a numeric gRPC status code. Permanent codes veto retries even
132/// when transient wording appears elsewhere in the error chain.
133#[must_use]
134pub fn grpc_code_evidence(code: i32) -> StructuredEvidence {
135    match code {
136        1 | 4 | 8 | 14 => StructuredEvidence::Transient,
137        3 | 5 | 6 | 7 | 9 | 10 | 11 | 12 | 13 | 15 | 16 => StructuredEvidence::Permanent,
138        _ => StructuredEvidence::Indeterminate,
139    }
140}
141
142/// Extracts HTTP statuses by scanning for the marker wordings and parsing
143/// the number that follows. Mirrors the TypeScript classifier's pattern —
144/// `http`, `http status`, or `status` on a word boundary, an optional `code`
145/// token, an optional colon, then exactly three digits ending on a word
146/// boundary — and is pinned to it by the classification fixtures.
147#[must_use]
148pub fn http_evidence(message: &str) -> Option<StructuredEvidence> {
149    const TRANSIENT: [u16; 5] = [408, 429, 502, 503, 504];
150    const STATUS_MARKERS: [&str; 3] = ["http status", "http", "status"];
151
152    let bytes = message.as_bytes();
153    let mut ledger = EvidenceLedger::default();
154    for marker in STATUS_MARKERS {
155        for (start, matched) in message.match_indices(marker) {
156            let on_word_boundary = start
157                .checked_sub(1)
158                .and_then(|index| bytes.get(index))
159                .is_none_or(|byte| !byte.is_ascii_alphanumeric() && *byte != b'_');
160            if !on_word_boundary {
161                continue;
162            }
163            let Some(status) = leading_status(bytes, start + matched.len()) else {
164                continue;
165            };
166            if !(400..=599).contains(&status) {
167                continue;
168            }
169            ledger.note(if TRANSIENT.contains(&status) {
170                StructuredEvidence::Transient
171            } else {
172                StructuredEvidence::Permanent
173            });
174        }
175    }
176    ledger.verdict()
177}
178
179fn skip_ascii_whitespace(bytes: &[u8], mut cursor: usize) -> usize {
180    while bytes.get(cursor).is_some_and(u8::is_ascii_whitespace) {
181        cursor += 1;
182    }
183    cursor
184}
185
186fn leading_status(bytes: &[u8], from: usize) -> Option<u16> {
187    let mut cursor = skip_ascii_whitespace(bytes, from);
188    if cursor > from && bytes[cursor..].starts_with(b"code") {
189        cursor = skip_ascii_whitespace(bytes, cursor + 4);
190    }
191    if bytes.get(cursor) == Some(&b':') {
192        cursor = skip_ascii_whitespace(bytes, cursor + 1);
193    }
194    let mut end = cursor;
195    while bytes.get(end).is_some_and(u8::is_ascii_digit) {
196        end += 1;
197    }
198    if end - cursor != 3 {
199        return None;
200    }
201    if bytes
202        .get(end)
203        .is_some_and(|byte| byte.is_ascii_alphabetic() || *byte == b'_')
204    {
205        return None;
206    }
207    std::str::from_utf8(&bytes[cursor..end]).ok()?.parse().ok()
208}
209
210#[must_use]
211pub fn flattened_grpc_evidence(message: &str) -> Option<StructuredEvidence> {
212    let normalized = message
213        .chars()
214        .filter(|character| character.is_ascii_alphanumeric())
215        .collect::<String>();
216    let patterns = [
217        ("grpccodecancelled", 1),
218        ("grpccodecanceled", 1),
219        ("grpccodedeadlineexceeded", 4),
220        ("grpccodeunavailable", 14),
221        ("grpccoderesourceexhausted", 8),
222        ("grpccodeinvalidargument", 3),
223        ("grpccodefailedprecondition", 9),
224        ("grpccodepermissiondenied", 7),
225        ("grpccodeunauthenticated", 16),
226        ("grpccodenotfound", 5),
227        ("grpccodealreadyexists", 6),
228        ("grpccodeoutofrange", 11),
229        ("grpccodeunimplemented", 12),
230        ("grpccodeaborted", 10),
231        ("grpccodeinternal", 13),
232        ("grpccodedataloss", 15),
233        ("grpccodeunknown", 2),
234    ];
235
236    let mut ledger = EvidenceLedger::default();
237    for (pattern, code) in patterns {
238        if normalized.contains(pattern) {
239            ledger.note(grpc_code_evidence(code));
240        }
241    }
242    ledger.verdict()
243}
244
245/// Last-resort transient wording, consulted only when the whole chain carried
246/// no status-level evidence. Guarded by the negative classification fixtures
247/// (server rejections must never match) — extend with care.
248#[must_use]
249pub fn flattened_transient(message: &str) -> bool {
250    [
251        "cancelled",
252        "canceled",
253        "deadline exceeded",
254        "timeout",
255        "unavailable",
256        "resource exhausted",
257        "request timeout",
258        "too many requests",
259        "rate limited",
260        "rate limit",
261        "bad gateway",
262        "service unavailable",
263        "gateway timeout",
264        "i/o timeout",
265        "io timeout",
266        "connection reset",
267        "broken pipe",
268    ]
269    .iter()
270    .any(|signal| message.contains(signal))
271}
272
273/// Connection-failure wording the retry window cannot fix: TLS and
274/// certificate problems, or an endpoint that never parsed. Everything else
275/// (refused, reset, timeout, unresolved name) is the peer-still-booting
276/// case and stays retryable.
277pub const CONNECT_PERMANENT_SIGNALS: [&str; 4] =
278    ["certificate", "tls", "invalid uri", "unsupported scheme"];
279
280/// Walks a connection error's chain and reports whether it carries wording
281/// from [`CONNECT_PERMANENT_SIGNALS`].
282pub fn connect_failure_is_permanent(error: &(dyn Error + 'static)) -> bool {
283    let mut message = error.to_string().to_ascii_lowercase();
284    let mut source = error.source();
285    while let Some(cause) = source {
286        message.push(' ');
287        message.push_str(&cause.to_string().to_ascii_lowercase());
288        source = cause.source();
289    }
290    CONNECT_PERMANENT_SIGNALS
291        .iter()
292        .any(|signal| message.contains(signal))
293}
294
295/// Node-RPC transient extension over [`flattened_transient`]: the node's
296/// transport layer renders dropped connections with wording the prover policy
297/// deliberately rejects (a bare "connection error" from a prover is treated as
298/// its considered answer). Guarded by the negative classification fixtures.
299pub const RPC_TRANSPORT_SIGNALS: [&str; 2] = ["connection error", "transport error"];
300
301/// Walks the whole error chain accumulating evidence: permanent anywhere
302/// vetoes transient anywhere; the transport-text fallback fires only when no
303/// status-level evidence exists in any link. `link_evidence` supplies
304/// transport-specific typed classification (e.g. a `tonic::Status` downcast).
305pub fn is_transient_error<F>(error: &(dyn Error + 'static), link_evidence: F) -> bool
306where
307    F: Fn(&(dyn Error + 'static)) -> StructuredEvidence,
308{
309    is_transient_error_with(error, link_evidence, &[])
310}
311
312/// [`is_transient_error`] with domain-specific additions to the transient
313/// text fallback. Extras participate under the same permanent-wins rule.
314pub fn is_transient_error_with<F>(
315    error: &(dyn Error + 'static),
316    link_evidence: F,
317    extra_transient_signals: &[&str],
318) -> bool
319where
320    F: Fn(&(dyn Error + 'static)) -> StructuredEvidence,
321{
322    let mut has_transient = false;
323    let mut has_fallback = false;
324    let mut current: Option<&(dyn Error + 'static)> = Some(error);
325
326    while let Some(cause) = current {
327        let message = cause.to_string().to_ascii_lowercase();
328        let mut ledger = EvidenceLedger::default();
329        ledger.note(link_evidence(cause));
330        if let Some(evidence) = http_evidence(&message) {
331            ledger.note(evidence);
332        }
333        if let Some(evidence) = flattened_grpc_evidence(&message) {
334            ledger.note(evidence);
335        }
336        match ledger.verdict() {
337            Some(StructuredEvidence::Permanent) => return false,
338            Some(_) => has_transient = true,
339            None => {}
340        }
341        has_fallback = has_fallback
342            || flattened_transient(&message)
343            || extra_transient_signals
344                .iter()
345                .any(|signal| message.contains(signal));
346        current = cause.source();
347    }
348
349    has_transient || has_fallback
350}
351
352#[must_use]
353pub fn retry_delay(retry_index: u32, unit_random: f64) -> Duration {
354    let exponent = retry_index.min(127);
355    let raw = u128::from(BASE_DELAY_MS).saturating_mul(1_u128 << exponent);
356    let bounded_random = unit_random.clamp(0.0, 1.0 - f64::EPSILON);
357    let factor = 0.75 + bounded_random * 0.5;
358    let jittered = (raw as f64 * factor).floor();
359    Duration::from_millis((jittered as u64).min(MAX_DELAY_MS))
360}
361
362#[cfg(test)]
363mod tests {
364    use std::fmt;
365
366    use serde::Deserialize;
367
368    use super::*;
369
370    #[derive(Deserialize)]
371    #[serde(rename_all = "camelCase")]
372    struct Fixtures {
373        classifications: Vec<ClassificationFixture>,
374        delays: Vec<DelayFixture>,
375    }
376
377    #[derive(Deserialize)]
378    struct ClassificationFixture {
379        name: String,
380        chain: Vec<ErrorFixture>,
381        transient: bool,
382    }
383
384    #[derive(Deserialize)]
385    struct ErrorFixture {
386        code: Option<String>,
387        status: Option<u16>,
388        message: String,
389    }
390
391    #[derive(Deserialize)]
392    #[serde(rename_all = "camelCase")]
393    struct DelayFixture {
394        retry_index: u32,
395        unit_random: f64,
396        delay_ms: u64,
397    }
398
399    #[derive(Debug)]
400    struct FixtureError {
401        message: String,
402        source: Option<Box<FixtureError>>,
403    }
404
405    impl fmt::Display for FixtureError {
406        fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
407            formatter.write_str(&self.message)
408        }
409    }
410
411    impl Error for FixtureError {
412        fn source(&self) -> Option<&(dyn Error + 'static)> {
413            self.source.as_deref().map(|source| source as _)
414        }
415    }
416
417    fn fixtures() -> Fixtures {
418        serde_json::from_str(include_str!(
419            "../../../fixtures/miden-multisig-client/rpc-policy-fixtures.json"
420        ))
421        .expect("fixtures must parse")
422    }
423
424    fn fixture_error(chain: &[ErrorFixture]) -> FixtureError {
425        let nested = chain.iter().rev().fold(None, |source, item| {
426            let message = match (&item.code, item.status) {
427                (Some(code), _) => format!("grpc code: {code}; {}", item.message),
428                (_, Some(status)) => format!("http status {status}; {}", item.message),
429                _ => item.message.clone(),
430            };
431            Some(Box::new(FixtureError { message, source }))
432        });
433        *nested.expect("classification chains are non-empty")
434    }
435
436    #[test]
437    fn classification_vectors_match_contract() {
438        for fixture in fixtures().classifications {
439            let error = fixture_error(&fixture.chain);
440            assert_eq!(
441                is_transient_error_with(
442                    &error,
443                    |_| StructuredEvidence::Indeterminate,
444                    &RPC_TRANSPORT_SIGNALS,
445                ),
446                fixture.transient,
447                "fixture: {}",
448                fixture.name
449            );
450        }
451    }
452
453    #[test]
454    fn transport_extension_is_opt_in() {
455        let error = FixtureError {
456            message: "transport error while proving".to_string(),
457            source: None,
458        };
459        assert!(!is_transient_error(&error, |_| {
460            StructuredEvidence::Indeterminate
461        }));
462        assert!(is_transient_error_with(
463            &error,
464            |_| StructuredEvidence::Indeterminate,
465            &RPC_TRANSPORT_SIGNALS,
466        ));
467    }
468
469    #[test]
470    fn delay_vectors_match_contract() {
471        for fixture in fixtures().delays {
472            assert_eq!(
473                retry_delay(fixture.retry_index, fixture.unit_random).as_millis(),
474                u128::from(fixture.delay_ms)
475            );
476        }
477    }
478
479    #[test]
480    fn typed_link_evidence_participates_in_precedence() {
481        let outer = FixtureError {
482            message: "temporarily unavailable".to_string(),
483            source: Some(Box::new(FixtureError {
484                message: "permission denied".to_string(),
485                source: None,
486            })),
487        };
488        let by_message = |cause: &(dyn Error + 'static)| {
489            let message = cause.to_string();
490            if message.contains("unavailable") {
491                StructuredEvidence::Transient
492            } else if message.contains("permission denied") {
493                StructuredEvidence::Permanent
494            } else {
495                StructuredEvidence::Indeterminate
496            }
497        };
498        assert!(!is_transient_error(&outer, by_message));
499
500        let transient_only = FixtureError {
501            message: "temporarily unavailable".to_string(),
502            source: None,
503        };
504        assert!(is_transient_error(&transient_only, by_message));
505    }
506
507    #[test]
508    fn grpc_code_partition_matches_tonic_codes() {
509        assert_eq!(grpc_code_evidence(1), StructuredEvidence::Transient);
510        assert_eq!(grpc_code_evidence(4), StructuredEvidence::Transient);
511        assert_eq!(grpc_code_evidence(8), StructuredEvidence::Transient);
512        assert_eq!(grpc_code_evidence(14), StructuredEvidence::Transient);
513        assert_eq!(grpc_code_evidence(13), StructuredEvidence::Permanent);
514        assert_eq!(grpc_code_evidence(10), StructuredEvidence::Permanent);
515        assert_eq!(grpc_code_evidence(0), StructuredEvidence::Indeterminate);
516        assert_eq!(grpc_code_evidence(2), StructuredEvidence::Indeterminate);
517        assert_eq!(grpc_code_evidence(99), StructuredEvidence::Indeterminate);
518    }
519}