Skip to main content

lc_core/runnables/
retry.rs

1// lc-core/src/runnables/retry.rs
2//! RunnableRetry - retry a Runnable with exponential backoff.
3//!
4//! # Example
5//!
6//! ```rust,ignore
7//! use lc_core::runnables::{RetryConfig, RetryOn, RunnableExt};
8//! use std::time::Duration;
9//!
10//! let chain = prompt.pipe(llm).pipe(parser)
11//!     .with_retry(RetryConfig {
12//!         max_retries: 3,
13//!         initial_delay: Duration::from_millis(500),
14//!         max_delay: Duration::from_secs(10),
15//!         backoff_multiplier: 2.0,
16//!         retry_on: RetryOn::TransientErrors,
17//!     });
18//! ```
19
20use std::pin::Pin;
21use std::sync::Arc;
22use std::time::Duration;
23
24use async_trait::async_trait;
25use futures_util::{Stream, StreamExt};
26
27use super::any::RunnableAny;
28use super::config::RunnableConfig;
29use super::error::LcelError;
30use super::runnable_trait::Runnable;
31
32/// Determines which errors should trigger a retry.
33#[derive(Clone)]
34pub enum RetryOn {
35    /// Retry on any error.
36    AllErrors,
37    /// Retry only on transient errors (rate limits, timeouts, server errors).
38    /// Specifically: HTTP 429, 500, 502, 503, 504 and timeout errors.
39    TransientErrors,
40    /// Retry only when a custom predicate returns true.
41    Custom(Arc<dyn Fn(&str) -> bool + Send + Sync>),
42}
43
44impl std::fmt::Debug for RetryOn {
45    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
46        match self {
47            Self::AllErrors => write!(f, "AllErrors"),
48            Self::TransientErrors => write!(f, "TransientErrors"),
49            Self::Custom(_) => write!(f, "Custom(<closure>)"),
50        }
51    }
52}
53
54/// Configuration for retry behavior.
55#[derive(Debug, Clone)]
56pub struct RetryConfig {
57    /// Maximum number of retry attempts (not counting the initial call).
58    pub max_retries: usize,
59
60    /// Initial delay before the first retry.
61    pub initial_delay: Duration,
62
63    /// Maximum delay between retries (caps exponential growth).
64    pub max_delay: Duration,
65
66    /// Multiplier applied to the delay after each attempt.
67    /// A value of 2.0 means each retry waits twice as long as the previous.
68    pub backoff_multiplier: f64,
69
70    /// Which errors should trigger a retry.
71    pub retry_on: RetryOn,
72}
73
74impl Default for RetryConfig {
75    fn default() -> Self {
76        Self {
77            max_retries: 3,
78            initial_delay: Duration::from_millis(500),
79            max_delay: Duration::from_secs(10),
80            backoff_multiplier: 2.0,
81            retry_on: RetryOn::TransientErrors,
82        }
83    }
84}
85
86impl RetryConfig {
87    /// Creates a new RetryConfig with the specified max retries and defaults for other fields.
88    pub fn new(max_retries: usize) -> Self {
89        Self {
90            max_retries,
91            ..Default::default()
92        }
93    }
94
95    /// Sets the initial delay.
96    pub fn with_initial_delay(mut self, delay: Duration) -> Self {
97        self.initial_delay = delay;
98        self
99    }
100
101    /// Sets the maximum delay.
102    pub fn with_max_delay(mut self, delay: Duration) -> Self {
103        self.max_delay = delay;
104        self
105    }
106
107    /// Sets the backoff multiplier.
108    pub fn with_backoff_multiplier(mut self, multiplier: f64) -> Self {
109        self.backoff_multiplier = multiplier;
110        self
111    }
112
113    /// Sets which errors should trigger a retry.
114    pub fn with_retry_on(mut self, retry_on: RetryOn) -> Self {
115        self.retry_on = retry_on;
116        self
117    }
118
119    /// Validate the configuration at construction time.
120    ///
121    /// A non-finite or non-positive `backoff_multiplier` used to propagate
122    /// all the way into `Duration::from_secs_f64` (a panic) or silently
123    /// collapse retries to zero delay via the runtime clamp. Likewise an
124    /// `initial_delay` above `max_delay` makes the cap meaningless. Both are
125    /// rejected here so misconfiguration fails fast before the first call.
126    pub fn validate(&self) -> Result<(), String> {
127        if !self.backoff_multiplier.is_finite() || self.backoff_multiplier <= 0.0 {
128            return Err(format!(
129                "backoff_multiplier must be a finite, positive number, got {}",
130                self.backoff_multiplier
131            ));
132        }
133        if self.max_delay < self.initial_delay {
134            return Err(format!(
135                "max_delay ({:?}) must be at least initial_delay ({:?})",
136                self.max_delay, self.initial_delay
137            ));
138        }
139        Ok(())
140    }
141
142    /// Checks if an error should trigger a retry.
143    fn should_retry(&self, error: &str) -> bool {
144        match &self.retry_on {
145            RetryOn::AllErrors => true,
146            RetryOn::TransientErrors => is_transient_error(error),
147            RetryOn::Custom(predicate) => predicate(error),
148        }
149    }
150
151    /// Calculates the delay for a given attempt number (0-based).
152    fn delay_for_attempt(&self, attempt: usize) -> Duration {
153        let multiplier = self.backoff_multiplier.powi(attempt as i32);
154        // Clamp to non-negative: a negative multiplier would produce a negative
155        // duration and `Duration::from_secs_f64` panics.
156        let delay = (self.initial_delay.as_secs_f64() * multiplier).max(0.0);
157        let delay = delay.min(self.max_delay.as_secs_f64()).max(0.0);
158        Duration::from_secs_f64(delay)
159    }
160}
161
162/// Check if an error looks like a transient (retriable) error.
163fn is_transient_error(error: &str) -> bool {
164    let error_lower = error.to_lowercase();
165
166    // HTTP status codes that are retriable. Matched as **whole tokens** (B3):
167    // a bare `contains("500")` misclassifies "5000 tokens" or "1.500ms" as a 500
168    // error. `split` on non-alphanumerics keeps "500" recognisable whether it
169    // appears bare, bracketed ("[500]"), or percent-encoded status-adjacent text.
170    for code in &["429", "500", "502", "503", "504"] {
171        if error_lower
172            .split(|c: char| !c.is_alphanumeric())
173            .any(|t| t == *code)
174        {
175            return true;
176        }
177    }
178
179    // Common transient error patterns
180    let transient_patterns = [
181        "rate limit",
182        "rate_limit",
183        "ratelimit",
184        "too many requests",
185        "timeout",
186        "timed out",
187        "connection reset",
188        "connection refused",
189        "temporary failure",
190        "service unavailable",
191        "internal server error",
192        "overloaded",
193        "capacity",
194    ];
195
196    for pattern in &transient_patterns {
197        if error_lower.contains(pattern) {
198            return true;
199        }
200    }
201
202    false
203}
204
205/// A Runnable wrapper that retries the inner Runnable on failure.
206pub struct RunnableRetry<I: Send + Sync + 'static, O: Send + Sync + 'static> {
207    runnable: Arc<dyn RunnableAny>,
208    retry_config: RetryConfig,
209    _marker: std::marker::PhantomData<(I, O)>,
210}
211
212impl<I: Send + Sync + 'static, O: Send + Sync + 'static> RunnableRetry<I, O> {
213    /// Creates a new RunnableRetry from a boxed RunnableAny.
214    ///
215    /// Fails at construction when `retry_config` is invalid (see
216    /// [`RetryConfig::validate`]) instead of panicking on the first retry.
217    pub fn new(runnable: Box<dyn RunnableAny>, retry_config: RetryConfig) -> Result<Self, String> {
218        retry_config.validate()?;
219        Ok(Self {
220            runnable: Arc::from(runnable),
221            retry_config,
222            _marker: std::marker::PhantomData,
223        })
224    }
225}
226
227#[async_trait]
228impl<I: Send + Sync + 'static, O: Send + Sync + 'static> Runnable<I, O> for RunnableRetry<I, O>
229where
230    I: Clone,
231{
232    type Error = LcelError;
233
234    async fn invoke(&self, input: I, config: Option<RunnableConfig>) -> Result<O, Self::Error> {
235        // Check cancellation before starting
236        if config.as_ref().is_some_and(|c| c.is_cancelled()) {
237            return Err(LcelError::Other("Operation cancelled".to_string()));
238        }
239
240        let mut last_error = None;
241
242        for attempt in 0..=self.retry_config.max_retries {
243            // Check cancellation before each attempt
244            if attempt > 0 && config.as_ref().is_some_and(|c| c.is_cancelled()) {
245                return Err(LcelError::Other("Operation cancelled".to_string()));
246            }
247
248            // Delay before retry (not on first attempt)
249            if attempt > 0 {
250                let delay = self.retry_config.delay_for_attempt(attempt - 1);
251                tokio::time::sleep(delay).await;
252            }
253
254            match self
255                .runnable
256                .invoke_any(Box::new(input.clone()), config.clone())
257                .await
258            {
259                Ok(result) => {
260                    return result.downcast::<O>().map(|boxed| *boxed).map_err(|_| {
261                        LcelError::Other("Type mismatch in retry result".to_string())
262                    });
263                }
264                Err(e) => {
265                    let error_str = e.to_string();
266                    if attempt < self.retry_config.max_retries
267                        && self.retry_config.should_retry(&error_str)
268                    {
269                        last_error = Some(e);
270                        continue;
271                    }
272                    return Err(e);
273                }
274            }
275        }
276
277        Err(last_error.unwrap_or_else(|| {
278            LcelError::Other("Retry exhausted with no error recorded".to_string())
279        }))
280    }
281
282    async fn stream(
283        &self,
284        input: I,
285        config: Option<RunnableConfig>,
286    ) -> Result<Pin<Box<dyn Stream<Item = Result<O, Self::Error>> + Send>>, Self::Error> {
287        // For stream, we retry the stream setup (construction), not individual tokens.
288        // Once the stream is established, token-level errors propagate normally.
289        let mut last_error = None;
290
291        for attempt in 0..=self.retry_config.max_retries {
292            if attempt > 0 && config.as_ref().is_some_and(|c| c.is_cancelled()) {
293                return Err(LcelError::Other("Operation cancelled".to_string()));
294            }
295
296            if attempt > 0 {
297                let delay = self.retry_config.delay_for_attempt(attempt - 1);
298                tokio::time::sleep(delay).await;
299            }
300
301            match self
302                .runnable
303                .stream_any(Box::new(input.clone()), config.clone())
304                .await
305            {
306                Ok(stream) => {
307                    // Convert the type-erased stream to a typed stream
308                    let typed_stream = stream.map(|result| {
309                        result.and_then(|boxed| {
310                            boxed.downcast::<O>().map(|b| *b).map_err(|_| {
311                                LcelError::Other("Type mismatch in retry stream".to_string())
312                            })
313                        })
314                    });
315                    return Ok(Box::pin(typed_stream));
316                }
317                Err(e) => {
318                    let error_str = e.to_string();
319                    if attempt < self.retry_config.max_retries
320                        && self.retry_config.should_retry(&error_str)
321                    {
322                        last_error = Some(e);
323                        continue;
324                    }
325                    return Err(e);
326                }
327            }
328        }
329
330        Err(last_error.unwrap_or_else(|| {
331            LcelError::Other("Retry exhausted with no error recorded".to_string())
332        }))
333    }
334}
335
336#[cfg(test)]
337mod tests {
338    use super::*;
339    use crate::runnables::{CancellationToken, RunnableConfig, RunnableExt, RunnableLambda};
340    use std::sync::atomic::{AtomicUsize, Ordering};
341    use std::sync::Arc;
342
343    #[test]
344    fn test_retry_config_default() {
345        let config = RetryConfig::default();
346        assert_eq!(config.max_retries, 3);
347        assert_eq!(config.initial_delay, Duration::from_millis(500));
348        assert_eq!(config.max_delay, Duration::from_secs(10));
349        assert!((config.backoff_multiplier - 2.0).abs() < f64::EPSILON);
350    }
351
352    #[test]
353    fn test_delay_for_attempt() {
354        let config = RetryConfig::default();
355        assert_eq!(config.delay_for_attempt(0), Duration::from_millis(500));
356        assert_eq!(config.delay_for_attempt(1), Duration::from_secs(1));
357        assert_eq!(config.delay_for_attempt(2), Duration::from_secs(2));
358        // Should be capped at max_delay
359        assert_eq!(config.delay_for_attempt(10), Duration::from_secs(10));
360    }
361
362    #[test]
363    fn test_is_transient_error() {
364        assert!(is_transient_error("HTTP 429: Too Many Requests"));
365        assert!(is_transient_error("HTTP 503: Service Unavailable"));
366        assert!(is_transient_error("rate limit exceeded"));
367        assert!(is_transient_error("Connection timeout"));
368        assert!(is_transient_error("internal server error"));
369
370        assert!(!is_transient_error("HTTP 401: Unauthorized"));
371        assert!(!is_transient_error("HTTP 403: Forbidden"));
372        assert!(!is_transient_error("invalid API key"));
373        assert!(!is_transient_error("model not found"));
374    }
375
376    #[tokio::test]
377    async fn test_retry_succeeds_on_second_attempt() {
378        let call_count = Arc::new(AtomicUsize::new(0));
379        let count_clone = call_count.clone();
380
381        let runnable = RunnableLambda::new_async(move |_: i32| {
382            let count = count_clone.clone();
383            async move {
384                let n = count.fetch_add(1, Ordering::SeqCst);
385                if n == 0 {
386                    Err(LcelError::Other(
387                        "HTTP 503: Service Unavailable".to_string(),
388                    ))
389                } else {
390                    Ok(42)
391                }
392            }
393        });
394
395        let retry = runnable.with_retry(RetryConfig::new(2)).unwrap();
396        let result: Result<i32, _> = retry.invoke(1, None).await;
397        assert_eq!(result.unwrap(), 42);
398        assert_eq!(call_count.load(Ordering::SeqCst), 2);
399    }
400
401    #[tokio::test]
402    async fn test_retry_exhausts_all_attempts() {
403        let call_count = Arc::new(AtomicUsize::new(0));
404        let count_clone = call_count.clone();
405
406        let runnable = RunnableLambda::new_async(move |_: i32| {
407            let count = count_clone.clone();
408            async move {
409                count.fetch_add(1, Ordering::SeqCst);
410                Err(LcelError::Other(
411                    "HTTP 503: Service Unavailable".to_string(),
412                ))
413            }
414        });
415
416        let retry = runnable.with_retry(RetryConfig::new(2)).unwrap();
417        let result: Result<i32, _> = retry.invoke(1, None).await;
418        assert!(result.is_err());
419        assert_eq!(call_count.load(Ordering::SeqCst), 3); // 1 initial + 2 retries
420    }
421
422    #[tokio::test]
423    async fn test_retry_non_retriable_error_fails_immediately() {
424        let call_count = Arc::new(AtomicUsize::new(0));
425        let count_clone = call_count.clone();
426
427        let runnable = RunnableLambda::new_async(move |_: i32| {
428            let count = count_clone.clone();
429            async move {
430                count.fetch_add(1, Ordering::SeqCst);
431                Err(LcelError::Other("HTTP 401: Unauthorized".to_string()))
432            }
433        });
434
435        let retry = runnable.with_retry(RetryConfig::new(3)).unwrap();
436        let result: Result<i32, _> = retry.invoke(1, None).await;
437        assert!(result.is_err());
438        assert_eq!(call_count.load(Ordering::SeqCst), 1); // No retry for 401
439    }
440
441    #[tokio::test]
442    async fn test_retry_succeeds_on_first_attempt() {
443        let runnable = RunnableLambda::new_sync(|x: i32| x * 2);
444        let retry = runnable.with_retry(RetryConfig::new(3)).unwrap();
445        let result: Result<i32, _> = retry.invoke(5, None).await;
446        assert_eq!(result.unwrap(), 10);
447    }
448
449    #[tokio::test]
450    async fn test_retry_respects_cancellation() {
451        let token = CancellationToken::new();
452        token.cancel();
453
454        let runnable = RunnableLambda::new_sync(|x: i32| x * 2);
455        let retry = runnable.with_retry(RetryConfig::new(3)).unwrap();
456
457        let config = RunnableConfig::new().with_cancellation_token(token);
458        let result: Result<i32, _> = retry.invoke(5, Some(config)).await;
459        assert!(result.is_err());
460        assert!(result.unwrap_err().to_string().contains("cancelled"));
461    }
462
463    // ---- 0.25.0: constructor-time validation ----
464
465    #[test]
466    fn validate_accepts_default_config() {
467        assert!(RetryConfig::default().validate().is_ok());
468    }
469
470    #[test]
471    fn validate_rejects_non_positive_or_non_finite_multiplier() {
472        for bad in [0.0, -1.0, -2.5, f64::NAN, f64::INFINITY, f64::NEG_INFINITY] {
473            let cfg = RetryConfig::default().with_backoff_multiplier(bad);
474            assert!(
475                cfg.validate().is_err(),
476                "multiplier {bad} must be rejected at construction"
477            );
478        }
479    }
480
481    #[test]
482    fn validate_rejects_delay_bounds_inverted() {
483        let cfg = RetryConfig::default()
484            .with_initial_delay(Duration::from_secs(30))
485            .with_max_delay(Duration::from_millis(100));
486        assert!(cfg.validate().is_err());
487    }
488
489    #[tokio::test]
490    async fn with_retry_rejects_invalid_config_before_running() {
491        let runnable = RunnableLambda::new_sync(|x: i32| x * 2);
492        // The negative multiplier that used to panic inside
493        // `Duration::from_secs_f64` is now a construction error.
494        let result = runnable.with_retry(RetryConfig::default().with_backoff_multiplier(-1.0));
495        assert!(result.is_err());
496    }
497}