Skip to content

Commit d1a2fa6

Browse files
committed
fix(agent): add graceful API failure handling for non-recoverable errors
Agent now immediately exits with clear guidance when encountering billing/auth issues instead of timing out. Adds ApiErrorKind enum and classify_api_error() to distinguish transient vs non-recoverable errors. Closes: fluent_cli-9ry
1 parent bea6631 commit d1a2fa6

1 file changed

Lines changed: 244 additions & 1 deletion

File tree

crates/fluent-cli/src/agentic.rs

Lines changed: 244 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,96 @@ use tracing::{debug, error, info, warn};
1818

1919
use crate::tui::{AgentStatus, TuiManager};
2020

21+
/// Classification of API errors for graceful handling
22+
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
23+
pub enum ApiErrorKind {
24+
/// Non-recoverable errors (billing, auth) - exit immediately
25+
NonRecoverable,
26+
/// Transient errors (network, rate limit) - may retry
27+
Transient,
28+
/// Unknown errors - treat as transient
29+
Unknown,
30+
}
31+
32+
/// Check if an error message indicates a non-recoverable API error
33+
///
34+
/// Non-recoverable errors include:
35+
/// - Billing/credit issues (e.g., "credit balance is too low")
36+
/// - Authentication failures (e.g., "invalid API key", "unauthorized")
37+
/// - Account issues (e.g., "account suspended")
38+
///
39+
/// These errors should cause immediate exit rather than continuing to retry.
40+
pub fn classify_api_error(error_msg: &str) -> ApiErrorKind {
41+
let lower = error_msg.to_lowercase();
42+
43+
// Billing/credit issues - non-recoverable
44+
if lower.contains("credit balance")
45+
|| lower.contains("billing")
46+
|| lower.contains("payment")
47+
|| lower.contains("quota exceeded")
48+
|| lower.contains("insufficient funds")
49+
|| lower.contains("purchase credits")
50+
{
51+
return ApiErrorKind::NonRecoverable;
52+
}
53+
54+
// Authentication issues - non-recoverable
55+
if lower.contains("invalid api key")
56+
|| lower.contains("invalid_api_key")
57+
|| lower.contains("unauthorized")
58+
|| lower.contains("authentication failed")
59+
|| lower.contains("invalid bearer token")
60+
|| lower.contains("api key not found")
61+
|| lower.contains("permission denied")
62+
{
63+
return ApiErrorKind::NonRecoverable;
64+
}
65+
66+
// Account issues - non-recoverable
67+
if lower.contains("account suspended")
68+
|| lower.contains("account disabled")
69+
|| lower.contains("access denied")
70+
{
71+
return ApiErrorKind::NonRecoverable;
72+
}
73+
74+
// Rate limiting - transient (may recover after backoff)
75+
if lower.contains("rate limit")
76+
|| lower.contains("too many requests")
77+
|| lower.contains("429")
78+
{
79+
return ApiErrorKind::Transient;
80+
}
81+
82+
// Network/timeout errors - transient
83+
if lower.contains("timeout")
84+
|| lower.contains("connection refused")
85+
|| lower.contains("network error")
86+
|| lower.contains("connection reset")
87+
{
88+
return ApiErrorKind::Transient;
89+
}
90+
91+
ApiErrorKind::Unknown
92+
}
93+
94+
/// Get a user-friendly message for non-recoverable errors
95+
pub fn get_api_error_guidance(error_msg: &str) -> &'static str {
96+
let lower = error_msg.to_lowercase();
97+
98+
if lower.contains("credit balance") || lower.contains("purchase credits") {
99+
"💳 API credits exhausted. Please add credits to your account and try again."
100+
} else if lower.contains("invalid api key") || lower.contains("invalid_api_key") {
101+
"🔑 Invalid API key. Please check your ANTHROPIC_API_KEY or OPENAI_API_KEY environment variable."
102+
} else if lower.contains("unauthorized") || lower.contains("authentication") {
103+
"🔐 Authentication failed. Please verify your API credentials."
104+
} else if lower.contains("account suspended") || lower.contains("account disabled") {
105+
"⚠️ Account issue. Please check your account status with the API provider."
106+
} else {
107+
"❌ Non-recoverable API error. Please check your API configuration."
108+
}
109+
}
110+
21111
/// Status of a todo item
22112
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
23113
pub enum TodoStatus {
@@ -1529,7 +1619,37 @@ impl<'a> AutonomousExecutor<'a> {
15291619
));
15301620
}
15311621

1532-
let reasoning_response = self.perform_reasoning(iteration, max_iterations).await?;
1622+
let reasoning_response = match self.perform_reasoning(iteration, max_iterations).await {
1623+
Ok(response) => response,
1624+
Err(e) => {
1625+
let error_msg = e.to_string();
1626+
let error_kind = classify_api_error(&error_msg);
1627+
1628+
match error_kind {
1629+
ApiErrorKind::NonRecoverable => {
1630+
// Log and exit immediately for non-recoverable errors
1631+
let guidance = get_api_error_guidance(&error_msg);
1632+
error!(
1633+
"agent.api.non_recoverable error='{}' guidance='{}'",
1634+
error_msg, guidance
1635+
);
1636+
self.tui.add_log(format!("🛑 {}", guidance));
1637+
self.tui.add_log(format!(
1638+
"❌ Agent stopping immediately due to non-recoverable API error"
1639+
));
1640+
return Err(anyhow!(
1641+
"Non-recoverable API error: {}. {}",
1642+
error_msg,
1643+
guidance
1644+
));
1645+
}
1646+
ApiErrorKind::Transient | ApiErrorKind::Unknown => {
1647+
// For transient errors, propagate normally (may retry)
1648+
return Err(e);
1649+
}
1650+
}
1651+
}
1652+
};
15331653

