1use serde::{Deserialize, Serialize};
19use serde_json::Value;
20
21#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
24#[serde(rename_all = "snake_case")]
25pub enum FailureClass {
26 RateLimited,
28 Overloaded,
30 UsageLimit,
32 Auth,
34 ContextExceeded,
36 MaxTurns,
38 Network,
40 Killed,
42 NoOutput,
44 SpawnFailure,
46 UnknownNonZero,
48}
49
50#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
54#[serde(rename_all = "camelCase")]
55pub struct AgentFailure {
56 pub code: FailureClass,
57 pub title: String,
58 pub detail: String,
59 #[serde(skip_serializing_if = "Option::is_none")]
60 pub exit_code: Option<i32>,
61 #[serde(skip_serializing_if = "Option::is_none")]
62 pub signal: Option<i32>,
63 #[serde(skip_serializing_if = "String::is_empty")]
64 pub stderr_tail: String,
65 pub retryable: bool,
66}
67
68impl AgentFailure {
69 pub fn explain(&self) -> String {
75 let mut s = self.title.clone();
76 if !self.detail.is_empty() {
77 s.push_str(" — ");
78 s.push_str(&self.detail);
79 }
80 match (self.signal, self.exit_code) {
81 (Some(sig), _) => s.push_str(&format!(" [signal {sig}]")),
82 (None, Some(code)) => s.push_str(&format!(" [exit {code}]")),
83 (None, None) => {}
84 }
85 if self.retryable {
86 s.push_str(" (retryable)");
87 }
88 if !self.stderr_tail.is_empty() {
89 s.push_str("\n--- claude stderr (tail) ---\n");
90 s.push_str(&self.stderr_tail);
91 }
92 s
93 }
94}
95
96pub fn classify(
104 exit_code: Option<i32>,
105 signal: Option<i32>,
106 stderr: &str,
107 result_frame: Option<&Value>,
108) -> AgentFailure {
109 let frame_reported_error = result_frame.is_some_and(is_error_result_frame);
110 let frame_text = result_frame.map(frame_error_text).unwrap_or_default();
111
112 let combined = if frame_text.is_empty() {
113 stderr.to_string()
114 } else {
115 format!("{stderr}\n{frame_text}")
116 };
117 let tail = tail_lines(&combined, 24, 1600);
118 let hay = combined.to_ascii_lowercase();
119
120 if result_frame
122 .and_then(|f| f.get("subtype"))
123 .and_then(|v| v.as_str())
124 .is_some_and(|s| s.contains("max_turns"))
125 {
126 return build(
127 FailureClass::MaxTurns,
128 "Hit the turn limit",
129 "The agent reached its configured `--max-turns` cap before finishing. Raise the cap or split the task.",
130 false,
131 exit_code,
132 signal,
133 &tail,
134 );
135 }
136
137 if signal.is_some() || exit_code == Some(137) {
139 return build(
140 FailureClass::Killed,
141 "Agent process was killed",
142 "Terminated by a signal — most often the OS out-of-memory killer or an external stop. Reduce concurrent load / memory pressure and retry.",
143 false,
144 exit_code,
145 signal,
146 &tail,
147 );
148 }
149
150 if hay.contains("rate limited")
154 || hay.contains("temporarily limiting requests")
155 || hay.contains("rate_limit")
156 || mentions_http_status(&hay, "429")
157 {
158 return build(
159 FailureClass::RateLimited,
160 "Rate-limited by the API",
161 "Server-side rate limit — transient, and not your account quota. Retry shortly; consider lowering parallelism.",
162 true,
163 exit_code,
164 signal,
165 &tail,
166 );
167 }
168 if hay.contains("overloaded") || mentions_http_status(&hay, "529") {
169 return build(
170 FailureClass::Overloaded,
171 "API temporarily overloaded",
172 "The API reported it is overloaded (HTTP 529). Transient — retry with backoff.",
173 true,
174 exit_code,
175 signal,
176 &tail,
177 );
178 }
179 if hay.contains("authentication_error")
180 || hay.contains("authentication_failed")
181 || hay.contains("invalid authentication")
182 || hay.contains("invalid api key")
183 || hay.contains("invalid x-api-key")
184 || hay.contains("unauthorized")
185 || hay.contains("please run /login")
186 || mentions_http_status(&hay, "401")
187 {
188 return build(
189 FailureClass::Auth,
190 "Not authenticated",
191 "The API rejected the credentials (HTTP 401). Re-authenticate via the Identity tab or `claude /login`.",
192 false,
193 exit_code,
194 signal,
195 &tail,
196 );
197 }
198 if hay.contains("model_context_window_exceeded")
199 || hay.contains("prompt is too long")
200 || (hay.contains("context") && (hay.contains("exceed") || hay.contains("window") || hay.contains("too long")))
201 {
202 return build(
203 FailureClass::ContextExceeded,
204 "Context window exceeded",
205 "The conversation exceeded the model's context window. Clear or compact the agent's history and retry.",
206 false,
207 exit_code,
208 signal,
209 &tail,
210 );
211 }
212 if hay.contains("max turns") || hay.contains("max-turns") || hay.contains("maximum number of turns") {
213 return build(
214 FailureClass::MaxTurns,
215 "Hit the turn limit",
216 "The agent reached its configured `--max-turns` cap before finishing. Raise the cap or split the task.",
217 false,
218 exit_code,
219 signal,
220 &tail,
221 );
222 }
223 if hay.contains("usage limit")
224 || hay.contains("out of credits")
225 || hay.contains("quota")
226 || hay.contains("billing")
227 || hay.contains("insufficient")
228 {
229 return build(
230 FailureClass::UsageLimit,
231 "Usage limit reached",
232 "Your plan or usage limit appears to be reached. Check plan / billing — retrying won't help until it resets.",
233 false,
234 exit_code,
235 signal,
236 &tail,
237 );
238 }
239 if hay.contains("apiconnectionerror")
240 || hay.contains("connection error")
241 || hay.contains("could not resolve")
242 || hay.contains("econnreset")
243 || hay.contains("network")
244 || hay.contains("timed out")
245 || hay.contains("timeout")
246 || hay.contains("dns")
247 {
248 return build(
249 FailureClass::Network,
250 "Network error reaching the API",
251 "A network/connection error occurred talking to the API. Check connectivity and retry.",
252 true,
253 exit_code,
254 signal,
255 &tail,
256 );
257 }
258
259 if frame_reported_error {
261 return build(
262 FailureClass::UnknownNonZero,
263 "Agent reported an error",
264 "The agent reported a terminal error with no recognized cause. The raw output tail is included below.",
265 false,
266 exit_code,
267 signal,
268 &tail,
269 );
270 }
271 if exit_code == Some(0) {
272 return build(
273 FailureClass::NoOutput,
274 "Agent produced no output",
275 "The agent exited cleanly (code 0) but never produced a final result. Inspect the transcript / sidecar log.",
276 false,
277 exit_code,
278 signal,
279 &tail,
280 );
281 }
282 build(
283 FailureClass::UnknownNonZero,
284 "Agent failed",
285 "The agent exited with a non-zero status and no recognized error. The raw stderr tail is included below.",
286 false,
287 exit_code,
288 signal,
289 &tail,
290 )
291}
292
293#[allow(clippy::too_many_arguments)]
294fn build(
295 code: FailureClass,
296 title: &str,
297 detail: &str,
298 retryable: bool,
299 exit_code: Option<i32>,
300 signal: Option<i32>,
301 tail: &str,
302) -> AgentFailure {
303 AgentFailure {
304 code,
305 title: title.to_string(),
306 detail: detail.to_string(),
307 exit_code,
308 signal,
309 stderr_tail: tail.to_string(),
310 retryable,
311 }
312}
313
314fn frame_error_text(frame: &Value) -> String {
317 if let Some(msg) = frame.pointer("/error/message").and_then(|v| v.as_str()) {
318 return msg.to_string();
319 }
320 if let Some(err) = frame.get("error").and_then(|v| v.as_str()) {
321 return err.to_string();
322 }
323 if let Some(res) = frame.get("result").and_then(|v| v.as_str()) {
324 return res.to_string();
325 }
326 frame
327 .get("subtype")
328 .and_then(|v| v.as_str())
329 .unwrap_or_default()
330 .to_string()
331}
332
333pub(crate) fn is_error_result_frame(frame: &Value) -> bool {
335 frame.get("is_error").and_then(|v| v.as_bool()).unwrap_or(false)
336 || frame
337 .get("subtype")
338 .and_then(|v| v.as_str())
339 .is_some_and(|s| s.starts_with("error"))
340}
341
342fn tail_lines(s: &str, max_lines: usize, max_chars: usize) -> String {
345 let mut lines: Vec<&str> = s
346 .lines()
347 .map(|l| l.trim_end())
348 .filter(|l| !l.is_empty())
349 .collect();
350 if lines.len() > max_lines {
351 lines = lines.split_off(lines.len() - max_lines);
352 }
353 let joined = lines.join("\n");
354 let n = joined.chars().count();
355 if n > max_chars {
356 joined.chars().skip(n - max_chars).collect()
357 } else {
358 joined
359 }
360}
361
362fn mentions_http_status(hay: &str, code: &str) -> bool {
367 [
368 format!("http {code}"),
369 format!("http/1.1 {code}"),
370 format!("http/2 {code}"),
371 format!("status {code}"),
372 format!("status: {code}"),
373 format!("status code {code}"),
374 format!("code {code}"),
375 format!("code: {code}"),
376 format!("error {code}"),
377 format!("error: {code}"),
378 format!("[{code}]"),
379 format!("({code})"),
380 ]
381 .iter()
382 .any(|p| hay.contains(p.as_str()))
383}
384
385#[cfg(test)]
386mod tests {
387 use super::*;
388 use serde_json::json;
389
390 #[test]
391 fn rate_limit_incident_string_classifies_retryable() {
392 let stderr =
394 "API Error: Server is temporarily limiting requests (not your usage limit) · Rate limited";
395 let f = classify(Some(1), None, stderr, None);
396 assert_eq!(f.code, FailureClass::RateLimited);
397 assert!(f.retryable);
398 let e = f.explain();
399 assert!(e.contains("Rate-limited"), "explain: {e}");
400 assert!(e.contains("retryable"), "explain: {e}");
401 assert!(e.to_lowercase().contains("rate limited"), "explain: {e}");
402 }
403
404 #[test]
405 fn not_your_usage_limit_does_not_classify_as_usage_limit() {
406 let f = classify(
409 Some(1),
410 None,
411 "temporarily limiting requests (not your usage limit) · Rate limited",
412 None,
413 );
414 assert_eq!(f.code, FailureClass::RateLimited);
415 }
416
417 #[test]
418 fn iso_date_in_stderr_does_not_false_positive() {
419 for stderr in [
423 "panic at 2026-04-29T10:00:00: unexpected eof",
424 "build 2026-04-01 failed: assertion",
425 "ts=2026-05-29 fatal: segfault",
426 ] {
427 let f = classify(Some(2), None, stderr, None);
428 assert_eq!(
429 f.code,
430 FailureClass::UnknownNonZero,
431 "stderr {stderr:?} must not match an HTTP status"
432 );
433 }
434 }
435
436 #[test]
437 fn anchored_http_status_still_classifies() {
438 assert_eq!(
439 classify(Some(1), None, "Error: HTTP 429 Too Many Requests", None).code,
440 FailureClass::RateLimited
441 );
442 assert_eq!(
443 classify(Some(1), None, "request failed with status 401", None).code,
444 FailureClass::Auth
445 );
446 }
447
448 #[test]
449 fn overloaded_is_retryable() {
450 let f = classify(Some(1), None, "overloaded_error: please retry", None);
451 assert_eq!(f.code, FailureClass::Overloaded);
452 assert!(f.retryable);
453 }
454
455 #[test]
456 fn invalid_api_key_is_auth_not_retryable() {
457 let f = classify(Some(1), None, "authentication_error: Invalid API key provided", None);
458 assert_eq!(f.code, FailureClass::Auth);
459 assert!(!f.retryable);
460 }
461
462 #[test]
463 fn sigkill_is_killed() {
464 let f = classify(None, Some(9), "", None);
465 assert_eq!(f.code, FailureClass::Killed);
466 assert_eq!(f.signal, Some(9));
467 assert!(f.explain().contains("[signal 9]"));
468 }
469
470 #[test]
471 fn exit_137_is_killed() {
472 let f = classify(Some(137), None, "", None);
473 assert_eq!(f.code, FailureClass::Killed);
474 }
475
476 #[test]
477 fn context_window_exceeded_classifies() {
478 let f = classify(
479 Some(1),
480 None,
481 "prompt is too long: 1200000 tokens > 200000 maximum context window",
482 None,
483 );
484 assert_eq!(f.code, FailureClass::ContextExceeded);
485 }
486
487 #[test]
488 fn network_error_is_retryable() {
489 let f = classify(Some(1), None, "APIConnectionError: Connection error.", None);
490 assert_eq!(f.code, FailureClass::Network);
491 assert!(f.retryable);
492 }
493
494 #[test]
495 fn clean_exit_no_output_is_no_output() {
496 let f = classify(Some(0), None, "", None);
497 assert_eq!(f.code, FailureClass::NoOutput);
498 assert!(!f.retryable);
499 }
500
501 #[test]
502 fn unknown_nonzero_is_default() {
503 let f = classify(Some(2), None, "something unexpected happened", None);
504 assert_eq!(f.code, FailureClass::UnknownNonZero);
505 assert!(f.explain().contains("[exit 2]"));
506 }
507
508 #[test]
509 fn authentication_failed_string_is_auth() {
510 let f = classify(
514 Some(0),
515 None,
516 "authentication_failed Failed to authenticate. API Error: 401 Invalid authentication credentials",
517 None,
518 );
519 assert_eq!(f.code, FailureClass::Auth);
520 assert!(!f.retryable);
521 }
522
523 #[test]
524 fn invalid_authentication_credentials_is_auth() {
525 let f = classify(Some(0), None, "invalid authentication credentials", None);
526 assert_eq!(f.code, FailureClass::Auth);
527 }
528
529 #[test]
530 fn api_error_colon_401_is_auth() {
531 let f = classify(Some(0), None, "api error: 401 invalid authentication credentials", None);
533 assert_eq!(f.code, FailureClass::Auth);
534 }
535
536 #[test]
537 fn result_frame_max_turns_subtype() {
538 let frame = json!({ "type": "result", "is_error": true, "subtype": "error_max_turns" });
539 let f = classify(Some(0), None, "", Some(&frame));
540 assert_eq!(f.code, FailureClass::MaxTurns);
541 }
542
543 #[test]
544 fn result_frame_error_message_feeds_classification() {
545 let frame = json!({
548 "type": "result",
549 "is_error": true,
550 "subtype": "error_during_execution",
551 "error": { "message": "overloaded_error: upstream busy" }
552 });
553 let f = classify(Some(0), None, "", Some(&frame));
554 assert_eq!(f.code, FailureClass::Overloaded);
555 }
556
557 #[test]
558 fn tail_lines_keeps_last_lines_char_safe() {
559 let s = (0..100).map(|i| format!("line{i}")).collect::<Vec<_>>().join("\n");
560 let t = tail_lines(&s, 5, 1000);
561 assert_eq!(t.lines().count(), 5);
562 assert!(t.contains("line99"));
563 assert!(!t.contains("line0\n"));
564 }
565
566 #[test]
567 fn serializes_snake_case_code_camel_fields() {
568 let f = classify(Some(1), None, "Rate limited", None);
569 let v = serde_json::to_value(&f).unwrap();
570 assert_eq!(v["code"], json!("rate_limited"));
571 assert_eq!(v["retryable"], json!(true));
572 assert!(v.get("stderrTail").is_some(), "camelCase stderrTail expected: {v}");
573 }
574}