Skip to main content

camel_component_redis/
executor.rs

1use async_trait::async_trait;
2use camel_component_api::{CamelError, Exchange, NetworkRetryPolicy};
3// retry_async is used in tests (the regression test in this file).
4#[cfg(test)]
5use camel_component_api::retry_async;
6use std::sync::Arc;
7use std::sync::atomic::{AtomicUsize, Ordering};
8use tokio::sync::Mutex;
9
10use crate::config::{RedisCommand, is_transient_redis_error};
11
12/// Abstraction over a Redis connection that can execute commands.
13///
14/// This trait enables testing retry/reconnect behavior without a live Redis
15/// by allowing injection of a fake implementation.
16#[async_trait]
17pub trait RedisCommandExecutor: Send + Sync {
18    /// Execute a Redis command against the given exchange.
19    async fn execute_command(
20        &mut self,
21        cmd: &RedisCommand,
22        exchange: &mut Exchange,
23    ) -> Result<(), CamelError>;
24
25    /// Reconnect the underlying connection.
26    async fn reconnect(&mut self) -> Result<(), CamelError>;
27}
28
29/// Fake executor for testing retry behavior.
30///
31/// Configured with a sequence of results to return. Each call to
32/// `execute_command` consumes the next result. After exhausting the
33/// sequence, returns `Ok(())`.
34pub struct FakeExecutor {
35    /// Pre-programmed results: each call pops the next one.
36    results: Arc<Mutex<Vec<Result<(), FakeError>>>>,
37    /// Counter of how many times `execute_command` was called.
38    pub call_count: Arc<AtomicUsize>,
39    /// Counter of how many times `reconnect` was called.
40    pub reconnect_count: Arc<AtomicUsize>,
41}
42
43/// Error type for the fake executor, can be transient or non-transient.
44#[derive(Debug, Clone)]
45pub struct FakeError {
46    pub message: String,
47    pub is_transient: bool,
48}
49
50impl std::fmt::Display for FakeError {
51    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
52        write!(f, "{}", self.message)
53    }
54}
55
56impl FakeExecutor {
57    /// Creates a new fake executor with the given result sequence.
58    pub fn new(results: Vec<Result<(), FakeError>>) -> Self {
59        Self {
60            results: Arc::new(Mutex::new(results)),
61            call_count: Arc::new(AtomicUsize::new(0)),
62            reconnect_count: Arc::new(AtomicUsize::new(0)),
63        }
64    }
65
66    /// Returns a shared reference to the call counter.
67    pub fn call_count(&self) -> Arc<AtomicUsize> {
68        Arc::clone(&self.call_count)
69    }
70
71    /// Returns a shared reference to the reconnect counter.
72    pub fn reconnect_count(&self) -> Arc<AtomicUsize> {
73        Arc::clone(&self.reconnect_count)
74    }
75}
76
77#[async_trait]
78impl RedisCommandExecutor for FakeExecutor {
79    async fn execute_command(
80        &mut self,
81        _cmd: &RedisCommand,
82        _exchange: &mut Exchange,
83    ) -> Result<(), CamelError> {
84        self.call_count.fetch_add(1, Ordering::SeqCst);
85
86        let result = {
87            let mut results = self.results.lock().await;
88            results.pop().unwrap_or(Ok(()))
89        };
90
91        match result {
92            Ok(()) => Ok(()),
93            Err(fake_err) => {
94                if fake_err.is_transient {
95                    Err(CamelError::ProcessorError(format!(
96                        "Connection error: {}",
97                        fake_err.message
98                    )))
99                } else {
100                    Err(CamelError::ProcessorError(fake_err.message))
101                }
102            }
103        }
104    }
105
106    async fn reconnect(&mut self) -> Result<(), CamelError> {
107        self.reconnect_count.fetch_add(1, Ordering::SeqCst);
108        Ok(())
109    }
110}
111
112/// Executes a command with retry on transient errors, using `NetworkRetryPolicy`.
113///
114/// This is the core retry logic extracted for testability.
115///
116/// - `policy`: reconnection policy (max attempts, backoff, jitter, etc.)
117/// - `is_idempotent`: whether the command is safe to retry
118///
119/// # Implementation note
120///
121/// Uses a manual retry loop calling [`NetworkRetryPolicy::should_retry`] and
122/// [`NetworkRetryPolicy::delay_for`] rather than the shared [`retry_async`]
123/// helper. Manual loop needed because: (a) `executor.reconnect()` must run
124/// before each retry attempt (not the first attempt), so `retry_async`'s
125/// "always invoke op" model doesn't fit; (b) `&mut executor` / `&mut exchange`
126/// borrows cannot be re-borrowed through `FnMut() -> async move { ... }` —
127/// the Future returned by the closure holds the borrow past the closure body,
128/// which the borrow checker rejects. `retry_async_cancelable` has the same
129/// FnMut constraint so it is also excluded.
130pub async fn execute_with_retry<E: RedisCommandExecutor>(
131    executor: &mut E,
132    cmd: &RedisCommand,
133    exchange: &mut Exchange,
134    is_idempotent: bool,
135    policy: &NetworkRetryPolicy,
136) -> Result<(), CamelError> {
137    // Non-idempotent commands must not be retried — use a disabled policy.
138    let effective_policy = if is_idempotent {
139        policy.clone()
140    } else {
141        NetworkRetryPolicy::disabled()
142    };
143
144    let mut attempt: u32 = 0;
145
146    loop {
147        // Reconnect before retries (attempt > 0), not on the initial try.
148        // This preserves the existing reconnect-before-retry semantics.
149        if attempt > 0 {
150            executor.reconnect().await?;
151        }
152
153        match executor.execute_command(cmd, exchange).await {
154            Ok(()) => return Ok(()),
155            Err(err) => {
156                if !is_transient_redis_error(&err) || !effective_policy.should_retry(attempt + 1) {
157                    return Err(err);
158                }
159                let delay = effective_policy.delay_for(attempt);
160                tracing::warn!(
161                    attempt,
162                    delay_ms = delay.as_millis(),
163                    error = %err,
164                    "transient error — retrying"
165                );
166                tokio::time::sleep(delay).await;
167                attempt += 1;
168            }
169        }
170    }
171}
172
173#[cfg(test)]
174mod tests {
175    use super::*;
176    use std::time::Duration;
177
178    fn transient_err(msg: &str) -> Result<(), FakeError> {
179        Err(FakeError {
180            message: msg.to_string(),
181            is_transient: true,
182        })
183    }
184
185    fn non_transient_err(msg: &str) -> Result<(), FakeError> {
186        Err(FakeError {
187            message: msg.to_string(),
188            is_transient: false,
189        })
190    }
191
192    #[tokio::test]
193    async fn test_retry_succeeds_after_transient_failures() {
194        // 3 transient failures, then success
195        // Results are popped from the back, so we reverse: success first in vec = last popped
196        let executor = FakeExecutor::new(vec![
197            Ok(()),
198            transient_err("connection reset"),
199            transient_err("connection reset"),
200            transient_err("connection reset"),
201        ]);
202        let call_count = executor.call_count();
203        let reconnect_count = executor.reconnect_count();
204
205        let mut exchange = Exchange::default();
206        let cmd = RedisCommand::Get; // idempotent
207
208        let policy = NetworkRetryPolicy {
209            max_attempts: 10,
210            initial_delay: Duration::from_millis(1),
211            max_delay: Duration::from_millis(10),
212            ..NetworkRetryPolicy::default()
213        };
214
215        let result = execute_with_retry(
216            &mut { executor },
217            &cmd,
218            &mut exchange,
219            true, // is_idempotent
220            &policy,
221        )
222        .await;
223
224        assert!(result.is_ok(), "should succeed after retries: {:?}", result);
225        // 4 execute calls total: 1 initial + 3 retries
226        assert_eq!(call_count.load(Ordering::SeqCst), 4);
227        // 3 reconnects (one per retry attempt)
228        assert_eq!(reconnect_count.load(Ordering::SeqCst), 3);
229    }
230
231    #[tokio::test]
232    async fn test_retry_exhausted_after_max_retries() {
233        // 10 transient failures (exhausts all retries)
234        let mut results = vec![transient_err("connection refused"); 10];
235        results.push(transient_err("final failure")); // one more for the initial call
236        let executor = FakeExecutor::new(results);
237        let call_count = executor.call_count();
238
239        let mut exchange = Exchange::default();
240        let cmd = RedisCommand::Get;
241
242        let policy = NetworkRetryPolicy {
243            max_attempts: 11, // 1 initial + 10 retries = 11 total
244            initial_delay: Duration::from_millis(1),
245            max_delay: Duration::from_millis(10),
246            ..NetworkRetryPolicy::default()
247        };
248
249        let result =
250            execute_with_retry(&mut { executor }, &cmd, &mut exchange, true, &policy).await;
251
252        assert!(result.is_err(), "should fail after exhausting retries");
253        // 1 initial + 10 retries = 11 calls
254        assert_eq!(call_count.load(Ordering::SeqCst), 11);
255    }
256
257    #[tokio::test]
258    async fn test_non_idempotent_command_not_retried() {
259        let executor = FakeExecutor::new(vec![transient_err("connection reset")]);
260        let call_count = executor.call_count();
261
262        let mut exchange = Exchange::default();
263        let cmd = RedisCommand::Incr; // NOT idempotent
264
265        let policy = NetworkRetryPolicy {
266            max_attempts: 10,
267            initial_delay: Duration::from_millis(1),
268            max_delay: Duration::from_millis(10),
269            ..NetworkRetryPolicy::default()
270        };
271
272        let result = execute_with_retry(
273            &mut { executor },
274            &cmd,
275            &mut exchange,
276            false, // NOT idempotent
277            &policy,
278        )
279        .await;
280
281        assert!(result.is_err(), "non-idempotent should not be retried");
282        // Only 1 call — no retries
283        assert_eq!(call_count.load(Ordering::SeqCst), 1);
284    }
285
286    #[tokio::test]
287    async fn test_non_transient_error_not_retried() {
288        let executor = FakeExecutor::new(vec![non_transient_err("WRONGTYPE")]);
289        let call_count = executor.call_count();
290
291        let mut exchange = Exchange::default();
292        let cmd = RedisCommand::Get; // idempotent, but error is NOT transient
293
294        let policy = NetworkRetryPolicy {
295            max_attempts: 10,
296            initial_delay: Duration::from_millis(1),
297            max_delay: Duration::from_millis(10),
298            ..NetworkRetryPolicy::default()
299        };
300
301        let result = execute_with_retry(
302            &mut { executor },
303            &cmd,
304            &mut exchange,
305            true, // is_idempotent
306            &policy,
307        )
308        .await;
309
310        assert!(result.is_err(), "non-transient error should not be retried");
311        // Only 1 call — no retries for non-transient
312        assert_eq!(call_count.load(Ordering::SeqCst), 1);
313    }
314
315    #[tokio::test]
316    async fn test_idempotent_command_retried_on_transient() {
317        let executor = FakeExecutor::new(vec![
318            Ok(()),
319            transient_err("EOF"),
320            transient_err("timed out"),
321        ]);
322        let call_count = executor.call_count();
323
324        let mut exchange = Exchange::default();
325        let cmd = RedisCommand::Set; // idempotent write
326
327        let policy = NetworkRetryPolicy {
328            max_attempts: 10,
329            initial_delay: Duration::from_millis(1),
330            max_delay: Duration::from_millis(10),
331            ..NetworkRetryPolicy::default()
332        };
333
334        let result =
335            execute_with_retry(&mut { executor }, &cmd, &mut exchange, true, &policy).await;
336
337        assert!(result.is_ok());
338        // 1 initial + 2 retries = 3 calls
339        assert_eq!(call_count.load(Ordering::SeqCst), 3);
340    }
341
342    #[tokio::test]
343    async fn test_immediate_success_no_retries() {
344        let executor = FakeExecutor::new(vec![Ok(())]);
345        let call_count = executor.call_count();
346        let reconnect_count = executor.reconnect_count();
347
348        let mut exchange = Exchange::default();
349        let cmd = RedisCommand::Get;
350
351        let policy = NetworkRetryPolicy {
352            max_attempts: 10,
353            initial_delay: Duration::from_millis(1),
354            max_delay: Duration::from_millis(10),
355            ..NetworkRetryPolicy::default()
356        };
357
358        let result =
359            execute_with_retry(&mut { executor }, &cmd, &mut exchange, true, &policy).await;
360
361        assert!(result.is_ok());
362        assert_eq!(call_count.load(Ordering::SeqCst), 1);
363        assert_eq!(reconnect_count.load(Ordering::SeqCst), 0);
364    }
365
366    #[tokio::test]
367    async fn network_retry_policy_retries_on_transient() {
368        let policy = NetworkRetryPolicy {
369            max_attempts: 2,
370            initial_delay: Duration::from_millis(1),
371            max_delay: Duration::from_millis(5),
372            ..NetworkRetryPolicy::default()
373        };
374
375        let attempts = std::sync::Arc::new(std::sync::atomic::AtomicU32::new(0));
376        let attempts_clone = attempts.clone();
377
378        let result = retry_async::<(), _, _, _, CamelError>(
379            &policy,
380            None,
381            || {
382                let c = attempts_clone.clone();
383                async move {
384                    let n = c.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
385                    if n == 0 {
386                        Err(CamelError::ProcessorError("transient".into()))
387                    } else {
388                        Ok(())
389                    }
390                }
391            },
392            |_| true,
393        )
394        .await;
395
396        assert!(result.is_ok());
397        assert_eq!(attempts.load(std::sync::atomic::Ordering::SeqCst), 2);
398    }
399}