Skip to main content

communitas_core/
retry_utils.rs

1// Copyright (c) 2025 Saorsa Labs Limited
2//
3// Retry utilities with exponential backoff for resilient networking
4//
5// Implements adaptive retry behavior as specified in MESH_CAPABILITIES.md ยง3.2
6// to handle intermittent connectivity and network degradation gracefully.
7
8use std::time::Duration;
9use tokio_retry::Retry;
10use tokio_retry::strategy::{ExponentialBackoff, jitter};
11use tracing::{debug, warn};
12
13/// Default retry configuration for network operations
14#[derive(Debug, Clone)]
15pub struct RetryConfig {
16    /// Initial delay before first retry (default: 100ms)
17    pub initial_delay: Duration,
18
19    /// Maximum delay between retries (default: 60 seconds)
20    pub max_delay: Duration,
21
22    /// Maximum number of retry attempts (default: 10)
23    pub max_retries: usize,
24
25    /// Backoff multiplier (default: 2.0 for exponential)
26    pub backoff_multiplier: f64,
27}
28
29impl Default for RetryConfig {
30    fn default() -> Self {
31        Self {
32            initial_delay: Duration::from_millis(100),
33            max_delay: Duration::from_secs(60),
34            max_retries: 10,
35            backoff_multiplier: 2.0,
36        }
37    }
38}
39
40/// Backoff configuration
41#[derive(Debug, Clone)]
42pub struct BackoffConfig {
43    pub initial: Duration,
44    pub max: Duration,
45    pub multiplier: f64,
46}
47
48impl BackoffConfig {
49    pub fn into_strategy(self) -> impl Iterator<Item = Duration> {
50        let mut current = self.initial.as_millis() as u64;
51        let max_ms = self.max.as_millis() as u64;
52
53        std::iter::from_fn(move || {
54            let _delay = Duration::from_millis(current);
55            current = (current as f64 * self.multiplier) as u64;
56            if current > max_ms {
57                current = max_ms;
58            }
59
60            let jitter = (rand::random::<f64>() * 0.1 - 0.05) * current as f64;
61            Some(Duration::from_millis((current as f64 + jitter) as u64))
62        })
63    }
64}
65
66/// Result type for retry operations
67pub type RetryResult<T> = Result<T, anyhow::Error>;
68
69/// Retry an async operation with exponential backoff
70#[allow(unused_mut)] // FnMut requires mut parameter for Retry::spawn
71pub async fn retry_with_backoff<F, Fut, T>(mut operation: F, config: RetryConfig) -> RetryResult<T>
72where
73    F: FnMut() -> Fut,
74    Fut: std::future::Future<Output = RetryResult<T>>,
75{
76    let strategy = config.build_strategy();
77
78    Retry::spawn(strategy, operation).await
79}
80
81impl RetryConfig {
82    /// Create config for fast retries (low latency operations)
83    pub fn fast() -> Self {
84        Self {
85            initial_delay: Duration::from_millis(50),
86            max_delay: Duration::from_secs(5),
87            max_retries: 5,
88            backoff_multiplier: 2.0,
89        }
90    }
91
92    /// Create config for slow retries (expensive operations)
93    pub fn slow() -> Self {
94        Self {
95            initial_delay: Duration::from_secs(1),
96            max_delay: Duration::from_secs(300), // 5 minutes
97            max_retries: 15,
98            backoff_multiplier: 2.0,
99        }
100    }
101
102    /// Create config for critical operations (more attempts)
103    pub fn critical() -> Self {
104        Self {
105            initial_delay: Duration::from_millis(100),
106            max_delay: Duration::from_secs(120), // 2 minutes
107            max_retries: 20,
108            backoff_multiplier: 2.0,
109        }
110    }
111
112    /// Build tokio-retry strategy from config
113    ///
114    /// With tokio-retry, the iterator produces delays between attempts.
115    /// N delays = N+1 total attempts (initial + N retries).
116    /// So for max_retries total attempts, we need (max_retries - 1) delays.
117    pub fn build_strategy(&self) -> impl Iterator<Item = Duration> {
118        let backoff = ExponentialBackoff::from_millis(self.initial_delay.as_millis() as u64)
119            .max_delay(self.max_delay)
120            .take(self.max_retries.saturating_sub(1));
121
122        backoff.map(jitter)
123    }
124}
125
126/// Retry a network dial operation with logging
127pub async fn retry_dial<F, Fut, T, E>(
128    peer_id: &str,
129    config: RetryConfig,
130    mut dial_fn: F,
131) -> Result<T, E>
132where
133    F: FnMut() -> Fut,
134    Fut: std::future::Future<Output = Result<T, E>>,
135    E: std::fmt::Display,
136{
137    let mut attempt = 0;
138    let strategy = config.build_strategy();
139
140    for delay in strategy {
141        attempt += 1;
142
143        match dial_fn().await {
144            Ok(result) => {
145                if attempt > 1 {
146                    debug!("Dial to {} succeeded on attempt {}", peer_id, attempt);
147                }
148                return Ok(result);
149            }
150            Err(e) => {
151                debug!(
152                    "Dial to {} failed (attempt {}): {} - retrying in {:?}",
153                    peer_id, attempt, e, delay
154                );
155                tokio::time::sleep(delay).await;
156            }
157        }
158    }
159
160    // Final attempt after all delays exhausted
161    attempt += 1;
162    dial_fn().await.map_err(|e| {
163        warn!(
164            "Dial to {} failed after {} attempts: {}",
165            peer_id, attempt, e
166        );
167        e
168    })
169}
170
171/// Retry a coordinator discovery operation with appropriate backoff
172pub async fn retry_coordinator_discovery<F, Fut, T>(
173    config: RetryConfig,
174    operation: F,
175) -> RetryResult<T>
176where
177    F: FnMut() -> Fut,
178    Fut: std::future::Future<Output = RetryResult<T>>,
179{
180    retry_with_backoff(operation, config).await
181}
182
183#[cfg(test)]
184mod tests {
185    use super::*;
186    use std::sync::Arc;
187    use std::sync::atomic::{AtomicUsize, Ordering};
188
189    #[tokio::test]
190    async fn test_retry_succeeds_eventually() {
191        let attempts = Arc::new(AtomicUsize::new(0));
192        let attempts_clone = attempts.clone();
193
194        let config = RetryConfig {
195            initial_delay: Duration::from_millis(10),
196            max_delay: Duration::from_millis(100),
197            max_retries: 5,
198            backoff_multiplier: 2.0,
199        };
200
201        let result = retry_with_backoff(
202            || {
203                let attempts = attempts_clone.clone();
204                async move {
205                    let count = attempts.fetch_add(1, Ordering::SeqCst);
206                    if count < 2 {
207                        Err(anyhow::anyhow!("Not yet"))
208                    } else {
209                        Ok("Success")
210                    }
211                }
212            },
213            config,
214        )
215        .await;
216
217        assert!(result.is_ok());
218        assert_eq!(result.unwrap(), "Success");
219        assert_eq!(attempts.load(Ordering::SeqCst), 3);
220    }
221
222    #[tokio::test]
223    async fn test_retry_fails_after_max_attempts() {
224        let attempts = Arc::new(AtomicUsize::new(0));
225        let attempts_clone = attempts.clone();
226
227        let config = RetryConfig {
228            initial_delay: Duration::from_millis(10),
229            max_delay: Duration::from_millis(50),
230            max_retries: 3,
231            backoff_multiplier: 2.0,
232        };
233
234        let result = retry_with_backoff(
235            || {
236                let attempts = attempts_clone.clone();
237                async move {
238                    attempts.fetch_add(1, Ordering::SeqCst);
239                    Err::<(), _>(anyhow::anyhow!("Always fails"))
240                }
241            },
242            config,
243        )
244        .await;
245
246        assert!(result.is_err());
247        assert_eq!(attempts.load(Ordering::SeqCst), 3);
248    }
249
250    #[test]
251    fn test_retry_config_presets() {
252        let fast = RetryConfig::fast();
253        assert_eq!(fast.initial_delay, Duration::from_millis(50));
254        assert_eq!(fast.max_retries, 5);
255
256        let slow = RetryConfig::slow();
257        assert_eq!(slow.initial_delay, Duration::from_secs(1));
258        assert_eq!(slow.max_retries, 15);
259
260        let critical = RetryConfig::critical();
261        assert_eq!(critical.max_retries, 20);
262    }
263
264    #[tokio::test]
265    async fn test_retry_dial_with_logging() {
266        let attempts = Arc::new(AtomicUsize::new(0));
267        let attempts_clone = attempts.clone();
268
269        let config = RetryConfig {
270            initial_delay: Duration::from_millis(10),
271            max_delay: Duration::from_millis(50),
272            max_retries: 3,
273            backoff_multiplier: 2.0,
274        };
275
276        let result = retry_dial("test-peer", config, || {
277            let attempts = attempts_clone.clone();
278            async move {
279                let count = attempts.fetch_add(1, Ordering::SeqCst);
280                if count < 1 {
281                    Err(anyhow::anyhow!("Connection refused"))
282                } else {
283                    Ok(())
284                }
285            }
286        })
287        .await;
288
289        assert!(result.is_ok());
290        assert_eq!(attempts.load(Ordering::SeqCst), 2);
291    }
292}