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;