Skip to main content

llm_kernel/llm/
retry.rs

1//! Exponential backoff retry wrapper for [`LLMClient`].
2//!
3//! Wraps any [`LLMClient`] implementation with configurable retry logic.
4//! Retries on rate-limit (429) and server errors (5xx) with exponential
5//! backoff and jitter to avoid thundering-herd behavior.
6//!
7//! Streaming is **not** retried — the caller receives the raw stream from
8//! the inner client. Streaming retry is complex and left for a future version.
9//!
10//! # Example
11//!
12//! ```ignore
13//! use llm_kernel::llm::{LLMClient, OpenAIClient, RetryClient, RetryConfig};
14//! use std::time::Duration;
15//!
16//! let client = OpenAIClient::from_key("gpt-4o", "sk-...")?;
17//! let retry = RetryClient::new(client, RetryConfig {
18//!     max_retries: 3,
19//!     base_delay: Duration::from_secs(1),
20//! });
21//! let response = retry.complete(request).await?;
22//! ```
23
24use std::time::Duration;
25
26use async_trait::async_trait;
27
28use crate::error::{KernelError, Result};
29use crate::llm::client::LLMClient;
30use crate::llm::types::{LLMRequest, LLMResponse, LLMStream};
31
32/// Configuration for retry behavior.
33#[derive(Debug, Clone)]
34pub struct RetryConfig {
35    /// Maximum number of retry attempts (0 = no retry).
36    pub max_retries: u32,
37    /// Base delay for exponential backoff.
38    pub base_delay: Duration,
39}
40
41impl Default for RetryConfig {
42    fn default() -> Self {
43        Self {
44            max_retries: 3,
45            base_delay: Duration::from_secs(1),
46        }
47    }
48}
49
50/// An [`LLMClient`] wrapper that retries on transient errors.
51///
52/// Retries are attempted for:
53/// - **429 Rate Limited** — uses the `retry-after` header value as the initial delay.
54/// - **5xx Server Errors** — exponential backoff starting from `base_delay`.
55///
56/// All other errors (4xx, network timeouts, deserialization failures) are
57/// returned immediately without retry.
58pub struct RetryClient<C> {
59    inner: C,
60    config: RetryConfig,
61}
62
63impl<C> RetryClient<C> {
64    /// Wrap an [`LLMClient`] with retry behavior.
65    pub fn new(inner: C, config: RetryConfig) -> Self {
66        Self { inner, config }
67    }
68
69    /// Access the underlying client.
70    pub fn inner(&self) -> &C {
71        &self.inner
72    }
73}
74
75/// Upper bound on an honored server `Retry-After`. A misconfigured or hostile
76/// endpoint returning e.g. `Retry-After: 999999` would otherwise wedge the task
77/// for ~11 days; we cap the honored hint at 5 minutes.
78const MAX_RETRY_AFTER_SECS: u64 = 300;
79
80/// Determine whether an error is retryable and extract the suggested delay.
81///
82/// Returns `None` for non-retryable errors.
83fn retry_delay(err: &KernelError, attempt: u32, base_delay: Duration) -> Option<Duration> {
84    match err {
85        KernelError::RateLimited(secs) => {
86            // Honor the server's retry-after hint (still applying backoff
87            // scaling), but clamp it so an absurd value can't stall for days.
88            let server_delay = Duration::from_secs((*secs).min(MAX_RETRY_AFTER_SECS));
89            Some(std::cmp::max(
90                server_delay,
91                backoff_with_jitter(attempt, base_delay),
92            ))
93        }
94        KernelError::Http { status, .. } if (*status >= 500 && *status < 600) => {
95            Some(backoff_with_jitter(attempt, base_delay))
96        }
97        _ => None,
98    }
99}
100
101/// Compute exponential backoff with jitter.
102///
103/// Delay = `base_delay * 2^attempt`, capped at 60 seconds, with a ±50% jitter.
104/// The jitter mixes the attempt with the current monotonic-time nanoseconds so
105/// concurrent retriers desynchronize (true jitter, preventing thundering-herd).
106/// No external RNG dependency — only `std` is used.
107fn backoff_with_jitter(attempt: u32, base_delay: Duration) -> Duration {
108    let base_ms = base_delay.as_millis() as u64;
109    // Exponential: base * 2^attempt, capped at 60s
110    let exp_ms = base_ms.saturating_mul(1u64 << attempt.min(6));
111    let capped_ms = exp_ms.min(60_000);
112    // Jitter seed: mix the attempt with a time-derived value so two retriers
113    // on the same attempt still get different delays.
114    let nanos = std::time::SystemTime::now()
115        .duration_since(std::time::UNIX_EPOCH)
116        .map(|d| d.subsec_nanos() as u64)
117        .unwrap_or(0);
118    let seed = (attempt as u64)
119        .wrapping_mul(2654435769)
120        .wrapping_add(nanos.wrapping_mul(0x9E3779B97F4A7C15));
121    let jitter_hash = seed % 1000;
122    // ±50%: scale into [0.5, 1.5) of the capped delay, then re-cap at 60s.
123    let jittered_ms = (capped_ms * (500 + jitter_hash) / 1000).min(60_000);
124    Duration::from_millis(jittered_ms)
125}
126
127#[async_trait]
128impl<C: LLMClient> LLMClient for RetryClient<C> {
129    async fn complete(&self, request: LLMRequest) -> Result<LLMResponse> {
130        let mut last_err = None;
131
132        for attempt in 0..=self.config.max_retries {
133            match self.inner.complete(request.clone()).await {
134                Ok(response) => return Ok(response),
135                Err(err) => {
136                    // Don't retry if we've exhausted attempts
137                    if attempt >= self.config.max_retries {
138                        return Err(err);
139                    }
140
141                    // Check if error is retryable
142                    let Some(delay) = retry_delay(&err, attempt, self.config.base_delay) else {
143                        return Err(err);
144                    };
145
146                    last_err = Some(err);
147                    tokio::time::sleep(delay).await;
148                }
149            }
150        }
151
152        // Should be unreachable, but just in case
153        Err(last_err.unwrap())
154    }
155
156    fn model_name(&self) -> &str {
157        self.inner.model_name()
158    }
159
160    /// Streaming is **not** retried. Delegates directly to the inner client.
161    async fn stream_complete(&self, request: LLMRequest) -> Result<LLMStream> {
162        self.inner.stream_complete(request).await
163    }
164}
165
166#[cfg(test)]
167mod tests {
168    use super::*;
169    use std::sync::atomic::AtomicU32;
170
171    #[test]
172    fn backoff_increases_with_attempts() {
173        let base = Duration::from_secs(1);
174        let d0 = backoff_with_jitter(0, base);
175        let d1 = backoff_with_jitter(1, base);
176        let d2 = backoff_with_jitter(2, base);
177
178        // Each attempt roughly doubles (with jitter)
179        assert!(d0.as_millis() > 0);
180        assert!(d1.as_millis() > d0.as_millis() / 2);
181        assert!(d2.as_millis() > d1.as_millis() / 2);
182    }
183
184    #[test]
185    fn backoff_capped_at_60s() {
186        let base = Duration::from_secs(10);
187        let d = backoff_with_jitter(20, base);
188        assert!(d <= Duration::from_secs(60));
189    }
190
191    #[test]
192    fn retry_delay_rate_limited_uses_server_hint() {
193        let err = KernelError::RateLimited(30);
194        let delay = retry_delay(&err, 0, Duration::from_secs(1));
195        assert!(delay.is_some());
196        // Should be at least 30s (server hint) or backoff, whichever is larger
197        assert!(delay.unwrap() >= Duration::from_secs(30));
198    }
199
200    #[test]
201    fn retry_delay_rate_limited_is_clamped() {
202        // A hostile Retry-After must not stall the task for days.
203        let err = KernelError::RateLimited(999_999);
204        let delay = retry_delay(&err, 0, Duration::from_secs(1)).unwrap();
205        assert!(delay <= Duration::from_secs(MAX_RETRY_AFTER_SECS));
206    }
207
208    #[test]
209    fn retry_delay_server_error_returns_backoff() {
210        let err = KernelError::Http {
211            status: 500,
212            message: "error".into(),
213        };
214        let delay = retry_delay(&err, 0, Duration::from_secs(1));
215        assert!(delay.is_some());
216    }
217
218    #[test]
219    fn retry_delay_client_error_returns_none() {
220        let err = KernelError::Http {
221            status: 400,
222            message: "bad request".into(),
223        };
224        let delay = retry_delay(&err, 0, Duration::from_secs(1));
225        assert!(delay.is_none());
226    }
227
228    #[test]
229    fn retry_delay_config_error_returns_none() {
230        let err = KernelError::Config("missing key".into());
231        let delay = retry_delay(&err, 0, Duration::from_secs(1));
232        assert!(delay.is_none());
233    }
234
235    /// A mock client that fails N times then succeeds.
236    struct MockClient {
237        fail_count: AtomicU32,
238        responses: std::sync::Mutex<Vec<Result<LLMResponse>>>,
239    }
240
241    impl MockClient {
242        fn new(responses: Vec<Result<LLMResponse>>) -> Self {
243            Self {
244                fail_count: AtomicU32::new(0),
245                responses: std::sync::Mutex::new(responses),
246            }
247        }
248    }
249
250    #[async_trait]
251    impl LLMClient for MockClient {
252        async fn complete(&self, _request: LLMRequest) -> Result<LLMResponse> {
253            let mut responses = self.responses.lock().unwrap();
254            if responses.is_empty() {
255                panic!("MockClient: no more responses");
256            }
257            let result = responses.remove(0);
258            if result.is_err() {
259                self.fail_count
260                    .fetch_add(1, std::sync::atomic::Ordering::Relaxed);
261            }
262            result
263        }
264
265        fn model_name(&self) -> &str {
266            "mock"
267        }
268
269        async fn stream_complete(&self, _request: LLMRequest) -> Result<LLMStream> {
270            unimplemented!()
271        }
272    }
273
274    fn ok_response() -> Result<LLMResponse> {
275        Ok(LLMResponse {
276            content: "ok".into(),
277            model: "mock".into(),
278            ..Default::default()
279        })
280    }
281
282    fn server_error() -> Result<LLMResponse> {
283        Err(KernelError::Http {
284            status: 500,
285            message: "error".into(),
286        })
287    }
288
289    fn rate_limited() -> Result<LLMResponse> {
290        Err(KernelError::RateLimited(0))
291    }
292
293    fn client_error() -> Result<LLMResponse> {
294        Err(KernelError::Http {
295            status: 400,
296            message: "bad request".into(),
297        })
298    }
299
300    #[tokio::test]
301    async fn retry_succeeds_after_transient_failures() {
302        let mock = MockClient::new(vec![server_error(), server_error(), ok_response()]);
303        let retry = RetryClient::new(
304            mock,
305            RetryConfig {
306                max_retries: 3,
307                base_delay: Duration::from_millis(1),
308            },
309        );
310
311        let result = retry.complete(LLMRequest::builder().build()).await;
312        assert!(result.is_ok());
313        assert_eq!(result.unwrap().content, "ok");
314    }
315
316    #[tokio::test]
317    async fn retry_exhausted_returns_last_error() {
318        let mock = MockClient::new(vec![
319            server_error(),
320            server_error(),
321            server_error(),
322            server_error(), // 4th failure = max_retries + 1
323        ]);
324        let retry = RetryClient::new(
325            mock,
326            RetryConfig {
327                max_retries: 3,
328                base_delay: Duration::from_millis(1),
329            },
330        );
331
332        let result = retry.complete(LLMRequest::builder().build()).await;
333        assert!(result.is_err());
334    }
335
336    #[tokio::test]
337    async fn retry_does_not_retry_client_error() {
338        let mock = MockClient::new(vec![client_error()]);
339        let retry = RetryClient::new(
340            mock,
341            RetryConfig {
342                max_retries: 3,
343                base_delay: Duration::from_millis(1),
344            },
345        );
346
347        let result = retry.complete(LLMRequest::builder().build()).await;
348        assert!(result.is_err());
349        let err = result.unwrap_err();
350        assert!(matches!(err, KernelError::Http { status: 400, .. }));
351    }
352
353    #[tokio::test]
354    async fn retry_handles_rate_limit_with_backoff() {
355        let mock = MockClient::new(vec![rate_limited(), ok_response()]);
356        let retry = RetryClient::new(
357            mock,
358            RetryConfig {
359                max_retries: 3,
360                base_delay: Duration::from_millis(1),
361            },
362        );
363
364        let result = retry.complete(LLMRequest::builder().build()).await;
365        assert!(result.is_ok());
366    }
367
368    #[tokio::test]
369    async fn retry_zero_retries_returns_immediately() {
370        let mock = MockClient::new(vec![server_error()]);
371        let retry = RetryClient::new(
372            mock,
373            RetryConfig {
374                max_retries: 0,
375                base_delay: Duration::from_millis(1),
376            },
377        );
378
379        let result = retry.complete(LLMRequest::builder().build()).await;
380        assert!(result.is_err());
381    }
382
383    #[test]
384    fn default_config() {
385        let config = RetryConfig::default();
386        assert_eq!(config.max_retries, 3);
387        assert_eq!(config.base_delay, Duration::from_secs(1));
388    }
389}