1use std::time::{Duration, SystemTime, UNIX_EPOCH};
15
16use async_trait::async_trait;
17use polyc_llm::{Chunk, CompletionRequest, LlmError, LlmErrorKind, LlmProvider};
18
19#[async_trait]
28pub trait Clock: std::fmt::Debug + Send + Sync {
29 fn now(&self) -> SystemTime;
31
32 fn jitter_frac(&self) -> f64 {
38 let nanos = self
39 .now()
40 .duration_since(UNIX_EPOCH)
41 .map_or(0, |d| d.subsec_nanos());
42 f64::from(nanos % 1_000_000) / 1_000_000.0
43 }
44
45 async fn sleep(&self, dur: Duration);
52}
53
54#[derive(Debug, Default, Clone, Copy)]
60pub struct RealClock;
61
62#[async_trait]
63impl Clock for RealClock {
64 fn now(&self) -> SystemTime {
65 SystemTime::now()
66 }
67
68 async fn sleep(&self, dur: Duration) {
69 tokio::time::sleep(dur).await;
70 }
71}
72
73#[derive(Debug, Clone, Copy)]
75pub struct RetryConfig {
76 pub max_retries: u32,
79 pub base_delay: Duration,
81 pub max_delay: Duration,
83}
84
85impl Default for RetryConfig {
86 fn default() -> Self {
87 Self {
88 max_retries: 4,
89 base_delay: Duration::from_millis(500),
90 max_delay: Duration::from_secs(30),
91 }
92 }
93}
94
95impl RetryConfig {
96 #[must_use]
102 pub fn from_env() -> Self {
103 let d = Self::default();
104 Self {
105 max_retries: env_parse("POLYCHROME_LLM_MAX_RETRIES").unwrap_or(d.max_retries),
106 base_delay: env_parse("POLYCHROME_LLM_RETRY_BASE_MS")
107 .map_or(d.base_delay, Duration::from_millis),
108 max_delay: env_parse("POLYCHROME_LLM_RETRY_MAX_MS")
109 .map_or(d.max_delay, Duration::from_millis),
110 }
111 }
112}
113
114pub(crate) fn env_parse<T: std::str::FromStr>(key: &str) -> Option<T> {
122 std::env::var(key).ok()?.parse().ok()
123}
124
125const fn is_retryable(kind: LlmErrorKind) -> bool {
127 matches!(
128 kind,
129 LlmErrorKind::RateLimit | LlmErrorKind::Timeout | LlmErrorKind::Unavailable
130 )
131}
132
133#[must_use]
139pub fn backoff_delay(attempt: u32, base: Duration, cap: Duration, jitter_frac: f64) -> Duration {
140 let factor = 1u32.checked_shl(attempt.min(16)).unwrap_or(u32::MAX);
142 let exp = base.saturating_mul(factor).min(cap);
143 let scale = 0.5_f64.mul_add(jitter_frac.clamp(0.0, 1.0), 0.5);
145 exp.mul_f64(scale)
146}
147
148pub async fn complete_with_retry<P>(
161 provider: &P,
162 req: CompletionRequest,
163 cfg: &RetryConfig,
164 clock: &dyn Clock,
165) -> Result<futures::stream::BoxStream<'static, Result<Chunk, P::Error>>, P::Error>
166where
167 P: LlmProvider + ?Sized,
168{
169 let mut attempt = 0u32;
170 loop {
171 match provider.complete(req.clone()).await {
172 Ok(stream) => return Ok(stream),
173 Err(err) => {
174 let kind = err.kind();
175 if !is_retryable(kind) || attempt >= cfg.max_retries {
176 return Err(err);
177 }
178 let delay = err.retry_after().filter(|d| !d.is_zero()).map_or_else(
183 || backoff_delay(attempt, cfg.base_delay, cfg.max_delay, clock.jitter_frac()),
184 |d| d.min(cfg.max_delay),
185 );
186 attempt += 1;
187 tracing::warn!(
188 attempt,
189 ?kind,
190 delay_ms = u64::try_from(delay.as_millis()).unwrap_or(u64::MAX),
191 "model call failed; retrying"
192 );
193 clock.sleep(delay).await;
194 }
195 }
196 }
197}
198
199#[cfg(test)]
200#[allow(clippy::pedantic, clippy::nursery, missing_docs)]
201mod tests {
202 use std::sync::atomic::{AtomicUsize, Ordering};
203
204 use async_trait::async_trait;
205 use futures::StreamExt as _;
206 use polyc_llm::error::DummyError;
207 use polyc_llm::{Chunk, CompletionRequest, LlmProvider, StopReason};
208
209 use super::*;
210
211 struct FlakyProvider {
213 calls: AtomicUsize,
214 fail_n: usize,
215 err: fn() -> DummyError,
216 }
217
218 #[async_trait]
219 impl LlmProvider for FlakyProvider {
220 type Error = DummyError;
221 async fn complete(
222 &self,
223 _req: CompletionRequest,
224 ) -> Result<futures::stream::BoxStream<'static, Result<Chunk, Self::Error>>, Self::Error>
225 {
226 let n = self.calls.fetch_add(1, Ordering::SeqCst);
227 if n < self.fail_n {
228 return Err((self.err)());
229 }
230 Ok(futures::stream::iter(vec![Ok(Chunk::Stop(StopReason::EndTurn))]).boxed())
231 }
232 }
233
234 fn fast_cfg(max_retries: u32) -> RetryConfig {
235 RetryConfig {
236 max_retries,
237 base_delay: Duration::from_millis(0),
238 max_delay: Duration::from_millis(0),
239 }
240 }
241
242 fn unavailable() -> DummyError {
243 DummyError::Transport("reset".to_owned())
244 }
245 fn bad_request() -> DummyError {
246 DummyError::Provider {
247 status: 400,
248 body: "nope".to_owned(),
249 }
250 }
251
252 #[tokio::test]
253 async fn retries_then_succeeds() {
254 let p = FlakyProvider {
255 calls: AtomicUsize::new(0),
256 fail_n: 2,
257 err: unavailable,
258 };
259 let out =
260 complete_with_retry(&p, CompletionRequest::new("m"), &fast_cfg(4), &RealClock).await;
261 assert!(out.is_ok(), "should succeed after 2 retries");
262 assert_eq!(p.calls.load(Ordering::SeqCst), 3, "2 failures + 1 success");
263 }
264
265 #[tokio::test]
266 async fn gives_up_after_budget() {
267 let p = FlakyProvider {
268 calls: AtomicUsize::new(0),
269 fail_n: 99,
270 err: unavailable,
271 };
272 let out =
273 complete_with_retry(&p, CompletionRequest::new("m"), &fast_cfg(3), &RealClock).await;
274 assert!(out.is_err(), "exhausts the budget");
275 assert_eq!(p.calls.load(Ordering::SeqCst), 4);
277 }
278
279 #[tokio::test]
280 async fn terminal_error_is_not_retried() {
281 let p = FlakyProvider {
282 calls: AtomicUsize::new(0),
283 fail_n: 99,
284 err: bad_request,
285 };
286 let out =
287 complete_with_retry(&p, CompletionRequest::new("m"), &fast_cfg(4), &RealClock).await;
288 assert!(out.is_err());
289 assert_eq!(p.calls.load(Ordering::SeqCst), 1, "bad-request is terminal");
290 }
291
292 #[test]
293 fn backoff_grows_and_caps() {
294 let base = Duration::from_millis(100);
295 let cap = Duration::from_millis(1000);
296 assert_eq!(backoff_delay(0, base, cap, 1.0), Duration::from_millis(100));
298 assert_eq!(backoff_delay(1, base, cap, 1.0), Duration::from_millis(200));
299 assert_eq!(backoff_delay(2, base, cap, 1.0), Duration::from_millis(400));
300 assert_eq!(
302 backoff_delay(4, base, cap, 1.0),
303 Duration::from_millis(1000)
304 );
305 assert_eq!(backoff_delay(1, base, cap, 0.0), Duration::from_millis(100));
307 }
308}