Skip to main content

riglr_evm_tools/
error.rs

1//! Error types for EVM tools
2
3use thiserror::Error;
4
5/// Main error type for EVM tools
6#[derive(Error, Debug, Clone)]
7pub enum EvmToolError {
8    /// Generic provider issues
9    #[error("Provider error: {0}")]
10    ProviderError(String),
11
12    /// Transaction reverted on-chain
13    #[error("Transaction reverted: {reason}")]
14    TransactionReverted { reason: String },
15
16    /// Nonce is too low
17    #[error("Nonce too low")]
18    NonceTooLow,
19
20    /// Nonce mismatch
21    #[error("Nonce mismatch")]
22    NonceMismatch,
23
24    /// Insufficient funds for transaction
25    #[error("Insufficient funds")]
26    InsufficientFunds,
27
28    /// Gas estimation failed
29    #[error("Gas estimation failed")]
30    GasEstimationFailed,
31
32    /// Invalid address format
33    #[error("Invalid address: {0}")]
34    InvalidAddress(String),
35
36    /// Contract-related errors
37    #[error("Contract error: {0}")]
38    ContractError(String),
39
40    /// Signer-related errors
41    #[error("Signer error: {0}")]
42    SignerError(#[from] riglr_core::SignerError),
43
44    /// Network timeout or connection issues
45    #[error("Network error: {0}")]
46    NetworkError(String),
47
48    /// Rate limit exceeded
49    #[error("Rate limit exceeded")]
50    RateLimited,
51
52    /// Unsupported chain
53    #[error("Unsupported chain: {0}")]
54    UnsupportedChain(u64),
55
56    /// Invalid parameters
57    #[error("Invalid parameter: {0}")]
58    InvalidParameter(String),
59
60    /// Generic error fallback
61    #[error("{0}")]
62    Generic(String),
63}
64
65/// Error classification for retry logic
66#[derive(Debug, Clone, Copy, PartialEq, Eq)]
67pub enum ErrorClass {
68    /// Permanent errors that should not be retried
69    Permanent,
70    /// Retriable errors that may succeed on retry
71    Retriable,
72    /// Rate-limited errors that need backoff
73    RateLimited,
74}
75
76/// Classify provider error messages for retry behavior
77fn classify_provider_message(msg: &str) -> ErrorClass {
78    if msg.contains("timeout") || msg.contains("connection") {
79        ErrorClass::Retriable
80    } else if msg.contains("rate") || msg.contains("quota") {
81        ErrorClass::RateLimited
82    } else {
83        ErrorClass::Permanent
84    }
85}
86
87/// Classify generic error messages for retry behavior
88fn classify_generic_message(msg: &str) -> ErrorClass {
89    if msg.contains("timeout") || msg.contains("connection") {
90        ErrorClass::Retriable
91    } else {
92        ErrorClass::Permanent
93    }
94}
95
96/// Attempt to extract JSON-RPC error code from error message
97///
98/// JSON-RPC error messages often contain the error code in the format:
99/// - "error code: -32000"
100/// - "code: -32603"
101/// - "429 Too Many Requests"
102fn extract_error_code(msg: &str) -> Option<i32> {
103    // Try to find patterns like "code: -32000" or "error code: -32000"
104    if let Some(idx) = msg.find("code:") {
105        let code_str = &msg[idx + 5..].trim();
106        if let Some(end_idx) = code_str.find(|c: char| !c.is_ascii_digit() && c != '-') {
107            return code_str[..end_idx].parse::<i32>().ok();
108        }
109        return code_str.split_whitespace().next()?.parse::<i32>().ok();
110    }
111
112    // Try to find HTTP status codes like "429"
113    if msg.starts_with("429") || msg.contains(" 429 ") {
114        return Some(429);
115    }
116
117    // Try to find patterns like "error -32000"
118    if let Some(idx) = msg.find("error ") {
119        let code_str = &msg[idx + 6..].trim();
120        if let Some(stripped) = code_str.strip_prefix('-') {
121            if let Some(end_idx) = stripped.find(|c: char| !c.is_ascii_digit()) {
122                return code_str[..=end_idx].parse::<i32>().ok();
123            }
124        }
125    }
126
127    None
128}
129
130/// Classify errors based on JSON-RPC error codes
131///
132/// Standard JSON-RPC error codes:
133/// - -32700: Parse error (permanent)
134/// - -32600: Invalid request (permanent)  
135/// - -32601: Method not found (permanent)
136/// - -32602: Invalid params (permanent)
137/// - -32603: Internal error (retriable)
138/// - -32000 to -32099: Server errors (implementation defined)
139///
140/// Common implementation-specific codes:
141/// - -32000: Insufficient funds, invalid input (permanent)
142/// - -32002: Resource not found (permanent)
143/// - -32003: Transaction rejected (permanent)
144/// - -32004: Method not supported (permanent)
145/// - -32005: Limit exceeded (rate limited)
146/// - 429: HTTP Too Many Requests (rate limited)
147fn classify_by_error_code(code: i32) -> Option<ErrorClass> {
148    match code {
149        // Standard JSON-RPC permanent errors
150        -32700 | -32600 | -32601 | -32602 => Some(ErrorClass::Permanent),
151
152        // Standard JSON-RPC internal error (retriable)
153        -32603 => Some(ErrorClass::Retriable),
154
155        // Common implementation-specific permanent errors
156        -32000 | -32002 | -32003 | -32004 => Some(ErrorClass::Permanent),
157
158        // Rate limiting
159        -32005 | 429 => Some(ErrorClass::RateLimited),
160
161        // Other server errors in the -32000 to -32099 range
162        -32099..=-32001 => Some(ErrorClass::Retriable),
163
164        // Unknown codes
165        _ => None,
166    }
167}
168
169/// Classify EVM errors to determine retry behavior
170///
171/// This function uses structured error variants to determine classification,
172/// with a fallback to string matching for provider errors that contain generic messages.
173///
174/// # Examples
175///
176/// ```ignore
177/// let error = EvmToolError::NonceTooLow;
178/// assert_eq!(classify_evm_error(&error), ErrorClass::Retriable);
179///
180/// let error = EvmToolError::InsufficientFunds;
181/// assert_eq!(classify_evm_error(&error), ErrorClass::Permanent);
182/// ```
183pub fn classify_evm_error(error: &EvmToolError) -> ErrorClass {
184    match error {
185        // Structured errors with clear classification
186        EvmToolError::NonceTooLow | EvmToolError::NonceMismatch => ErrorClass::Retriable,
187        EvmToolError::InsufficientFunds => ErrorClass::Permanent,
188        EvmToolError::TransactionReverted { .. } => ErrorClass::Permanent,
189        EvmToolError::RateLimited => ErrorClass::RateLimited,
190        EvmToolError::GasEstimationFailed => ErrorClass::Retriable,
191        EvmToolError::InvalidAddress(_) => ErrorClass::Permanent,
192        EvmToolError::ContractError(_) => ErrorClass::Permanent,
193        EvmToolError::SignerError(_) => ErrorClass::Permanent,
194        EvmToolError::UnsupportedChain(_) => ErrorClass::Permanent,
195        EvmToolError::InvalidParameter(_) => ErrorClass::Permanent,
196
197        // Network errors are typically retriable
198        EvmToolError::NetworkError(_msg) => ErrorClass::Retriable,
199
200        // Provider errors need string matching as fallback
201        EvmToolError::ProviderError(msg) => {
202            // First try to extract and classify by error code
203            if let Some(code) = extract_error_code(msg) {
204                if let Some(class) = classify_by_error_code(code) {
205                    return class;
206                }
207            }
208            // Fall back to string matching
209            classify_provider_message(msg)
210        }
211
212        // Generic errors use string matching
213        EvmToolError::Generic(msg) => classify_generic_message(msg),
214    }
215}
216
217impl From<EvmToolError> for riglr_core::ToolError {
218    fn from(err: EvmToolError) -> Self {
219        let class = classify_evm_error(&err);
220        match class {
221            ErrorClass::Permanent => {
222                riglr_core::ToolError::permanent_with_source(err, "EVM operation failed")
223            }
224            ErrorClass::Retriable => {
225                riglr_core::ToolError::retriable_with_source(err, "EVM operation can be retried")
226            }
227            ErrorClass::RateLimited => riglr_core::ToolError::rate_limited_with_source(
228                err,
229                "EVM rate limit exceeded",
230                None,
231            ),
232        }
233    }
234}
235
236#[cfg(test)]
237mod tests {
238    use super::*;
239
240    #[test]
241    fn test_extract_error_code() {
242        // Test various error message formats
243        assert_eq!(extract_error_code("error code: -32000"), Some(-32000));
244        assert_eq!(extract_error_code("code: -32603"), Some(-32603));
245        assert_eq!(extract_error_code("429 Too Many Requests"), Some(429));
246        assert_eq!(extract_error_code("Status: 429 - Rate limited"), Some(429));
247        assert_eq!(
248            extract_error_code("error -32001: insufficient funds"),
249            Some(-32001)
250        );
251        assert_eq!(
252            extract_error_code("JSON-RPC error code: -32602 invalid params"),
253            Some(-32602)
254        );
255
256        // Test messages without error codes
257        assert_eq!(extract_error_code("connection timeout"), None);
258        assert_eq!(extract_error_code("invalid transaction"), None);
259        assert_eq!(extract_error_code("rate limit exceeded"), None);
260    }
261
262    #[test]
263    fn test_classify_by_error_code() {
264        // Test standard JSON-RPC permanent errors
265        assert_eq!(classify_by_error_code(-32700), Some(ErrorClass::Permanent)); // Parse error
266        assert_eq!(classify_by_error_code(-32600), Some(ErrorClass::Permanent)); // Invalid request
267        assert_eq!(classify_by_error_code(-32601), Some(ErrorClass::Permanent)); // Method not found
268        assert_eq!(classify_by_error_code(-32602), Some(ErrorClass::Permanent)); // Invalid params
269
270        // Test standard JSON-RPC retriable error
271        assert_eq!(classify_by_error_code(-32603), Some(ErrorClass::Retriable)); // Internal error
272
273        // Test implementation-specific permanent errors
274        assert_eq!(classify_by_error_code(-32000), Some(ErrorClass::Permanent)); // Invalid input
275        assert_eq!(classify_by_error_code(-32002), Some(ErrorClass::Permanent)); // Resource not found
276        assert_eq!(classify_by_error_code(-32003), Some(ErrorClass::Permanent)); // Transaction rejected
277        assert_eq!(classify_by_error_code(-32004), Some(ErrorClass::Permanent)); // Method not supported
278
279        // Test rate limiting codes
280        assert_eq!(
281            classify_by_error_code(-32005),
282            Some(ErrorClass::RateLimited)
283        ); // Limit exceeded
284        assert_eq!(classify_by_error_code(429), Some(ErrorClass::RateLimited)); // HTTP Too Many Requests
285
286        // Test server errors in range
287        assert_eq!(classify_by_error_code(-32050), Some(ErrorClass::Retriable));
288        assert_eq!(classify_by_error_code(-32099), Some(ErrorClass::Retriable));
289
290        // Test unknown codes
291        assert_eq!(classify_by_error_code(500), None);
292        assert_eq!(classify_by_error_code(-33000), None);
293    }
294
295    #[test]
296    fn test_classify_evm_error_with_codes() {
297        // Test errors with structured JSON-RPC codes in ProviderError messages
298        let error =
299            EvmToolError::ProviderError("error code: -32000 insufficient funds".to_string());
300        assert_eq!(classify_evm_error(&error), ErrorClass::Permanent);
301
302        let error = EvmToolError::ProviderError(
303            "JSON-RPC error code: -32603 internal server error".to_string(),
304        );
305        assert_eq!(classify_evm_error(&error), ErrorClass::Retriable);
306
307        let error =
308            EvmToolError::ProviderError("429 Too Many Requests - please retry later".to_string());
309        assert_eq!(classify_evm_error(&error), ErrorClass::RateLimited);
310
311        let error = EvmToolError::ProviderError("error code: -32602 invalid params".to_string());
312        assert_eq!(classify_evm_error(&error), ErrorClass::Permanent);
313
314        let error = EvmToolError::ProviderError("code: -32005 rate limit exceeded".to_string());
315        assert_eq!(classify_evm_error(&error), ErrorClass::RateLimited);
316    }
317
318    #[test]
319    fn test_classify_evm_error_fallback_string_matching() {
320        // Test fallback to string-based classification when no code is present
321
322        // Network errors
323        let error = EvmToolError::NetworkError("connection timeout after 30 seconds".to_string());
324        assert_eq!(classify_evm_error(&error), ErrorClass::Retriable);
325
326        // Provider errors with string matching
327        let error = EvmToolError::ProviderError("request timeout".to_string());
328        assert_eq!(classify_evm_error(&error), ErrorClass::Retriable);
329
330        let error = EvmToolError::ProviderError("quota exceeded for API key".to_string());
331        assert_eq!(classify_evm_error(&error), ErrorClass::RateLimited);
332
333        // Test structured error variants
334        let error = EvmToolError::NonceTooLow;
335        assert_eq!(classify_evm_error(&error), ErrorClass::Retriable);
336
337        let error = EvmToolError::InsufficientFunds;
338        assert_eq!(classify_evm_error(&error), ErrorClass::Permanent);
339
340        let error = EvmToolError::TransactionReverted {
341            reason: "Out of gas".to_string(),
342        };
343        assert_eq!(classify_evm_error(&error), ErrorClass::Permanent);
344    }
345
346    #[test]
347    fn test_classify_evm_error_direct_variants() {
348        // Test error types that are always classified the same way
349        let error = EvmToolError::ContractError("method not found".to_string());
350        assert_eq!(classify_evm_error(&error), ErrorClass::Permanent);
351
352        let error = EvmToolError::InvalidParameter("invalid address format".to_string());
353        assert_eq!(classify_evm_error(&error), ErrorClass::Permanent);
354
355        let error = EvmToolError::InsufficientFunds;
356        assert_eq!(classify_evm_error(&error), ErrorClass::Permanent);
357
358        let error = EvmToolError::InvalidAddress("not a valid hex address".to_string());
359        assert_eq!(classify_evm_error(&error), ErrorClass::Permanent);
360
361        let error = EvmToolError::UnsupportedChain(999);
362        assert_eq!(classify_evm_error(&error), ErrorClass::Permanent);
363
364        let error = EvmToolError::SignerError(riglr_core::SignerError::NoSignerContext);
365        assert_eq!(classify_evm_error(&error), ErrorClass::Permanent);
366
367        let error = EvmToolError::RateLimited;
368        assert_eq!(classify_evm_error(&error), ErrorClass::RateLimited);
369
370        let error = EvmToolError::GasEstimationFailed;
371        assert_eq!(classify_evm_error(&error), ErrorClass::Retriable);
372    }
373
374    #[test]
375    fn test_classify_evm_error_code_priority() {
376        // Test that error codes take priority over string matching in ProviderError
377
378        // Message suggests retriable, but code says permanent
379        let error =
380            EvmToolError::ProviderError("connection timeout, error code: -32000".to_string());
381        assert_eq!(classify_evm_error(&error), ErrorClass::Permanent);
382
383        // Message suggests permanent, but code says rate limited
384        let error = EvmToolError::ProviderError("invalid request format code: 429".to_string());
385        assert_eq!(classify_evm_error(&error), ErrorClass::RateLimited);
386    }
387
388    #[test]
389    fn test_from_evm_tool_error_to_tool_error() {
390        // Test the From trait implementation
391        let evm_error = EvmToolError::ProviderError("error code: -32000".to_string());
392        let tool_error: riglr_core::ToolError = evm_error.into();
393        // We can't directly test the internal state of ToolError, but we can verify it converts
394        assert!(tool_error.to_string().contains("-32000"));
395
396        let evm_error = EvmToolError::RateLimited;
397        let tool_error: riglr_core::ToolError = evm_error.into();
398        assert!(tool_error.to_string().contains("Rate limit exceeded"));
399
400        let evm_error = EvmToolError::Generic("timeout".to_string());
401        let tool_error: riglr_core::ToolError = evm_error.into();
402        assert!(tool_error.to_string().contains("timeout"));
403
404        // Test source preservation - create a structured error and verify it's preserved
405        let evm_error = EvmToolError::InsufficientFunds;
406        let tool_error: riglr_core::ToolError = evm_error.clone().into();
407        // The source should be preserved
408        assert!(tool_error.to_string().contains("Insufficient funds"));
409    }
410
411    #[test]
412    fn test_classify_generic_message() {
413        // Test generic message classification
414        assert_eq!(
415            classify_generic_message("connection reset"),
416            ErrorClass::Retriable
417        );
418        assert_eq!(
419            classify_generic_message("request timeout"),
420            ErrorClass::Retriable
421        );
422        assert_eq!(
423            classify_generic_message("unknown error"),
424            ErrorClass::Permanent
425        );
426    }
427
428    #[test]
429    fn test_new_structured_error_variants() {
430        // Test all new structured error variants
431        let error = EvmToolError::NonceTooLow;
432        assert_eq!(classify_evm_error(&error), ErrorClass::Retriable);
433
434        let error = EvmToolError::NonceMismatch;
435        assert_eq!(classify_evm_error(&error), ErrorClass::Retriable);
436
437        let error = EvmToolError::InsufficientFunds;
438        assert_eq!(classify_evm_error(&error), ErrorClass::Permanent);
439
440        let error = EvmToolError::TransactionReverted {
441            reason: "Execution failed".to_string(),
442        };
443        assert_eq!(classify_evm_error(&error), ErrorClass::Permanent);
444
445        let error = EvmToolError::RateLimited;
446        assert_eq!(classify_evm_error(&error), ErrorClass::RateLimited);
447
448        let error = EvmToolError::GasEstimationFailed;
449        assert_eq!(classify_evm_error(&error), ErrorClass::Retriable);
450
451        let error = EvmToolError::NetworkError("timeout".to_string());
452        assert_eq!(classify_evm_error(&error), ErrorClass::Retriable);
453    }
454
455    #[test]
456    fn test_downcast_from_tool_error_source() {
457        // Create a structured EvmToolError
458        let evm_error = EvmToolError::InsufficientFunds;
459
460        // Convert to ToolError
461        let tool_error: riglr_core::ToolError = evm_error.clone().into();
462
463        // The error message should be preserved
464        assert!(tool_error.to_string().contains("Insufficient funds"));
465
466        // We can't directly test downcasting because ToolError's source is private,
467        // but the From implementation preserves the source, which is what matters
468
469        // Test another structured variant
470        let evm_error = EvmToolError::TransactionReverted {
471            reason: "Out of gas".to_string(),
472        };
473        let tool_error: riglr_core::ToolError = evm_error.into();
474        assert!(tool_error.to_string().contains("Out of gas"));
475    }
476
477    #[test]
478    fn test_provider_error_classification_with_fallback() {
479        // Test ProviderError with error code (should use code)
480        let error = EvmToolError::ProviderError("error code: -32000".to_string());
481        assert_eq!(classify_evm_error(&error), ErrorClass::Permanent);
482
483        // Test ProviderError without code (should use string matching)
484        let error = EvmToolError::ProviderError("connection timeout".to_string());
485        assert_eq!(classify_evm_error(&error), ErrorClass::Retriable);
486
487        let error = EvmToolError::ProviderError("rate limit exceeded".to_string());
488        assert_eq!(classify_evm_error(&error), ErrorClass::RateLimited);
489
490        let error = EvmToolError::ProviderError("unknown error".to_string());
491        assert_eq!(classify_evm_error(&error), ErrorClass::Permanent);
492    }
493}