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#[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
65pub 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#[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#[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#[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#[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
273pub const CONNECT_PERMANENT_SIGNALS: [&str; 4] =
278 ["certificate", "tls", "invalid uri", "unsupported scheme"];
279
280pub 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
295pub const RPC_TRANSPORT_SIGNALS: [&str; 2] = ["connection error", "transport error"];
300
301pub 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
312pub 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}