1use std::collections::HashMap;
17use std::time::{Duration, Instant};
18
19pub const MAX_ATTEMPTS: u32 = 3;
20const BASE_DELAY: Duration = Duration::from_secs(5);
21const 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 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 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 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
78pub 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
98const 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
102pub 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
116pub 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
125pub 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 { attempt: u32, delay: Duration },
150 Escalate { found: Found, attempts: u32 },
152 Limit { found: Found },
154}
155
156#[derive(Default)]
157struct Track {
158 attempts: u32,
159 last_error: Option<Instant>,
160 seen: Option<(String, usize)>,
162 last_prompt: Option<String>,
163 escalated: Option<String>,
164 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 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 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 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 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 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 None => a = Some(Action::Escalate { found, attempts: *attempt - 1 }),
291 }
292 }
293 a
294 }
295
296 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 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 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 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 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 t.turn_started(2, Some("hello"));
371 assert!(matches!(t.turn_ended(2, "", t0), Some(Action::Escalate { found: Found { class: Class::Unknown, .. }, .. })));
372 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 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}