Skip to main content

lc_agents/
retry.rs

1//! Retry helpers for LLM calls.
2//!
3//! LLM provider services are subject to transient failures (timeouts, 5xx,
4//! rate limits). Wrapping chat calls in an exponential-backoff retry turns a
5//! single network blip from a hard task failure into a non-event.
6
7use std::time::Duration;
8
9use lc_core::language_models::{BaseChatModel, LLMResult};
10use lc_core::runnables::RunnableConfig;
11use lc_schema::Message;
12
13/// Configuration for exponential-backoff retries.
14#[derive(Debug, Clone)]
15pub struct RetryConfig {
16    /// Maximum number of retries after the first failure.
17    pub max_retries: usize,
18    /// Initial delay before the first retry.
19    pub base_delay: Duration,
20    /// Upper bound on the backoff delay.
21    pub max_delay: Duration,
22}
23
24impl Default for RetryConfig {
25    fn default() -> Self {
26        Self {
27            max_retries: 3,
28            base_delay: Duration::from_secs(1),
29            max_delay: Duration::from_secs(30),
30        }
31    }
32}
33
34/// Call `llm.chat(...)` with exponential-backoff retries.
35///
36/// Returns the first successful result, or the last error once
37/// `retry.max_retries` retries have been exhausted. Accepts any reference to
38/// a `BaseChatModel` (concrete type, `&M`, or `&dyn ...` via `as_ref()`).
39pub(crate) async fn retry_chat<M>(
40    llm: &M,
41    messages: Vec<Message>,
42    config: Option<RunnableConfig>,
43    retry: &RetryConfig,
44) -> Result<LLMResult, M::Error>
45where
46    M: BaseChatModel + ?Sized,
47{
48    let mut attempt = 0usize;
49    loop {
50        match llm.chat(messages.clone(), config.clone()).await {
51            Ok(result) => return Ok(result),
52            Err(e) if attempt < retry.max_retries => {
53                // Exponential backoff: base_delay * 2^attempt, capped at max_delay.
54                let shift = 1u32.checked_shl(attempt as u32).unwrap_or(u32::MAX);
55                let delay = retry.base_delay.saturating_mul(shift).min(retry.max_delay);
56                log::warn!(
57                    "LLM call failed (attempt {}), retrying in {:?}: {}",
58                    attempt + 1,
59                    delay,
60                    e
61                );
62                tokio::time::sleep(delay).await;
63                attempt += 1;
64            }
65            Err(e) => return Err(e),
66        }
67    }
68}
69
70#[cfg(test)]
71mod tests {
72    use super::*;
73    use async_trait::async_trait;
74    use lc_core::language_models::{BaseLanguageModel, StreamChunk};
75    use lc_core::runnables::Runnable;
76    use std::sync::atomic::{AtomicUsize, Ordering};
77
78    /// A chat model that fails the first N calls, then succeeds.
79    struct FlakyChat {
80        calls: AtomicUsize,
81        failures_before_success: usize,
82    }
83
84    impl FlakyChat {
85        fn new(failures_before_success: usize) -> Self {
86            Self {
87                calls: AtomicUsize::new(0),
88                failures_before_success,
89            }
90        }
91    }
92
93    #[derive(Debug, thiserror::Error)]
94    #[error("flaky chat error")]
95    struct FlakyError;
96
97    #[async_trait]
98    impl Runnable<Vec<Message>, LLMResult> for FlakyChat {
99        type Error = FlakyError;
100
101        async fn invoke(
102            &self,
103            _input: Vec<Message>,
104            _config: Option<RunnableConfig>,
105        ) -> Result<LLMResult, Self::Error> {
106            unreachable!()
107        }
108    }
109
110    #[async_trait]
111    impl BaseLanguageModel<Vec<Message>, LLMResult> for FlakyChat {
112        fn model_name(&self) -> &str {
113            "flaky"
114        }
115
116        fn get_num_tokens(&self, text: &str) -> usize {
117            text.split_whitespace().count()
118        }
119
120        fn with_temperature(self, _temp: f32) -> Self
121        where
122            Self: Sized,
123        {
124            self
125        }
126
127        fn with_max_tokens(self, _max: usize) -> Self
128        where
129            Self: Sized,
130        {
131            self
132        }
133    }
134
135    #[async_trait]
136    impl BaseChatModel for FlakyChat {
137        async fn chat(
138            &self,
139            _messages: Vec<Message>,
140            _config: Option<RunnableConfig>,
141        ) -> Result<LLMResult, Self::Error> {
142            let call = self.calls.fetch_add(1, Ordering::SeqCst);
143            if call < self.failures_before_success {
144                Err(FlakyError)
145            } else {
146                Ok(LLMResult {
147                    content: "ok".to_string(),
148                    model: "flaky".to_string(),
149                    token_usage: None,
150                    tool_calls: None,
151                    thinking_content: None,
152                })
153            }
154        }
155
156        async fn stream_chat(
157            &self,
158            _messages: Vec<Message>,
159            _config: Option<RunnableConfig>,
160        ) -> Result<
161            std::pin::Pin<
162                Box<dyn futures_util::Stream<Item = Result<StreamChunk, Self::Error>> + Send>,
163            >,
164            Self::Error,
165        > {
166            unreachable!()
167        }
168    }
169
170    #[test]
171    fn retry_config_defaults() {
172        let cfg = RetryConfig::default();
173        assert_eq!(cfg.max_retries, 3);
174        assert_eq!(cfg.base_delay, Duration::from_secs(1));
175        assert_eq!(cfg.max_delay, Duration::from_secs(30));
176    }
177
178    #[tokio::test]
179    async fn retry_succeeds_after_transient_failures() {
180        let llm = FlakyChat::new(2); // fail twice, succeed on 3rd attempt
181        let cfg = RetryConfig {
182            max_retries: 3,
183            base_delay: Duration::from_millis(1),
184            max_delay: Duration::from_millis(5),
185        };
186        let result = retry_chat(&llm, vec![Message::human("hi")], None, &cfg).await;
187        assert!(result.is_ok());
188        assert_eq!(llm.calls.load(Ordering::SeqCst), 3);
189    }
190
191    #[tokio::test]
192    async fn retry_exhausts_and_returns_last_error() {
193        let llm = FlakyChat::new(10); // always fails
194        let cfg = RetryConfig {
195            max_retries: 2,
196            base_delay: Duration::from_millis(1),
197            max_delay: Duration::from_millis(5),
198        };
199        let result = retry_chat(&llm, vec![Message::human("hi")], None, &cfg).await;
200        assert!(result.is_err());
201        // 1 initial call + 2 retries
202        assert_eq!(llm.calls.load(Ordering::SeqCst), 3);
203    }
204}