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 ("];
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 = &lower[i.saturating_sub(6)..i];
68 if (400..600).contains(&n) && (before == b'(' || ctx.contains("http") || ctx.contains("status") || ctx.contains("code")) {
69 return Some(n);
70 }
71 }
72 }
73 None
74}
75
76pub fn classify_line(line: &str) -> Option<Found> {
78 let lower = line.to_lowercase();
79 let code = code_in(&lower);
80 let has = |l: &[&str]| l.iter().any(|p| lower.contains(p));
81 let class = if has(LIMIT) {
82 Class::Limit
83 } else if matches!(code, Some(429 | 500..=599)) || has(RETRYABLE) {
84 Class::Retryable
85 } else if matches!(code, Some(400..=499)) || has(FATAL) {
86 Class::Fatal
87 } else if has(ERRORISH) {
88 Class::Unknown
89 } else {
90 return None;
91 };
92 let hint: String = line.trim().trim_matches(|c: char| !c.is_alphanumeric() && c != ')').chars().take(160).collect();
93 Some(Found { class, code, hint })
94}
95
96const API_MARKERS: &[&str] = &["request failed", "api error", "http ", "overloaded", "stream disconnected", "too many requests", "usage limit", "you've hit your", "reached your limit", "quota exceeded"];
99
100pub fn classify_screen(screen: &str) -> Option<(Found, usize)> {
104 screen.lines().rev().filter(|l| !l.trim().is_empty()).take(12).enumerate().find_map(|(row, l)| {
105 let lower = l.to_lowercase();
106 let api = API_MARKERS.iter().any(|m| lower.contains(m)) || code_in(&lower).is_some_and(|c| lower.contains(&format!("({c})")));
107 if !api || l.len() > 300 {
108 return None;
109 }
110 classify_line(l).filter(|f| f.class != Class::Unknown).map(|f| (f, row))
111 })
112}
113
114pub fn classify_message(message: &str, vendor_code: Option<&str>) -> Found {
116 let joined = match vendor_code {
117 Some(v) => format!("{v}: {message}"),
118 None => message.to_owned(),
119 };
120 classify_line(&joined).unwrap_or(Found { class: Class::Unknown, code: None, hint: joined.chars().take(160).collect() })
121}
122
123pub fn backoff(attempt: u32) -> Duration {
124 BASE_DELAY * 2u32.pow(attempt.saturating_sub(1).min(6))
125}
126
127#[derive(Clone, Debug, PartialEq, Eq)]
128pub enum Retry {
129 Pty,
130 Acp { prompt: String },
131}
132
133#[derive(Clone, Debug, PartialEq, Eq)]
134pub enum Action {
135 Retry { attempt: u32, delay: Duration },
137 Escalate { found: Found, attempts: u32 },
139 Limit { found: Found },
141}
142
143#[derive(Default)]
144struct Track {
145 attempts: u32,
146 last_error: Option<Instant>,
147 seen: Option<(String, usize)>,
149 last_prompt: Option<String>,
150 escalated: Option<String>,
151}
152
153#[derive(Default)]
154pub struct Tracker {
155 sessions: HashMap<u64, Track>,
156 due: Vec<(u64, Instant, Retry, u32)>,
157}
158
159impl Tracker {
160 fn decide(t: &mut Track, found: Found, now: Instant) -> Option<Action> {
161 if t.last_error.is_some_and(|at| now.duration_since(at) > QUIET_RESET) {
162 t.attempts = 0;
163 t.escalated = None;
164 }
165 t.last_error = Some(now);
166 match found.class {
167 Class::Limit => Some(Action::Limit { found }),
168 Class::Retryable if t.attempts < MAX_ATTEMPTS => {
169 t.attempts += 1;
170 Some(Action::Retry { attempt: t.attempts, delay: backoff(t.attempts) })
171 }
172 _ => {
173 let key = format!("{}:{:?}", found.class.as_str(), found.code);
174 if t.escalated.as_deref() == Some(key.as_str()) {
175 return None;
176 }
177 t.escalated = Some(key);
178 Some(Action::Escalate { attempts: t.attempts, found })
179 }
180 }
181 }
182
183 pub fn observe_screen(&mut self, instance: u64, screen: &str, now: Instant) -> Option<Action> {
185 let t = self.sessions.entry(instance).or_default();
186 let Some((found, row)) = classify_screen(screen) else {
187 t.seen = None;
188 return None;
189 };
190 let fp = (found.hint.clone(), row);
191 if t.seen.as_ref() == Some(&fp) || t.seen.as_ref().is_some_and(|(h, r)| *h == fp.0 && *r < row) {
192 t.seen = Some(fp);
194 return None;
195 }
196 t.seen = Some(fp);
197 let a = Self::decide(t, found, now);
198 if let Some(Action::Retry { attempt, delay }) = &a {
199 self.due.push((instance, now + *delay, Retry::Pty, *attempt));
200 }
201 a
202 }
203
204 pub fn turn_started(&mut self, instance: u64, prompt: Option<&str>) {
205 if let Some(p) = prompt.filter(|p| !p.trim().is_empty()) {
206 self.sessions.entry(instance).or_default().last_prompt = Some(p.to_owned());
207 }
208 }
209
210 pub fn turn_ok(&mut self, instance: u64) {
212 if let Some(t) = self.sessions.get_mut(&instance) {
213 t.attempts = 0;
214 t.escalated = None;
215 }
216 }
217
218 pub fn observe_acp_error(&mut self, instance: u64, found: Found, now: Instant) -> Option<Action> {
219 let t = self.sessions.entry(instance).or_default();
220 let prompt = t.last_prompt.clone();
221 let mut a = Self::decide(t, found.clone(), now);
222 if let Some(Action::Retry { attempt, delay }) = &a {
223 match prompt {
224 Some(prompt) => self.due.push((instance, now + *delay, Retry::Acp { prompt }, *attempt)),
225 None => a = Some(Action::Escalate { found, attempts: *attempt - 1 }),
227 }
228 }
229 a
230 }
231
232 pub fn take_due(&mut self, now: Instant) -> Vec<(u64, Retry, u32)> {
234 let (ready, wait): (Vec<_>, Vec<_>) = std::mem::take(&mut self.due).into_iter().partition(|(_, at, _, _)| *at <= now);
235 self.due = wait;
236 ready.into_iter().map(|(i, _, r, n)| (i, r, n)).collect()
237 }
238
239 pub fn forget(&mut self, instance: u64) {
240 self.sessions.remove(&instance);
241 self.due.retain(|(i, ..)| *i != instance);
242 }
243}
244
245#[cfg(test)]
246mod tests {
247 use super::*;
248
249 #[test]
250 fn classes() {
251 let c = |s: &str| classify_line(s).map(|f| (f.class, f.code));
252 assert_eq!(c("Bad request (400): Request failed (HTTP 400)"), Some((Class::Fatal, Some(400))));
253 assert_eq!(c("Too Many Requests (429)"), Some((Class::Retryable, Some(429))));
254 assert_eq!(c("API Error: 529 overloaded_error"), Some((Class::Retryable, None)));
255 assert_eq!(c("Request failed (HTTP 503)"), Some((Class::Retryable, Some(503))));
256 assert_eq!(c("stream disconnected before completion"), Some((Class::Retryable, None)));
257 assert_eq!(c("You've hit your usage limit. Try again in 2 hours."), Some((Class::Limit, None)));
258 assert_eq!(c("Unauthorized (401)"), Some((Class::Fatal, Some(401))));
259 assert_eq!(c("prompt is too long: 210000 tokens"), Some((Class::Fatal, None)));
260 assert_eq!(c("Error: something odd"), Some((Class::Unknown, None)));
261 assert_eq!(c("fixed 400 tests in src/http"), None);
262 assert_eq!(c("> continue"), None);
263 assert_eq!(classify_screen("error: could not compile `x`\ntest foo ... timed out\n> "), None);
265 assert_eq!(classify_screen("Forbidden: path outside workspace"), None);
266 assert!(classify_screen("Bad request (400): Request failed (HTTP 400)").is_some());
267 }
268
269 #[test]
270 fn pty_retries_then_escalates() {
271 let mut t = Tracker::default();
272 let t0 = Instant::now();
273 let s = |n: usize| format!("{}Request failed (HTTP 503)\n> ", "work\n".repeat(n));
274 assert!(matches!(t.observe_screen(1, &s(1), t0), Some(Action::Retry { attempt: 1, .. })));
275 assert_eq!(t.observe_screen(1, &s(1), t0), None, "same line on screen is not a new error");
276 assert_eq!(t.take_due(t0).len(), 0);
277 assert_eq!(t.take_due(t0 + Duration::from_secs(6)).len(), 1);
278 assert_eq!(t.observe_screen(1, "> continue\nworking", t0), None);
279 assert!(matches!(t.observe_screen(1, &s(2), t0), Some(Action::Retry { attempt: 2, .. })));
280 t.observe_screen(1, "ok", t0);
281 assert!(matches!(t.observe_screen(1, &s(1), t0), Some(Action::Retry { attempt: 3, .. })));
282 t.observe_screen(1, "ok", t0);
283 assert!(matches!(t.observe_screen(1, &s(1), t0), Some(Action::Escalate { attempts: 3, .. })));
284 t.observe_screen(1, "ok", t0);
285 assert_eq!(t.observe_screen(1, &s(1), t0), None, "one escalation per class+code");
286 }
287
288 #[test]
289 fn fatal_escalates_at_once_and_acp_resends_prompt() {
290 let mut t = Tracker::default();
291 let t0 = Instant::now();
292 assert!(matches!(t.observe_screen(2, "Bad request (400): Request failed (HTTP 400)", t0), Some(Action::Escalate { attempts: 0, .. })));
293 t.turn_started(3, Some("do it"));
294 let f = classify_message("Internal server error", None);
295 assert!(matches!(t.observe_acp_error(3, f, t0), Some(Action::Retry { attempt: 1, .. })));
296 let due = t.take_due(t0 + Duration::from_secs(5));
297 assert_eq!(due, vec![(3, Retry::Acp { prompt: "do it".into() }, 1)]);
298 t.turn_ok(3);
299 assert!(matches!(t.observe_acp_error(4, classify_message("Internal server error", None), t0), Some(Action::Escalate { .. })), "no prompt to resend");
300 }
301}