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;
15
16use polyc_llm::{Chunk, CompletionRequest, LlmError, LlmErrorKind, LlmProvider};
17
18/// How many times to retry, and the backoff envelope.
19#[derive(Debug, Clone, Copy)]
20pub struct RetryConfig {
21    /// Maximum retries *after* the first attempt (so `max_retries` of 4 ⇒ up to
22    /// 5 total calls).
23    pub max_retries: u32,
24    /// Base delay; the nth retry waits ~`base * 2^n` (jittered, capped).
25    pub base_delay: Duration,
26    /// Ceiling on a single backoff wait.
27    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    /// Load from the environment, falling back to [`Default`] for any unset or
42    /// unparseable value:
43    /// - `POLYCHROME_LLM_MAX_RETRIES`
44    /// - `POLYCHROME_LLM_RETRY_BASE_MS`
45    /// - `POLYCHROME_LLM_RETRY_MAX_MS`
46    #[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
63/// Whether an error kind warrants a retry.
64const fn is_retryable(kind: LlmErrorKind) -> bool {
65    matches!(
66        kind,
67        LlmErrorKind::RateLimit | LlmErrorKind::Timeout | LlmErrorKind::Unavailable
68    )
69}
70
71/// Exponential backoff with equal jitter.
72///
73/// Half the delay is fixed and half is scaled by `jitter_frac` ∈ [0, 1), so the
74/// wait lands in `[0.5, 1.0] × base × 2^attempt` (capped). Pure, so callers and
75/// tests control the jitter.
76#[must_use]
77pub fn backoff_delay(attempt: u32, base: Duration, cap: Duration, jitter_frac: f64) -> Duration {
78    // `2^attempt`, saturating so a large attempt can't panic on shift overflow.
79    let factor = 1u32.checked_shl(attempt.min(16)).unwrap_or(u32::MAX);
80    let exp = base.saturating_mul(factor).min(cap);
81    // Equal jitter: 0.5 + 0.5*frac, written as a fused multiply-add.
82    let scale = 0.5_f64.mul_add(jitter_frac.clamp(0.0, 1.0), 0.5);
83    exp.mul_f64(scale)
84}
85
86/// Cheap entropy for jitter — the exact value is irrelevant (it only spreads
87/// retries to avoid a thundering herd), so a clock read suffices.
88fn 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
96/// Call `provider.complete`, retrying retryable failures up to `cfg.max_retries`
97/// with exponential backoff + jitter, honoring a server `Retry-After`
98/// ([`LlmError::retry_after`]) when present.
99///
100/// # Errors
101///
102/// Returns the provider's error once it is terminal (non-retryable) or the retry
103/// budget is exhausted.
104pub 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                // Honor a server `Retry-After` (capped), but fall back to
122                // computed backoff for a zero/absent value — a literal
123                // `Retry-After: 0` must NOT collapse the spacing into a tight,
124                // sleep-free retry loop against an already-rate-limited upstream.
125                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    /// Fails the first `fail_n` calls with `err`, then streams a one-chunk turn.
155    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        // initial attempt + 3 retries = 4 calls.
217        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        // jitter_frac = 1.0 → full delay (no shrink).
237        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        // 100 * 2^4 = 1600 → capped at 1000.
241        assert_eq!(
242            backoff_delay(4, base, cap, 1.0),
243            Duration::from_millis(1000)
244        );
245        // jitter_frac = 0.0 → half delay (equal-jitter floor).
246        assert_eq!(backoff_delay(1, base, cap, 0.0), Duration::from_millis(100));
247    }
248}