@@ -18,6 +18,96 @@ use tracing::{debug, error, info, warn};
1818
1919use 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 ) ]
23113pub 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