sui_rpc/light_client/
retry.rs1use std::time::Duration;
20
21use super::RatchetConfig;
22use super::error::LightClientError;
23
24pub(crate) async fn step(
31 config: &RatchetConfig,
32 err: LightClientError,
33 attempt: &mut u32,
34) -> Result<(), LightClientError> {
35 if !is_retryable(&err) || *attempt >= config.max_retries {
36 return Err(err);
37 }
38 tokio::time::sleep(backoff_delay(config, *attempt)).await;
39 *attempt = attempt.saturating_add(1);
40 Ok(())
41}
42
43fn is_retryable(err: &LightClientError) -> bool {
48 let LightClientError::Rpc(status) = err else {
49 return false;
50 };
51 matches!(
52 status.code(),
53 tonic::Code::Unavailable
54 | tonic::Code::DeadlineExceeded
55 | tonic::Code::ResourceExhausted
56 | tonic::Code::Aborted
57 )
58}
59
60fn backoff_delay(config: &RatchetConfig, attempt: u32) -> Duration {
61 let shift = attempt.min(20);
62 let base = config
63 .base_retry_delay
64 .saturating_mul(1u32 << shift)
65 .min(config.max_retry_delay);
66 base.saturating_add(pseudo_jitter(attempt, config.retry_jitter))
67}
68
69fn pseudo_jitter(attempt: u32, ceiling: Duration) -> Duration {
73 if ceiling.is_zero() {
74 return Duration::ZERO;
75 }
76 let mix = u64::from(attempt).wrapping_mul(0x9E37_79B9_7F4A_7C15) as u128;
77 let ceiling_ms = ceiling.as_millis().max(1);
78 let offset_ms = (mix % ceiling_ms) as u64;
79 Duration::from_millis(offset_ms)
80}
81
82#[cfg(test)]
83mod tests {
84 use super::*;
85
86 fn no_sleep_config(max_retries: u32) -> RatchetConfig {
87 RatchetConfig {
88 max_retries,
89 base_retry_delay: Duration::ZERO,
90 max_retry_delay: Duration::ZERO,
91 retry_jitter: Duration::ZERO,
92 ..RatchetConfig::default()
93 }
94 }
95
96 #[tokio::test]
98 async fn step_retries_until_cap_then_surfaces_last_error() {
99 let config = no_sleep_config(3);
100 let mut attempt = 0u32;
101 for _ in 0..3 {
102 let err = LightClientError::Rpc(tonic::Status::unavailable("down"));
103 step(&config, err, &mut attempt)
104 .await
105 .expect("retry allowed");
106 }
107 let err = LightClientError::Rpc(tonic::Status::unavailable("down"));
109 let result = step(&config, err, &mut attempt).await;
110 assert!(matches!(result, Err(LightClientError::Rpc(_))));
111 assert_eq!(attempt, 3);
112 }
113
114 #[tokio::test]
117 async fn step_passes_non_retryable_errors_through() {
118 let config = no_sleep_config(5);
119 let mut attempt = 0u32;
120 let err = LightClientError::Rpc(tonic::Status::invalid_argument("nope"));
121 let result = step(&config, err, &mut attempt).await;
122 assert!(matches!(result, Err(LightClientError::Rpc(_))));
123 assert_eq!(attempt, 0);
124 }
125
126 #[tokio::test]
129 async fn step_disabled_retry_refuses_immediately() {
130 let config = no_sleep_config(0);
131 let mut attempt = 0u32;
132 let err = LightClientError::Rpc(tonic::Status::unavailable("down"));
133 let result = step(&config, err, &mut attempt).await;
134 assert!(matches!(result, Err(LightClientError::Rpc(_))));
135 assert_eq!(attempt, 0);
136 }
137
138 #[test]
141 fn backoff_delay_caps_at_max() {
142 let config = RatchetConfig {
143 base_retry_delay: Duration::from_millis(100),
144 max_retry_delay: Duration::from_millis(800),
145 retry_jitter: Duration::ZERO,
146 ..RatchetConfig::default()
147 };
148 assert_eq!(backoff_delay(&config, 0), Duration::from_millis(100));
150 assert_eq!(backoff_delay(&config, 1), Duration::from_millis(200));
152 assert_eq!(backoff_delay(&config, 2), Duration::from_millis(400));
154 assert_eq!(backoff_delay(&config, 3), Duration::from_millis(800));
156 assert_eq!(backoff_delay(&config, 4), Duration::from_millis(800));
158 }
159}