Skip to main content

polyc_agent/
retry.rs

1//! Bounded retry for the model call with exponential backoff + jitter.
2//!
3//! The turn loop's `provider.complete(req)` is the connect/initial-response
4//! boundary: it returns `Err` for a refused connection, a 429, or an immediate
5//! 5xx *before* any chunk is yielded, so retrying it is safe (nothing has been
6//! forwarded to the client yet). A failure *during* streaming — after chunks may
7//! already have been forwarded — is the caller's and is NOT retried here.
8//!
9//! Only retryable kinds (rate-limit / timeout / unavailable) are retried; a
10//! terminal error (auth / bad-request) returns immediately. When the provider
11//! captured a server `Retry-After`, that wait is honored over the computed
12//! backoff.
13
14use std::time::{Duration, SystemTime, UNIX_EPOCH};
15
16use async_trait::async_trait;
17use polyc_llm::{Chunk, CompletionRequest, LlmError, LlmErrorKind, LlmProvider};
18
19/// The turn's clock and jitter source — its only non-determinism.
20///
21/// The retry backoff is the sole place the turn loop reads wall time (for jitter
22/// entropy) or waits (for the backoff), so routing both through an injected
23/// capability makes a whole turn deterministically replayable: production wires
24/// the real clock ([`RealClock`]) and behaves exactly as before, while a test
25/// wires a virtual clock with a fixed jitter seed and gets byte-identical output
26/// it can step without a wall-clock wait.
27#[async_trait]
28pub trait Clock: std::fmt::Debug + Send + Sync {
29    /// The current wall-clock time.
30    fn now(&self) -> SystemTime;
31
32    /// A jitter fraction in `[0, 1)` used to spread retries (equal jitter in
33    /// [`backoff_delay`]). The exact value only matters for the spread, so the
34    /// default derives it from [`Self::now`] — the same cheap clock read the
35    /// retry path used before the seam existed. A deterministic clock overrides
36    /// this with a seeded draw so a replay reproduces the same spacing.
37    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    /// Wait for `dur` before the caller retries.
46    ///
47    /// # Cancellation
48    ///
49    /// Cancellation-safe: dropping the returned future cancels the wait with no
50    /// observable effect, exactly like the underlying timer.
51    async fn sleep(&self, dur: Duration);
52}
53
54/// The production [`Clock`]: real wall time and a real timer.
55///
56/// [`Clock::now`] reads the system clock and [`Clock::sleep`] awaits a tokio
57/// timer, so the retry path spreads and waits exactly as it did before the seam
58/// was introduced.
59#[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/// How many times to retry, and the backoff envelope.
74#[derive(Debug, Clone, Copy)]
75pub struct RetryConfig {
76    /// Maximum retries *after* the first attempt (so `max_retries` of 4 ⇒ up to
77    /// 5 total calls).
78    pub max_retries: u32,
79    /// Base delay; the nth retry waits ~`base * 2^n` (jittered, capped).
80    pub base_delay: Duration,
81    /// Ceiling on a single backoff wait.
82    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    /// Load from the environment, falling back to [`Default`] for any unset or
97    /// unparseable value:
98    /// - `POLYCHROME_LLM_MAX_RETRIES`
99    /// - `POLYCHROME_LLM_RETRY_BASE_MS`
100    /// - `POLYCHROME_LLM_RETRY_MAX_MS`
101    #[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
114/// Parse an environment variable, treating unset OR unparseable as `None` (the
115/// caller falls back to a default) rather than erroring — a malformed override
116/// must degrade to the shipped default, never fail startup.
117///
118/// `pub(crate)` so other per-deployment env/config knobs in this crate (e.g.
119/// [`crate::resolve_max_steps`]) share the exact same "unset or unparseable ⇒
120/// default" resolution instead of re-deriving it.
121pub(crate) fn env_parse<T: std::str::FromStr>(key: &str) -> Option<T> {
122    std::env::var(key).ok()?.parse().ok()
123}
124
125/// Whether an error kind warrants a retry.
126const fn is_retryable(kind: LlmErrorKind) -> bool {
127    matches!(
128        kind,
129        LlmErrorKind::RateLimit | LlmErrorKind::Timeout | LlmErrorKind::Unavailable
130    )
131}
132
133/// Exponential backoff with equal jitter.
134///
135/// Half the delay is fixed and half is scaled by `jitter_frac` ∈ [0, 1), so the
136/// wait lands in `[0.5, 1.0] × base × 2^attempt` (capped). Pure, so callers and
137/// tests control the jitter.
138#[must_use]
139pub fn backoff_delay(attempt: u32, base: Duration, cap: Duration, jitter_frac: f64) -> Duration {
140    // `2^attempt`, saturating so a large attempt can't panic on shift overflow.
141    let factor = 1u32.checked_shl(attempt.min(16)).unwrap_or(u32::MAX);
142    let exp = base.saturating_mul(factor).min(cap);
143    // Equal jitter: 0.5 + 0.5*frac, written as a fused multiply-add.
144    let scale = 0.5_f64.mul_add(jitter_frac.clamp(0.0, 1.0), 0.5);
145    exp.mul_f64(scale)
146}
147
148/// Call `provider.complete`, retrying retryable failures up to `cfg.max_retries`
149/// with exponential backoff + jitter, honoring a server `Retry-After`
150/// ([`LlmError::retry_after`]) when present.
151///
152/// `clock` supplies the jitter entropy and the backoff wait; production passes
153/// [`RealClock`], so behavior is unchanged, while a test passes a virtual clock
154/// with a fixed seed to make the retry deterministic.
155///
156/// # Errors
157///
158/// Returns the provider's error once it is terminal (non-retryable) or the retry
159/// budget is exhausted.
160pub 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                // Honor a server `Retry-After` (capped), but fall back to
179                // computed backoff for a zero/absent value — a literal
180                // `Retry-After: 0` must NOT collapse the spacing into a tight,
181                // sleep-free retry loop against an already-rate-limited upstream.
182                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    /// Fails the first `fail_n` calls with `err`, then streams a one-chunk turn.
212    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        // initial attempt + 3 retries = 4 calls.
276        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        // jitter_frac = 1.0 → full delay (no shrink).
297        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        // 100 * 2^4 = 1600 → capped at 1000.
301        assert_eq!(
302            backoff_delay(4, base, cap, 1.0),
303            Duration::from_millis(1000)
304        );
305        // jitter_frac = 0.0 → half delay (equal-jitter floor).
306        assert_eq!(backoff_delay(1, base, cap, 0.0), Duration::from_millis(100));
307    }
308}