Skip to main content

hatchery_node/
provider_error.rs

1//! Provider error classifier and retry tracker (SCOPE: "Provider errors: classify, retry, escalate").
2//!
3//! Inputs: the rendered PTY screen of a session and ACP turn errors. Four classes:
4//! - `retryable`: 429 / 5xx / overloaded / timeout / dropped stream. The node retries the turn
5//!   itself with exponential backoff, up to [`MAX_ATTEMPTS`] (PTY: types "continue"; ACP: the
6//!   last prompt again).
7//! - `limit`: a 5h / 7d window is spent. No retry; the existing `limit` session signal carries
8//!   the reset hint and the task manager pauses or moves the work.
9//! - `fatal`: 400 bad request, auth, context too long. No retry.
10//! - `unknown`: an error-looking line we cannot place. No retry.
11//! Only `fatal`, `unknown` and `retryable` with attempts spent escalate (a `provider_error`
12//! session signal to core → one HQ-ERROR letter, the run's card to blocked). Retries themselves
13//! go to the node log only (target `provider_retry`). Error text is clipped and never carries
14//! prompt or screen content beyond the matching line.
15
16use std::collections::HashMap;
17use std::time::{Duration, Instant};
18
19pub const MAX_ATTEMPTS: u32 = 3;
20const BASE_DELAY: Duration = Duration::from_secs(5);
21/// A session that shows no error for this long starts its attempt count over.
22const QUIET_RESET: Duration = Duration::from_secs(120);
23
24#[derive(Clone, Copy, Debug, PartialEq, Eq)]
25pub enum Class {
26    Retryable,
27    Limit,
28    Fatal,
29    Unknown,
30}
31
32impl Class {
33    pub fn as_str(self) -> &'static str {
34        match self {
35            Class::Retryable => "retryable",
36            Class::Limit => "limit",
37            Class::Fatal => "fatal",
38            Class::Unknown => "unknown",
39        }
40    }
41}
42
43#[derive(Clone, Debug, PartialEq, Eq)]
44pub struct Found {
45    pub class: Class,
46    pub code: Option<u16>,
47    /// The matching line, trimmed and clipped.
48    pub hint: String,
49}
50
51const LIMIT: &[&str] = &["usage limit", "limit reached", "reached your limit", "you've hit your", "you have hit your", "quota exceeded", "exceeded your current quota", "out of credits", "weekly limit", "5-hour limit", "usagelimitexceeded"];
52const RETRYABLE: &[&str] = &["too many requests", "rate limited", "rate limit", "overloaded", "internal server error", "bad gateway", "service unavailable", "gateway timeout", "request timed out", "timed out", "server error", "stream disconnected", "stream error", "connection reset", "connection closed", "error decoding response body", "unexpected eof"];
53const FATAL: &[&str] = &["bad request", "unauthorized", "forbidden", "invalid api key", "authentication", "not logged in", "please log in", "payment required", "request too large", "payload too large", "context length", "context window", "prompt is too long", "too many tokens", "maximum context", "is not in your available models", "not found (", "model is not supported", "is not supported when using", "unsupported model", "model_not_found", "does not exist or you do not have access", "not supported when using", "issue with the selected model"];
54const ERRORISH: &[&str] = &["error:", "error (", "request failed", "api error", "failed ("];
55
56fn code_in(lower: &str) -> Option<u16> {
57    // "(400)", "HTTP 400", "status 400", "code 400"
58    let b = lower.as_bytes();
59    for i in 0..b.len().saturating_sub(2) {
60        if b[i].is_ascii_digit() && b[i + 1].is_ascii_digit() && b[i + 2].is_ascii_digit() {
61            let before = if i > 0 { b[i - 1] } else { b' ' };
62            let after = b.get(i + 3).copied().unwrap_or(b' ');
63            if before.is_ascii_alphanumeric() || after.is_ascii_alphanumeric() {
64                continue;
65            }
66            let n: u16 = lower[i..i + 3].parse().ok()?;
67            // Byte window before the number (a char-boundary-safe slice of a multibyte line).
68            let ctx = &b[i.saturating_sub(8)..i];
69            let has = |w: &[u8]| ctx.windows(w.len()).any(|x| x == w);
70            if (400..600).contains(&n) && (before == b'(' || has(b"http") || has(b"status") || has(b"code")) {
71                return Some(n);
72            }
73        }
74    }
75    None
76}
77
78/// Classify one line of provider output.
79pub fn classify_line(line: &str) -> Option<Found> {
80    let lower = line.to_lowercase();
81    let code = code_in(&lower);
82    let has = |l: &[&str]| l.iter().any(|p| lower.contains(p));
83    let class = if has(LIMIT) {
84        Class::Limit
85    } else if matches!(code, Some(429 | 500..=599)) || has(RETRYABLE) {
86        Class::Retryable
87    } else if matches!(code, Some(400..=499)) || has(FATAL) {
88        Class::Fatal
89    } else if has(ERRORISH) {
90        Class::Unknown
91    } else {
92        return None;
93    };
94    let hint: String = line.trim().trim_matches(|c: char| !c.is_alphanumeric() && c != ')').chars().take(160).collect();
95    Some(Found { class, code, hint })
96}
97
98/// Markers that tie a screen line to the provider's own API call (not to tool output such as a
99/// compiler "error:" or a test's "timed out" the agent is showing).
100const API_MARKERS: &[&str] = &["unexpected status", "is not supported when using", "model is not supported", "request failed", "api error", "http ", "overloaded", "stream disconnected", "too many requests", "usage limit", "you've hit your", "reached your limit", "quota exceeded", "not supported when using", "issue with the selected model"];
101
102/// The newest provider-API error on a screen (bottom-up, last 12 non-empty lines) and its row
103/// from the bottom. Stricter than [`classify_line`]: the line must carry an HTTP-like code in
104/// parentheses or an API marker, and `unknown` is never taken from a screen.
105pub fn classify_screen(screen: &str) -> Option<(Found, usize)> {
106    screen.lines().rev().filter(|l| !l.trim().is_empty()).take(12).enumerate().find_map(|(row, l)| {
107        let lower = l.to_lowercase();
108        let api = API_MARKERS.iter().any(|m| lower.contains(m)) || code_in(&lower).is_some_and(|c| lower.contains(&format!("({c})")));
109        if !api || l.len() > 300 {
110            return None;
111        }
112        classify_line(l).filter(|f| f.class != Class::Unknown).map(|f| (f, row))
113    })
114}
115
116/// Classify an ACP turn error (message plus the vendor code when present).
117pub fn classify_message(message: &str, vendor_code: Option<&str>) -> Found {
118    let joined = match vendor_code {
119        Some(v) => format!("{v}: {message}"),
120        None => message.to_owned(),
121    };
122    classify_line(&joined).unwrap_or(Found { class: Class::Unknown, code: None, hint: joined.chars().take(160).collect() })
123}
124
125/// Codex reports a spent account window as a bare JSON-RPC `-32603 Internal error`. When a
126/// fresh limit reading (heartbeat or a `/status` screen, at most a few minutes old) puts the
127/// 5h window at ~100%, such an otherwise unknown error is the limit.
128pub fn limit_from_window(found: Found, vendor_code: Option<&str>, window_used: Option<f64>) -> Found {
129    let internal = vendor_code == Some("-32603") || found.hint.to_lowercase().contains("internal error");
130    match window_used {
131        Some(u) if found.class == Class::Unknown && internal && u >= 98.0 => Found { class: Class::Limit, code: found.code, hint: format!("{} (5h window {u:.0}% used)", found.hint).chars().take(160).collect() },
132        _ => found,
133    }
134}
135
136pub fn backoff(attempt: u32) -> Duration {
137    BASE_DELAY * 2u32.pow(attempt.saturating_sub(1).min(6))
138}
139
140#[derive(Clone, Debug, PartialEq, Eq)]
141pub enum Retry {
142    Pty,
143    Acp { prompt: String },
144}
145
146#[derive(Clone, Debug, PartialEq, Eq)]
147pub enum Action {
148    /// Retry at `due` (attempt N of MAX_ATTEMPTS).
149    Retry { attempt: u32, delay: Duration },
150    /// Escalate to core (fatal/unknown, or retryable with attempts spent).
151    Escalate { found: Found, attempts: u32 },
152    /// Limit: no retry here; the `limit` signal path covers it.
153    Limit { found: Found },
154}
155
156#[derive(Default)]
157struct Track {
158    attempts: u32,
159    last_error: Option<Instant>,
160    /// Screen fingerprint of the error already handled (line + row from bottom).
161    seen: Option<(String, usize)>,
162    last_prompt: Option<String>,
163    escalated: Option<String>,
164    /// Current ACP turn: tail of the agent text and the tool calls seen.
165    turn_text: String,
166    turn_tools: u32,
167}
168
169const TURN_TEXT_TAIL: usize = 4096;
170
171#[derive(Default)]
172pub struct Tracker {
173    sessions: HashMap<u64, Track>,
174    due: Vec<(u64, Instant, Retry, u32)>,
175}
176
177impl Tracker {
178    fn decide(t: &mut Track, found: Found, now: Instant) -> Option<Action> {
179        if t.last_error.is_some_and(|at| now.duration_since(at) > QUIET_RESET) {
180            t.attempts = 0;
181            t.escalated = None;
182        }
183        t.last_error = Some(now);
184        match found.class {
185            Class::Limit => Some(Action::Limit { found }),
186            Class::Retryable if t.attempts < MAX_ATTEMPTS => {
187                t.attempts += 1;
188                Some(Action::Retry { attempt: t.attempts, delay: backoff(t.attempts) })
189            }
190            _ => {
191                let key = format!("{}:{:?}", found.class.as_str(), found.code);
192                if t.escalated.as_deref() == Some(key.as_str()) {
193                    return None;
194                }
195                t.escalated = Some(key);
196                Some(Action::Escalate { attempts: t.attempts, found })
197            }
198        }
199    }
200
201    /// Feed a PTY screen. Acts only when a new error line shows up.
202    pub fn observe_screen(&mut self, instance: u64, screen: &str, now: Instant) -> Option<Action> {
203        let t = self.sessions.entry(instance).or_default();
204        let Some((found, row)) = classify_screen(screen) else {
205            t.seen = None;
206            return None;
207        };
208        let fp = (found.hint.clone(), row);
209        if t.seen.as_ref() == Some(&fp) || t.seen.as_ref().is_some_and(|(h, r)| *h == fp.0 && *r < row) {
210            // Same line still on screen (or scrolled up): already handled.
211            t.seen = Some(fp);
212            return None;
213        }
214        t.seen = Some(fp);
215        let a = Self::decide(t, found, now);
216        if let Some(Action::Retry { attempt, delay }) = &a {
217            self.due.push((instance, now + *delay, Retry::Pty, *attempt));
218        }
219        a
220    }
221
222    pub fn turn_started(&mut self, instance: u64, prompt: Option<&str>) {
223        let t = self.sessions.entry(instance).or_default();
224        t.turn_text.clear();
225        t.turn_tools = 0;
226        if let Some(p) = prompt.filter(|p| !p.trim().is_empty()) {
227            t.last_prompt = Some(p.to_owned());
228        }
229    }
230
231    /// ACP agent text of the current turn (only the tail is kept).
232    pub fn turn_text(&mut self, instance: u64, text: &str, is_delta: bool) {
233        let t = self.sessions.entry(instance).or_default();
234        if !is_delta {
235            t.turn_text.clear();
236        }
237        t.turn_text.push_str(text);
238        if t.turn_text.len() > TURN_TEXT_TAIL {
239            let mut cut = t.turn_text.len() - TURN_TEXT_TAIL;
240            while !t.turn_text.is_char_boundary(cut) {
241                cut += 1;
242            }
243            t.turn_text.drain(..cut);
244        }
245    }
246
247    pub fn turn_tool(&mut self, instance: u64) {
248        self.sessions.entry(instance).or_default().turn_tools += 1;
249    }
250
251    /// An ACP turn that ended without an error flag. Some providers (codex over ACP) report a
252    /// rejected request (e.g. 400 "model is not supported") as the turn's only text, or end the
253    /// turn with nothing at all. A tool-less turn whose text (or result) is a provider-API error
254    /// line is treated as that error; a turn with no text, no result and no tool calls after a
255    /// real prompt escalates as `unknown`. Otherwise the count resets as before.
256    pub fn turn_ended(&mut self, instance: u64, result: &str, now: Instant) -> Option<Action> {
257        let t = self.sessions.entry(instance).or_default();
258        let text = std::mem::take(&mut t.turn_text);
259        let tools = std::mem::replace(&mut t.turn_tools, 0);
260        if tools == 0 {
261            let src = if text.trim().is_empty() { result } else { text.as_str() };
262            if let Some((found, _)) = classify_screen(src) {
263                return self.observe_acp_error(instance, found, now);
264            }
265            if src.trim().is_empty() && t.last_prompt.is_some() {
266                let found = Found { class: Class::Unknown, code: None, hint: "turn ended with no output and no tool calls".into() };
267                return Self::decide(t, found, now);
268            }
269        }
270        self.turn_ok(instance);
271        None
272    }
273
274    /// A clean ACP turn end resets the count.
275    pub fn turn_ok(&mut self, instance: u64) {
276        if let Some(t) = self.sessions.get_mut(&instance) {
277            t.attempts = 0;
278            t.escalated = None;
279        }
280    }
281
282    pub fn observe_acp_error(&mut self, instance: u64, found: Found, now: Instant) -> Option<Action> {
283        let t = self.sessions.entry(instance).or_default();
284        let prompt = t.last_prompt.clone();
285        let mut a = Self::decide(t, found.clone(), now);
286        if let Some(Action::Retry { attempt, delay }) = &a {
287            match prompt {
288                Some(prompt) => self.due.push((instance, now + *delay, Retry::Acp { prompt }, *attempt)),
289                // Nothing to resend: escalate instead of guessing.
290                None => a = Some(Action::Escalate { found, attempts: *attempt - 1 }),
291            }
292        }
293        a
294    }
295
296    /// Retries whose pause is over.
297    pub fn take_due(&mut self, now: Instant) -> Vec<(u64, Retry, u32)> {
298        let (ready, wait): (Vec<_>, Vec<_>) = std::mem::take(&mut self.due).into_iter().partition(|(_, at, _, _)| *at <= now);
299        self.due = wait;
300        ready.into_iter().map(|(i, _, r, n)| (i, r, n)).collect()
301    }
302
303    pub fn forget(&mut self, instance: u64) {
304        self.sessions.remove(&instance);
305        self.due.retain(|(i, ..)| *i != instance);
306    }
307}
308
309#[cfg(test)]
310mod tests {
311    #[test]
312    fn codex_internal_error_at_full_window_is_limit() {
313        let f = classify_message("Internal error", Some("-32603"));
314        assert_eq!(f.class, Class::Unknown);
315        assert_eq!(limit_from_window(f.clone(), Some("-32603"), Some(100.0)).class, Class::Limit);
316        assert_eq!(limit_from_window(f.clone(), Some("-32603"), Some(60.0)).class, Class::Unknown);
317        assert_eq!(limit_from_window(f, Some("-32603"), None).class, Class::Unknown);
318        let fatal = classify_message("model is not supported", None);
319        assert_eq!(limit_from_window(fatal, Some("-32603"), Some(100.0)).class, Class::Fatal);
320    }
321
322    use super::*;
323
324    #[test]
325    fn classes() {
326        let c = |s: &str| classify_line(s).map(|f| (f.class, f.code));
327        assert_eq!(c("Bad request (400): Request failed (HTTP 400)"), Some((Class::Fatal, Some(400))));
328        assert_eq!(c("Too Many Requests (429)"), Some((Class::Retryable, Some(429))));
329        assert_eq!(c("API Error: 529 overloaded_error"), Some((Class::Retryable, None)));
330        assert_eq!(c("Request failed (HTTP 503)"), Some((Class::Retryable, Some(503))));
331        assert_eq!(c("stream disconnected before completion"), Some((Class::Retryable, None)));
332        assert_eq!(c("You've hit your usage limit. Try again in 2 hours."), Some((Class::Limit, None)));
333        assert_eq!(c("Unauthorized (401)"), Some((Class::Fatal, Some(401))));
334        assert_eq!(c("prompt is too long: 210000 tokens"), Some((Class::Fatal, None)));
335        assert_eq!(c("Error: something odd"), Some((Class::Unknown, None)));
336        assert_eq!(c("fixed 400 tests in src/http"), None);
337        assert_eq!(c("> continue"), None);
338        // Screens: tool output is not a provider error.
339        assert_eq!(classify_screen("error: could not compile `x`\ntest foo ... timed out\n> "), None);
340        assert_eq!(classify_screen("Forbidden: path outside workspace"), None);
341        assert!(classify_screen("Bad request (400): Request failed (HTTP 400)").is_some());
342        // A model the account may not use is fatal (core keeps it out of routing).
343        let codex = r#"■ unexpected status 400 Bad Request: {"detail":"The 'gpt-6.1-sol' model is not supported when using Codex with a ChatGPT account."}"#;
344        assert_eq!(classify_screen(codex).map(|f| f.0.class), Some(Class::Fatal));
345        assert!(classify_screen(codex).unwrap().0.hint.contains("gpt-6.1-sol"));
346        assert_eq!(classify_message("There's an issue with the selected model (x-1). It may not exist or you may not have access to it.", None).class, Class::Fatal);
347        let codex = r#"unexpected status 400 Bad Request: {"detail":"The 'gpt-x' model is not supported when using Codex with a ChatGPT account."}"#;
348        assert_eq!(classify_line(codex).map(|f| (f.class, f.code)), Some((Class::Fatal, Some(400))));
349        assert!(classify_screen(codex).is_some());
350        assert_eq!(classify_message("You've hit your session limit · resets 1:40am (America/New_York)", Some("-32603")).class, Class::Limit);
351    }
352
353    #[test]
354    fn acp_turn_text_error_and_empty_turn() {
355        let mut t = Tracker::default();
356        let t0 = Instant::now();
357        // codex: the 400 arrives as the only text of a clean turn.
358        t.turn_started(1, Some("task 27"));
359        t.turn_text(1, "unexpected status 400 Bad Request: ", true);
360        t.turn_text(1, "{\"detail\":\"The 'gpt-x' model is not supported when using Codex with a ChatGPT account.\"}", true);
361        match t.turn_ended(1, "", t0) {
362            Some(Action::Escalate { found, .. }) => assert_eq!((found.class, found.code), (Class::Fatal, Some(400))),
363            other => panic!("expected fatal escalation, got {other:?}"),
364        }
365        // Same error next turn: escalated once only.
366        t.turn_started(1, Some("again"));
367        t.turn_text(1, "unexpected status 400 Bad Request: the model is not supported when using Codex", true);
368        assert_eq!(t.turn_ended(1, "", t0), None);
369        // Empty turn after a real prompt.
370        t.turn_started(2, Some("hello"));
371        assert!(matches!(t.turn_ended(2, "", t0), Some(Action::Escalate { found: Found { class: Class::Unknown, .. }, .. })));
372        // A normal turn: tools ran, error-looking text is the agent quoting a log.
373        t.turn_started(3, Some("fix"));
374        t.turn_tool(3);
375        t.turn_text(3, "the log says: Request failed (HTTP 503)", true);
376        assert_eq!(t.turn_ended(3, "", t0), None);
377        // A plain answer.
378        t.turn_started(4, Some("hi"));
379        t.turn_text(4, "Done, all tests pass.", false);
380        assert_eq!(t.turn_ended(4, "", t0), None);
381    }
382
383    #[test]
384    fn pty_retries_then_escalates() {
385        let mut t = Tracker::default();
386        let t0 = Instant::now();
387        let s = |n: usize| format!("{}Request failed (HTTP 503)\n> ", "work\n".repeat(n));
388        assert!(matches!(t.observe_screen(1, &s(1), t0), Some(Action::Retry { attempt: 1, .. })));
389        assert_eq!(t.observe_screen(1, &s(1), t0), None, "same line on screen is not a new error");
390        assert_eq!(t.take_due(t0).len(), 0);
391        assert_eq!(t.take_due(t0 + Duration::from_secs(6)).len(), 1);
392        assert_eq!(t.observe_screen(1, "> continue\nworking", t0), None);
393        assert!(matches!(t.observe_screen(1, &s(2), t0), Some(Action::Retry { attempt: 2, .. })));
394        t.observe_screen(1, "ok", t0);
395        assert!(matches!(t.observe_screen(1, &s(1), t0), Some(Action::Retry { attempt: 3, .. })));
396        t.observe_screen(1, "ok", t0);
397        assert!(matches!(t.observe_screen(1, &s(1), t0), Some(Action::Escalate { attempts: 3, .. })));
398        t.observe_screen(1, "ok", t0);
399        assert_eq!(t.observe_screen(1, &s(1), t0), None, "one escalation per class+code");
400    }
401
402    #[test]
403    fn fatal_escalates_at_once_and_acp_resends_prompt() {
404        let mut t = Tracker::default();
405        let t0 = Instant::now();
406        assert!(matches!(t.observe_screen(2, "Bad request (400): Request failed (HTTP 400)", t0), Some(Action::Escalate { attempts: 0, .. })));
407        t.turn_started(3, Some("do it"));
408        let f = classify_message("Internal server error", None);
409        assert!(matches!(t.observe_acp_error(3, f, t0), Some(Action::Retry { attempt: 1, .. })));
410        let due = t.take_due(t0 + Duration::from_secs(5));
411        assert_eq!(due, vec![(3, Retry::Acp { prompt: "do it".into() }, 1)]);
412        t.turn_ok(3);
413        assert!(matches!(t.observe_acp_error(4, classify_message("Internal server error", None), t0), Some(Action::Escalate { .. })), "no prompt to resend");
414    }
415}