Skip to main content

mecha_core/provider/
retry.rs

1//! Provider failure classification, and the retry policy over it.
2//!
3//! Any non-2xx used to bail straight out of both providers, which meant a
4//! single transient 429, a 529 overload, or a stale pooled connection killed
5//! the run — and in `batch` or `eval`, killed it in the middle of a fan-out
6//! that had already spent real time. Observed live, reproducibly: llama-server
7//! closes idle keep-alive connections, reqwest reuses one, and the write dies
8//! with "connection closed before message completed" on a request that would
9//! have succeeded one retry later.
10//!
11//! The load-bearing invariant: **a retry must never duplicate work.** Retrying
12//! the HTTP request is safe exactly when nothing of the attempt has been acted
13//! on — no tool has run, no delta has reached the front-end. So retries live
14//! at the request level, before the response body is consumed; once a
15//! streaming body is being read, a failure is not retried at all. Mid-stream
16//! errors therefore carry no [`ProviderError`] in their chain, which is also
17//! what tells the failover wrapper it must not re-issue them.
18
19use std::time::Duration;
20
21/// Why a provider call failed, coarsely enough to decide policy per class.
22///
23/// Classification is by status *and* by message text, because the text is
24/// sometimes the only signal — no backend gives context overflow a usable
25/// code, which is the lesson `is_context_overflow` already encodes.
26#[derive(Debug, Clone, PartialEq, Eq)]
27pub enum ProviderError {
28    /// HTTP 429 — `Retry-After` is honoured when it is sane; a provider can
29    /// name a wait long enough that the process is simply asleep.
30    RateLimit { retry_after: Option<Duration> },
31    /// The provider says it is drowning (529, or a 503 that says so).
32    Overloaded,
33    /// Any other 5xx.
34    ServerError,
35    /// 401/403 — terminal; the same key fails the same way every time.
36    Auth,
37    /// Credit exhausted — terminal, and retrying it spends nothing but time.
38    Billing,
39    /// The prompt does not fit. Never retried here: the compaction path in
40    /// the loop owns this one, and a retry with the same payload cannot fit
41    /// any better.
42    ContextOverflow,
43    /// Any other 4xx — the same payload fails the same way.
44    Invalid(String),
45    /// Connect failures, timeouts, aborted writes. The pooled-connection race
46    /// lands here.
47    Transport,
48}
49
50impl std::fmt::Display for ProviderError {
51    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
52        match self {
53            ProviderError::RateLimit {
54                retry_after: Some(d),
55            } => {
56                write!(f, "rate limited (retry after {}s)", d.as_secs())
57            }
58            ProviderError::RateLimit { retry_after: None } => write!(f, "rate limited"),
59            ProviderError::Overloaded => write!(f, "provider overloaded"),
60            ProviderError::ServerError => write!(f, "provider server error"),
61            ProviderError::Auth => write!(f, "authentication failed"),
62            ProviderError::Billing => write!(f, "billing/credit failure"),
63            ProviderError::ContextOverflow => write!(f, "prompt exceeds the context window"),
64            ProviderError::Invalid(detail) => write!(f, "invalid request: {detail}"),
65            ProviderError::Transport => write!(f, "transport failure"),
66        }
67    }
68}
69
70impl std::error::Error for ProviderError {}
71
72impl ProviderError {
73    /// Whether another *identical request* could plausibly succeed. This is
74    /// also what the failover wrapper keys on: an error without this property
75    /// fails the same way on every provider (`Invalid`, `ContextOverflow`) or
76    /// must not be retried at all.
77    pub fn transient(&self) -> bool {
78        matches!(
79            self,
80            ProviderError::RateLimit { .. }
81                | ProviderError::Overloaded
82                | ProviderError::ServerError
83                | ProviderError::Transport
84        )
85    }
86}
87
88/// Does this error text say the prompt did not fit? Shared with the loop's
89/// `is_context_overflow`, because no backend gives it a usable code:
90/// llama-server says `exceed_context_size_error`, vLLM says "maximum context
91/// length", Anthropic says "prompt is too long".
92pub fn overflow_text(text: &str) -> bool {
93    let t = text.to_ascii_lowercase();
94    t.contains("exceed_context_size")
95        || t.contains("context_length_exceeded")
96        || t.contains("context length")
97        || t.contains("context size")
98        || t.contains("prompt is too long")
99        || t.contains("too many tokens")
100        || t.contains("maximum context")
101}
102
103/// Classify a non-2xx response.
104pub fn classify_http(status: u16, body: &str, retry_after: Option<Duration>) -> ProviderError {
105    let lower = body.to_ascii_lowercase();
106    match status {
107        401 | 403 => ProviderError::Auth,
108        402 => ProviderError::Billing,
109        429 => ProviderError::RateLimit { retry_after },
110        529 => ProviderError::Overloaded,
111        503 if lower.contains("overload") => ProviderError::Overloaded,
112        // Before the 5xx arm: llama-server reports overflow as a *500* saying
113        // "Context size has been exceeded" (observed live). Classified as
114        // ServerError it would be retried with the same payload three times
115        // and then never reach the loop's compact-and-retry recovery.
116        _ if overflow_text(body) => ProviderError::ContextOverflow,
117        s if s >= 500 => ProviderError::ServerError,
118        _ if lower.contains("credit balance") || lower.contains("billing") => {
119            ProviderError::Billing
120        }
121        _ => ProviderError::Invalid(body.chars().take(200).collect()),
122    }
123}
124
125/// Per-request retry policy. Lives on the provider, built from its config.
126#[derive(Debug, Clone)]
127pub struct RetryPolicy {
128    /// Retries after the first attempt. 0 disables retrying entirely.
129    pub max_retries: u32,
130    /// A `Retry-After` above this is surfaced as a failure instead of slept
131    /// through — control has to return to a layer that could fall back.
132    pub retry_after_cap: Duration,
133    /// First backoff delay; doubles per attempt, capped at [`Self::MAX_DELAY`].
134    pub base_delay: Duration,
135}
136
137impl Default for RetryPolicy {
138    fn default() -> Self {
139        RetryPolicy {
140            max_retries: 3,
141            retry_after_cap: Duration::from_secs(60),
142            base_delay: Duration::from_millis(2_500),
143        }
144    }
145}
146
147impl RetryPolicy {
148    pub const MAX_DELAY: Duration = Duration::from_secs(30);
149
150    pub fn from_config(cfg: &crate::config::ProviderConfig) -> Self {
151        let d = RetryPolicy::default();
152        RetryPolicy {
153            max_retries: cfg.max_retries.unwrap_or(d.max_retries),
154            retry_after_cap: cfg
155                .retry_after_cap_secs
156                .map(Duration::from_secs)
157                .unwrap_or(d.retry_after_cap),
158            base_delay: d.base_delay,
159        }
160    }
161
162    /// How long to wait before retry number `attempt` (1-based), or `None`
163    /// for "do not retry" — exhausted, terminal class, or a `Retry-After`
164    /// past the cap.
165    pub fn delay_for(&self, error: &ProviderError, attempt: u32) -> Option<Duration> {
166        if attempt > self.max_retries || !error.transient() {
167            return None;
168        }
169        match error {
170            ProviderError::RateLimit {
171                retry_after: Some(after),
172            } => {
173                // Above the cap is a failure, not a nap: sleeping an hour on
174                // a header's say-so takes the process hostage.
175                (*after <= self.retry_after_cap).then_some(*after)
176            }
177            _ => {
178                let exp = self
179                    .base_delay
180                    .saturating_mul(1u32 << (attempt - 1).min(16));
181                Some(exp.min(Self::MAX_DELAY))
182            }
183        }
184    }
185}
186
187/// What a request died of, once the policy gave up on it.
188///
189/// The caller formats the user-facing message — each provider keeps its
190/// existing error shape, which the loop's overflow detection greps — and
191/// attaches [`RequestFailure::class`] underneath it so policy layers
192/// (failover, the loop) can match on the class instead of the prose.
193#[derive(Debug)]
194pub struct RequestFailure {
195    pub class: ProviderError,
196    /// HTTP status, when the failure got that far. `None` is transport.
197    pub status: Option<u16>,
198    /// The provider's error body, or the transport error text.
199    pub detail: String,
200}
201
202/// Send a request until it succeeds, the policy gives up, or the class is
203/// terminal. Retries cover the send and the status line only — the response
204/// body is never consumed here, so nothing of a retried attempt can have
205/// been shown or acted on, which is the invariant that makes the retry safe.
206pub async fn send_with_retry(
207    make_request: impl Fn() -> reqwest::RequestBuilder,
208    policy: &RetryPolicy,
209) -> Result<reqwest::Response, RequestFailure> {
210    let mut attempt = 0u32;
211    loop {
212        let failure = match make_request().send().await {
213            Ok(resp) if resp.status().is_success() => return Ok(resp),
214            Ok(resp) => {
215                let status = resp.status().as_u16();
216                let retry_after = resp
217                    .headers()
218                    .get(reqwest::header::RETRY_AFTER)
219                    .and_then(|v| v.to_str().ok())
220                    .and_then(|s| s.trim().parse::<u64>().ok())
221                    .map(Duration::from_secs);
222                let body = resp.text().await.unwrap_or_default();
223                RequestFailure {
224                    class: classify_http(status, &body, retry_after),
225                    status: Some(status),
226                    detail: body,
227                }
228            }
229            Err(e) => RequestFailure {
230                class: ProviderError::Transport,
231                status: None,
232                detail: e.to_string(),
233            },
234        };
235
236        attempt += 1;
237        match policy.delay_for(&failure.class, attempt) {
238            Some(delay) => {
239                tracing::warn!(
240                    error = %failure.class,
241                    attempt,
242                    delay_ms = delay.as_millis() as u64,
243                    "provider request failed; retrying"
244                );
245                tokio::time::sleep(delay).await;
246            }
247            None => return Err(failure),
248        }
249    }
250}
251
252#[cfg(test)]
253mod tests {
254    use super::*;
255
256    #[test]
257    fn each_class_gets_its_policy() {
258        let p = RetryPolicy {
259            base_delay: Duration::from_millis(10),
260            ..Default::default()
261        };
262
263        // Transient classes back off, doubling, capped.
264        for err in [
265            ProviderError::Overloaded,
266            ProviderError::ServerError,
267            ProviderError::Transport,
268        ] {
269            assert_eq!(p.delay_for(&err, 1), Some(Duration::from_millis(10)));
270            assert_eq!(p.delay_for(&err, 2), Some(Duration::from_millis(20)));
271            assert_eq!(p.delay_for(&err, 4), None, "exhausted past max_retries");
272        }
273
274        // Terminal classes never retry: the same payload fails the same way,
275        // and a retried 401 is a lockout risk, not a recovery.
276        for err in [
277            ProviderError::Auth,
278            ProviderError::Billing,
279            ProviderError::Invalid("x".into()),
280            ProviderError::ContextOverflow,
281        ] {
282            assert_eq!(p.delay_for(&err, 1), None);
283        }
284    }
285
286    #[test]
287    fn retry_after_is_honoured_when_sane_and_a_failure_when_hostile() {
288        let p = RetryPolicy::default();
289        let soon = ProviderError::RateLimit {
290            retry_after: Some(Duration::from_secs(3)),
291        };
292        assert_eq!(p.delay_for(&soon, 1), Some(Duration::from_secs(3)));
293
294        // An hour-long Retry-After would put the process to sleep past every
295        // budget; control must return to a layer that can decide.
296        let hostile = ProviderError::RateLimit {
297            retry_after: Some(Duration::from_secs(3_600)),
298        };
299        assert_eq!(p.delay_for(&hostile, 1), None);
300
301        let unstated = ProviderError::RateLimit { retry_after: None };
302        assert_eq!(p.delay_for(&unstated, 1), Some(p.base_delay));
303    }
304
305    #[test]
306    fn zero_max_retries_disables_retrying() {
307        let p = RetryPolicy {
308            max_retries: 0,
309            ..Default::default()
310        };
311        assert_eq!(p.delay_for(&ProviderError::Transport, 1), None);
312    }
313
314    #[test]
315    fn the_backoff_never_exceeds_the_ceiling() {
316        let p = RetryPolicy {
317            max_retries: 40,
318            ..Default::default()
319        };
320        assert_eq!(
321            p.delay_for(&ProviderError::Transport, 39),
322            Some(RetryPolicy::MAX_DELAY)
323        );
324    }
325
326    #[test]
327    fn classification_reads_status_and_text() {
328        use ProviderError::*;
329        assert_eq!(classify_http(401, "", None), Auth);
330        assert_eq!(classify_http(403, "", None), Auth);
331        assert_eq!(
332            classify_http(429, "", Some(Duration::from_secs(2))),
333            RateLimit {
334                retry_after: Some(Duration::from_secs(2))
335            }
336        );
337        assert_eq!(classify_http(529, "", None), Overloaded);
338        assert_eq!(
339            classify_http(503, "The server is overloaded", None),
340            Overloaded
341        );
342        assert_eq!(classify_http(500, "", None), ServerError);
343        assert_eq!(classify_http(503, "", None), ServerError);
344
345        // The text is sometimes the only signal.
346        assert_eq!(
347            classify_http(400, r#"{"type":"exceed_context_size_error"}"#, None),
348            ContextOverflow
349        );
350        // ...and it outranks the status class: llama-server reports overflow
351        // as a 500 (observed live). As ServerError it would be retried with
352        // the same payload and never reach compaction recovery.
353        assert_eq!(
354            classify_http(500, "Context size has been exceeded.", None),
355            ContextOverflow
356        );
357        assert_eq!(
358            classify_http(400, "Your credit balance is too low", None),
359            Billing
360        );
361        assert!(matches!(classify_http(400, "bad json", None), Invalid(_)));
362    }
363}