Skip to main content

camel_component_api/
network_retry.rs

1//! Shared reconnection/backoff policy for networked components.
2//!
3//! Provides [`NetworkRetryPolicy`] (config struct), [`retry_async`] (execution helper),
4//! and [`retry_async_cancelable`] (cancellation-aware variant). Components that
5//! supervise external processes (JMS, xj, xslt) should use only
6//! [`NetworkRetryPolicy::delay_for`] inside their own supervision loops.
7//!
8//! Both [`retry_async`] and [`retry_async_cancelable`] identify the retrying
9//! component by `scheme`/`operation` in retry logs and metrics:
10//!
11//! ```rust,ignore
12//! use camel_component_api::retry_async;
13//!
14//! retry_async(&config.reconnect, "ws", "connect", op, is_retryable, metrics).await?;
15//! ```
16//!
17//! Retry log messages include `"ws/connect: transient error — retrying"` with
18//! `scheme` and `operation` structured fields that operators can filter with
19//! `scheme=ws operation=connect`. When `metrics` is `Some`, every attempt is
20//! recorded via `increment_retry_attempt(scheme, operation)` and an exhausted
21//! (or non-retryable) sequence records exactly one error via
22//! `increment_errors(operation, "e:{scheme}:{operation}")` — call sites must
23//! NOT double-count the same exhaustion in their Err arms. Cancellation is a
24//! clean shutdown and records no error.
25//!
26//! For location-specific context (URLs, endpoints), wrap the retry call in a
27//! [`tracing::span`](https://docs.rs/tracing/latest/tracing/macro.span.html)
28//! whose fields are inherited by all log events inside the retry loop:
29//!
30//! ```rust,ignore
31//! let span = tracing::info_span!("ws_connect", url = %url);
32//! let _guard = span.enter();
33//! retry_async(&config.reconnect, "ws", "connect", op, is_retryable, metrics).await?;
34//! ```
35
36use std::{future::Future, time::Duration};
37
38use rand::RngExt;
39use rand::distr::Uniform;
40use serde::{Deserialize, Serialize};
41use tokio::time::sleep;
42use tokio_util::sync::CancellationToken;
43
44use camel_api::MetricsCollector;
45
46use crate::CamelError;
47
48// Default-value helpers — must return the field type (Duration), not u64,
49// because serde calls these when the key is absent and deserialize_with is
50// NOT invoked in that case; the value must already be the target type.
51fn default_enabled() -> bool {
52    true
53}
54fn default_max_attempts() -> u32 {
55    10
56}
57fn default_initial_delay() -> Duration {
58    Duration::from_millis(100)
59}
60fn default_multiplier() -> f64 {
61    2.0
62}
63fn default_max_delay() -> Duration {
64    Duration::from_millis(30_000)
65}
66fn default_jitter_factor() -> f64 {
67    0.2
68}
69
70fn deserialize_duration_ms<'de, D>(d: D) -> Result<Duration, D::Error>
71where
72    D: serde::Deserializer<'de>,
73{
74    let ms = u64::deserialize(d)?;
75    Ok(Duration::from_millis(ms))
76}
77
78/// Reconnection and backoff policy for networked components.
79///
80/// Used in component config structs as a `reconnect` field:
81/// ```toml
82/// [default.components.redis.reconnect]
83/// max_attempts = 10
84/// initial_delay_ms = 100
85/// max_delay_ms = 30000
86/// ```
87// Derive Serialize as well: several component configs (e.g. Kafka) derive
88// Serialize, and a Deserialize-only nested struct breaks the derive on the
89// host struct.
90#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
91#[must_use]
92pub struct NetworkRetryPolicy {
93    /// Whether reconnection is enabled at all.
94    #[serde(default = "default_enabled")]
95    pub enabled: bool,
96
97    /// Maximum number of attempts before giving up. 0 means unlimited.
98    #[serde(default = "default_max_attempts")]
99    pub max_attempts: u32,
100
101    /// Base delay for the first retry.
102    #[serde(
103        default = "default_initial_delay",
104        rename = "initial_delay_ms",
105        deserialize_with = "deserialize_duration_ms"
106    )]
107    pub initial_delay: Duration,
108
109    /// Exponential backoff multiplier applied to each successive attempt.
110    #[serde(default = "default_multiplier")]
111    pub multiplier: f64,
112
113    /// Maximum delay cap regardless of computed backoff.
114    #[serde(
115        default = "default_max_delay",
116        rename = "max_delay_ms",
117        deserialize_with = "deserialize_duration_ms"
118    )]
119    pub max_delay: Duration,
120
121    /// Jitter factor in [0.0, 1.0]. Actual delay is `base ± (base * jitter_factor / 2)`.
122    #[serde(default = "default_jitter_factor")]
123    pub jitter_factor: f64,
124
125    /// Hard cap on total retry attempts regardless of `max_attempts`.
126    ///
127    /// When `Some(n)`, the policy stops retrying after `n` total attempts
128    /// (including the initial call) even if `max_attempts` is 0 (unlimited).
129    /// `None` (the default) preserves the `max_attempts` semantics unchanged.
130    #[serde(default)]
131    pub max_attempts_absolute: Option<u32>,
132}
133
134impl Default for NetworkRetryPolicy {
135    fn default() -> Self {
136        Self {
137            enabled: default_enabled(),
138            max_attempts: default_max_attempts(),
139            initial_delay: default_initial_delay(),
140            multiplier: default_multiplier(),
141            max_delay: default_max_delay(),
142            jitter_factor: default_jitter_factor(),
143            max_attempts_absolute: None,
144        }
145    }
146}
147
148impl NetworkRetryPolicy {
149    /// Returns a disabled policy — no retries will be attempted.
150    pub fn disabled() -> Self {
151        Self {
152            enabled: false,
153            ..Self::default()
154        }
155    }
156
157    /// Computes the sleep duration for a given zero-based attempt number.
158    ///
159    /// Formula: `clamp(initial * multiplier^attempt, 0, max_delay)` with random jitter.
160    #[must_use]
161    pub fn delay_for(&self, attempt: u32) -> Duration {
162        let base_ms = self.initial_delay.as_millis() as f64;
163        let exp = self.multiplier.powi(attempt as i32);
164        let computed_ms = (base_ms * exp).min(self.max_delay.as_millis() as f64);
165
166        // Apply random jitter: uniform in [-jitter_range/2, +jitter_range/2].
167        // Uses rand to avoid thundering herd when multiple consumers reconnect.
168        let jitter_range = computed_ms * self.jitter_factor;
169        let jitter = if jitter_range > 0.0 {
170            let mut rng = rand::rng();
171            let lo = -jitter_range / 2.0;
172            let hi = jitter_range / 2.0;
173            debug_assert!(lo < hi, "jitter bounds are valid when jitter_range > 0");
174            let dist = Uniform::new(lo, hi).unwrap(); // allow-unwrap
175            rng.sample(dist)
176        } else {
177            0.0
178        };
179
180        let final_ms = (computed_ms + jitter).max(0.0) as u64;
181        let max_delay_ms = u64::try_from(self.max_delay.as_millis()).unwrap_or(u64::MAX);
182        Duration::from_millis(final_ms.min(max_delay_ms))
183    }
184
185    /// Returns `true` if another retry should be attempted.
186    ///
187    /// `attempt` is zero-based: 0 = first attempt, 1 = first retry, etc.
188    ///
189    /// When `max_attempts_absolute` is `Some(n)`, the policy stops after `n`
190    /// total attempts regardless of `max_attempts`. When `None` (the default),
191    /// only `max_attempts` governs the limit (where 0 means unlimited).
192    #[must_use]
193    pub fn should_retry(&self, attempt: u32) -> bool {
194        if !self.enabled {
195            return false;
196        }
197        if let Some(abs_cap) = self.max_attempts_absolute
198            && attempt >= abs_cap
199        {
200            return false;
201        }
202        self.max_attempts == 0 || attempt < self.max_attempts
203    }
204}
205
206/// Executes `op` with reconnect/backoff according to `policy`.
207///
208/// `scheme` and `operation` identify the retrying component in retry log
209/// messages (structured `scheme`/`operation` fields, e.g. `ws/connect:
210/// transient error — retrying`) and in metrics.
211///
212/// When `metrics` is `Some`, every attempt (the first included) is recorded
213/// via [`MetricsCollector::increment_retry_attempt`], and a final failure —
214/// attempts exhausted or a non-retryable first error — records exactly ONE
215/// error via `increment_errors(operation, "e:{scheme}:{operation}")` before
216/// returning it. Call sites must not also count the same exhaustion in their
217/// Err arms (one error per exhausted retry sequence).
218///
219/// `is_retryable` classifies errors: retryable errors are retried, permanent
220/// errors are not.
221///
222/// # Security note: error Display is logged at WARN level
223///
224/// On every retry, the error's [`Display`](std::fmt::Display) representation
225/// is emitted via `tracing::warn!` for operator visibility during connection
226/// retries. Callers MUST sanitize errors before returning them from `op` if
227/// they may contain sensitive content such as connection strings, embedded
228/// credentials, or host‑port pairs. Sanitization belongs at the source — in
229/// the IO call whose error is wrapped — not here.
230///
231/// This log call is intentional and should not be removed: it provides the
232/// only diagnostic signal that a networked component is retrying and why.
233///
234/// # Example
235/// ```rust,ignore
236/// let result = retry_async(
237///     &config.reconnect,
238///     "ws",
239///     "connect",
240///     || async move { connect_to_server().await },
241///     |err: &CamelError| matches!(err, CamelError::Io(_)),
242///     metrics,
243/// ).await?;
244/// ```
245pub async fn retry_async<T, Op, Fut, IsRetryable, E>(
246    policy: &NetworkRetryPolicy,
247    scheme: &'static str,
248    operation: &'static str,
249    op: Op,
250    is_retryable: IsRetryable,
251    metrics: Option<&dyn MetricsCollector>,
252) -> Result<T, E>
253where
254    Op: FnMut() -> Fut,
255    Fut: Future<Output = Result<T, E>>,
256    IsRetryable: Fn(&E) -> bool,
257    E: std::fmt::Display,
258{
259    retry_async_inner(policy, scheme, operation, op, is_retryable, None, metrics).await
260}
261
262/// Shared private implementation used by both [`retry_async`] and
263/// [`retry_async_cancelable`].
264async fn retry_async_inner<T, Op, Fut, IsRetryable, E>(
265    policy: &NetworkRetryPolicy,
266    scheme: &'static str,
267    operation: &'static str,
268    mut op: Op,
269    is_retryable: IsRetryable,
270    cancel: Option<&CancellationToken>,
271    metrics: Option<&dyn MetricsCollector>,
272) -> Result<T, E>
273where
274    Op: FnMut() -> Fut,
275    Fut: Future<Output = Result<T, E>>,
276    IsRetryable: Fn(&E) -> bool,
277    E: std::fmt::Display,
278{
279    let mut attempt = 0u32;
280    loop {
281        if let Some(metrics) = metrics {
282            // allow-open-label rc-gm6s (scheme/operation: &'static str params, callers pass literals)
283            metrics.increment_retry_attempt(scheme, operation);
284        }
285        match op().await {
286            Ok(val) => return Ok(val),
287            Err(err) => {
288                if !is_retryable(&err) || !policy.should_retry(attempt + 1) {
289                    // One error per exhausted retry sequence. Cancellation
290                    // below is NOT an exhaustion: it is a clean shutdown and
291                    // must not fire error alerts.
292                    if let Some(metrics) = metrics {
293                        metrics.increment_errors(operation, &format!("e:{scheme}:{operation}"));
294                    }
295                    return Err(err);
296                }
297                let delay = policy.delay_for(attempt);
298                tracing::warn!(
299                    scheme,
300                    operation,
301                    attempt,
302                    delay_ms = delay.as_millis(),
303                    error = %err,
304                    "{scheme}/{operation}: transient error — retrying"
305                );
306                // Honour cancellation only during inter-retry sleep, not
307                // during the operation itself (that is the caller's
308                // responsibility).
309                if let Some(token) = cancel {
310                    tokio::select! {
311                        biased;
312                        _ = token.cancelled() => return Err(err),
313                        _ = sleep(delay) => {}
314                    }
315                } else {
316                    sleep(delay).await;
317                }
318                attempt += 1;
319            }
320        }
321    }
322}
323
324/// Like [`retry_async`] but honours a [`CancellationToken`] during inter-retry sleep.
325///
326/// Same semantics as [`retry_async`] except that if `cancel` fires while waiting
327/// between attempts, the function returns the last operation error immediately.
328/// Cancellation is **not** checked during the operation itself — the caller is
329/// responsible for making the operation itself cancellation-aware if needed.
330/// A cancelled sequence is a clean shutdown: no error is recorded with
331/// `metrics` even when the return value is `Err`.
332///
333/// `scheme`/`operation` are emitted as structured tracing fields
334/// (see [`retry_async`] for details).
335///
336/// # Example
337/// ```rust,ignore
338/// let cancel = CancellationToken::new();
339/// let result = retry_async_cancelable(
340///     &config.reconnect,
341///     "container",
342///     "events-connect",
343///     || async move { make_network_call().await },
344///     |err| is_transient(err),
345///     &cancel,
346///     metrics,
347/// ).await;
348/// ```
349pub async fn retry_async_cancelable<T, Op, Fut, IsRetryable, E>(
350    policy: &NetworkRetryPolicy,
351    scheme: &'static str,
352    operation: &'static str,
353    op: Op,
354    is_retryable: IsRetryable,
355    cancel: &CancellationToken,
356    metrics: Option<&dyn MetricsCollector>,
357) -> Result<T, E>
358where
359    Op: FnMut() -> Fut,
360    Fut: Future<Output = Result<T, E>>,
361    IsRetryable: Fn(&E) -> bool,
362    E: std::fmt::Display,
363{
364    retry_async_inner(
365        policy,
366        scheme,
367        operation,
368        op,
369        is_retryable,
370        Some(cancel),
371        metrics,
372    )
373    .await
374}
375
376/// Classify a [`CamelError`] as retryable (transient network/IO errors).
377///
378/// Retryable variants:
379/// - [`CamelError::Io`] — I/O errors (connection refused, DNS, etc.)
380/// - [`CamelError::ProcessorError`] whose message contains the literal
381///   `[TRANSIENT]` marker (used by gRPC and other components to flag
382///   retryable-by-classification errors).
383/// - [`CamelError::ProcessorErrorWithSource`] whose message contains the
384///   `[TRANSIENT]` marker (same semantics, preserves source error chain).
385///
386/// Non-retryable variants:
387/// - [`CamelError::Config`], [`CamelError::TypeConversionFailed`],
388///   [`CamelError::ConsumerStopping`], [`CamelError::EndpointCreationFailed`],
389///   [`CamelError::ChannelClosed`] — permanent failures
390pub fn is_retryable_camel_error(err: &CamelError) -> bool {
391    matches!(err, CamelError::Io(_))
392        || matches!(err, CamelError::ProcessorError(s) if s.contains("[TRANSIENT]"))
393        || matches!(err, CamelError::ProcessorErrorWithSource(s, _) if s.contains("[TRANSIENT]"))
394}
395
396#[cfg(test)]
397#[path = "network_retry_tests.rs"]
398mod tests;