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