1use std::time::Duration;
15
16use polyc_llm::{Chunk, CompletionRequest, LlmError, LlmErrorKind, LlmProvider};
17
18#[derive(Debug, Clone, Copy)]
20pub struct RetryConfig {
21 pub max_retries: u32,
24 pub base_delay: Duration,
26 pub max_delay: Duration,
28}
29
30impl Default for RetryConfig {
31 fn default() -> Self {
32 Self {
33 max_retries: 4,
34 base_delay: Duration::from_millis(500),
35 max_delay: Duration::from_secs(30),
36 }
37 }
38}
39
40impl RetryConfig {
41 #[must_use]
47 pub fn from_env() -> Self {
48 let d = Self::default();
49 Self {
50 max_retries: env_parse("POLYCHROME_LLM_MAX_RETRIES").unwrap_or(d.max_retries),
51 base_delay: env_parse("POLYCHROME_LLM_RETRY_BASE_MS")
52 .map_or(d.base_delay, Duration::from_millis),
53 max_delay: env_parse("POLYCHROME_LLM_RETRY_MAX_MS")
54 .map_or(d.max_delay, Duration::from_millis),
55 }
56 }
57}
58
59fn env_parse<T: std::str::FromStr>(key: &str) -> Option<T> {
60 std::env::var(key).ok()?.parse().ok()
61}
62
63const fn is_retryable(kind: LlmErrorKind) -> bool {
65 matches!(
66 kind,
67 LlmErrorKind::RateLimit | LlmErrorKind::Timeout | LlmErrorKind::Unavailable
68 )
69}
70
71#[must_use]
77pub fn backoff_delay(attempt: u32, base: Duration, cap: Duration, jitter_frac: f64) -> Duration {
78 let factor = 1u32.checked_shl(attempt.min(16)).unwrap_or(u32::MAX);
80 let exp = base.saturating_mul(factor).min(cap);
81 let scale = 0.5_f64.mul_add(jitter_frac.clamp(0.0, 1.0), 0.5);
83 exp.mul_f64(scale)
84}
85
86fn jitter_frac() -> f64 {
89 use std::time::{SystemTime, UNIX_EPOCH};
90 let nanos = SystemTime::now()
91 .duration_since(UNIX_EPOCH)
92 .map_or(0, |d| d.subsec_nanos());
93 f64::from(nanos % 1_000_000) / 1_000_000.0
94}
95
96pub async fn complete_with_retry<P>(
105 provider: &P,
106 req: CompletionRequest,
107 cfg: &RetryConfig,
108) -> Result<futures::stream::BoxStream<'static, Result<Chunk, P::Error>>, P::Error>
109where
110 P: LlmProvider + ?Sized,
111{
112 let mut attempt = 0u32;
113 loop {
114 match provider.complete(req.clone()).await {
115 Ok(stream) => return Ok(stream),
116 Err(err) => {
117 let kind = err.kind();
118 if !is_retryable(kind) || attempt >= cfg.max_retries {
119 return Err(err);
120 }
121 let delay = err.retry_after().filter(|d| !d.is_zero()).map_or_else(
126 || backoff_delay(attempt, cfg.base_delay, cfg.max_delay, jitter_frac()),
127 |d| d.min(cfg.max_delay),
128 );
129 attempt += 1;
130 tracing::warn!(
131 attempt,
132 ?kind,
133 delay_ms = u64::try_from(delay.as_millis()).unwrap_or(u64::MAX),
134 "model call failed; retrying"
135 );
136 tokio::time::sleep(delay).await;
137 }
138 }
139 }
140}
141
142#[cfg(test)]
143#[allow(clippy::pedantic, clippy::nursery, missing_docs)]
144mod tests {
145 use std::sync::atomic::{AtomicUsize, Ordering};
146
147 use async_trait::async_trait;
148 use futures::StreamExt as _;
149 use polyc_llm::error::DummyError;
150 use polyc_llm::{Chunk, CompletionRequest, LlmProvider, StopReason};
151
152 use super::*;
153
154 struct FlakyProvider {
156 calls: AtomicUsize,
157 fail_n: usize,
158 err: fn() -> DummyError,
159 }
160
161 #[async_trait]
162 impl LlmProvider for FlakyProvider {
163 type Error = DummyError;
164 async fn complete(
165 &self,
166 _req: CompletionRequest,
167 ) -> Result<futures::stream::BoxStream<'static, Result<Chunk, Self::Error>>, Self::Error>
168 {
169 let n = self.calls.fetch_add(1, Ordering::SeqCst);
170 if n < self.fail_n {
171 return Err((self.err)());
172 }
173 Ok(futures::stream::iter(vec![Ok(Chunk::Stop(StopReason::EndTurn))]).boxed())
174 }
175 }
176
177 fn fast_cfg(max_retries: u32) -> RetryConfig {
178 RetryConfig {
179 max_retries,
180 base_delay: Duration::from_millis(0),
181 max_delay: Duration::from_millis(0),
182 }
183 }
184
185 fn unavailable() -> DummyError {
186 DummyError::Transport("reset".to_owned())
187 }
188 fn bad_request() -> DummyError {
189 DummyError::Provider {
190 status: 400,
191 body: "nope".to_owned(),
192 }
193 }
194
195 #[tokio::test]
196 async fn retries_then_succeeds() {
197 let p = FlakyProvider {
198 calls: AtomicUsize::new(0),
199 fail_n: 2,
200 err: unavailable,
201 };
202 let out = complete_with_retry(&p, CompletionRequest::new("m"), &fast_cfg(4)).await;
203 assert!(out.is_ok(), "should succeed after 2 retries");
204 assert_eq!(p.calls.load(Ordering::SeqCst), 3, "2 failures + 1 success");
205 }
206
207 #[tokio::test]
208 async fn gives_up_after_budget() {
209 let p = FlakyProvider {
210 calls: AtomicUsize::new(0),
211 fail_n: 99,
212 err: unavailable,
213 };
214 let out = complete_with_retry(&p, CompletionRequest::new("m"), &fast_cfg(3)).await;
215 assert!(out.is_err(), "exhausts the budget");
216 assert_eq!(p.calls.load(Ordering::SeqCst), 4);
218 }
219
220 #[tokio::test]
221 async fn terminal_error_is_not_retried() {
222 let p = FlakyProvider {
223 calls: AtomicUsize::new(0),
224 fail_n: 99,
225 err: bad_request,
226 };
227 let out = complete_with_retry(&p, CompletionRequest::new("m"), &fast_cfg(4)).await;
228 assert!(out.is_err());
229 assert_eq!(p.calls.load(Ordering::SeqCst), 1, "bad-request is terminal");
230 }
231
232 #[test]
233 fn backoff_grows_and_caps() {
234 let base = Duration::from_millis(100);
235 let cap = Duration::from_millis(1000);
236 assert_eq!(backoff_delay(0, base, cap, 1.0), Duration::from_millis(100));
238 assert_eq!(backoff_delay(1, base, cap, 1.0), Duration::from_millis(200));
239 assert_eq!(backoff_delay(2, base, cap, 1.0), Duration::from_millis(400));
240 assert_eq!(
242 backoff_delay(4, base, cap, 1.0),
243 Duration::from_millis(1000)
244 );
245 assert_eq!(backoff_delay(1, base, cap, 0.0), Duration::from_millis(100));
247 }
248}