15341654
// Reset activity timer - we got an LLM response
15351655
self.reset_activity_timer();
@@ -2969,3 +3089,126 @@ impl<'a> GameCreator<'a> {
29693089
context.set_variable("game_type".to_string(), file_extension.to_string());
29703090
}
29713091
}
3092+
3093+
#[cfg(test)]
3094+
mod tests {
3095+
use super::*;
3096+
3097+
#[test]
3098+
fn test_classify_api_error_billing() {
3099+
// Credit balance errors
3100+
assert_eq!(
3101+
classify_api_error("Your credit balance is too low to access the Anthropic API"),
3102+
ApiErrorKind::NonRecoverable
3103+
);
3104+
assert_eq!(
3105+
classify_api_error("Please go to Plans & Billing to purchase credits"),
3106+
ApiErrorKind::NonRecoverable
3107+
);
3108+
assert_eq!(
3109+
classify_api_error("Quota exceeded for your organization"),
3110+
ApiErrorKind::NonRecoverable
3111+
);
3112+
}
3113+
3114+
#[test]
3115+
fn test_classify_api_error_auth() {
3116+
// Authentication errors
3117+
assert_eq!(
3118+
classify_api_error("Invalid API key provided"),
3119+
ApiErrorKind::NonRecoverable
3120+
);
3121+
assert_eq!(
3122+
classify_api_error("Unauthorized: invalid_api_key"),
3123+
ApiErrorKind::NonRecoverable
3124+
);
3125+
assert_eq!(
3126+
classify_api_error("Authentication failed: invalid bearer token"),
3127+
ApiErrorKind::NonRecoverable
3128+
);
3129+
}
3130+
3131+
#[test]
3132+
fn test_classify_api_error_account() {
3133+
// Account issues (note: patterns are "account suspended", "account disabled", "access denied")
3134+
assert_eq!(
3135+
classify_api_error("Your account suspended for policy violation"),
3136+
ApiErrorKind::NonRecoverable
3137+
);
3138+
assert_eq!(
3139+
classify_api_error("Account disabled due to terms of service"),
3140+
ApiErrorKind::NonRecoverable
3141+
);
3142+
assert_eq!(
3143+
classify_api_error("Access denied: insufficient permissions"),
3144+
ApiErrorKind::NonRecoverable
3145+
);
3146+
}
3147+
3148+
#[test]
3149+
fn test_classify_api_error_transient() {
3150+
// Rate limiting - transient
3151+
assert_eq!(
3152+
classify_api_error("Rate limit exceeded, please retry"),
3153+
ApiErrorKind::Transient
3154+
);
3155+
assert_eq!(
3156+
classify_api_error("429 Too Many Requests"),
3157+
ApiErrorKind::Transient
3158+
);
3159+
3160+
// Network errors - transient (note: patterns are "timeout", "connection refused", "network error")
3161+
assert_eq!(
3162+
classify_api_error("Request timeout after 30 seconds"),
3163+
ApiErrorKind::Transient
3164+
);
3165+
assert_eq!(
3166+
classify_api_error("Connection refused by remote server"),
3167+
ApiErrorKind::Transient
3168+
);
3169+
assert_eq!(
3170+
classify_api_error("A network error occurred"),
3171+
ApiErrorKind::Transient
3172+
);
3173+
}
3174+
3175+
#[test]
3176+
fn test_classify_api_error_unknown() {
3177+
// Unknown errors
3178+
assert_eq!(
3179+
classify_api_error("Some random error message"),
3180+
ApiErrorKind::Unknown
3181+
);
3182+
assert_eq!(
3183+
classify_api_error("Internal server error"),
3184+
ApiErrorKind::Unknown
3185+
);
3186+
}
3187+
3188+
#[test]
3189+
fn test_classify_api_error_case_insensitive() {
3190+
// Should be case insensitive
3191+
assert_eq!(
3192+
classify_api_error("CREDIT BALANCE is too low"),
3193+
ApiErrorKind::NonRecoverable
3194+
);
3195+
assert_eq!(
3196+
classify_api_error("INVALID API KEY"),
3197+
ApiErrorKind::NonRecoverable
3198+
);
3199+
assert_eq!(
3200+
classify_api_error("RATE LIMIT exceeded"),
3201+
ApiErrorKind::Transient
3202+
);
3203+
}
3204+
3205+
#[test]
3206+
fn test_get_api_error_guidance() {
3207+
// Test guidance messages
3208+
assert!(get_api_error_guidance("credit balance is too low").contains("credits"));
3209+
assert!(get_api_error_guidance("invalid api key").contains("API key"));
3210+
assert!(get_api_error_guidance("unauthorized").contains("Authentication"));
3211+
assert!(get_api_error_guidance("account suspended").contains("Account"));
3212+
assert!(get_api_error_guidance("unknown error").contains("API"));
3213+
}
3214+
}

0 commit comments

Comments
 (0)