Skip to main content

vtcode_core/
error.rs

1//! Structured error handling for VT Code.
2//!
3//! Provides a VT Code-specific error envelope with machine-readable codes and
4//! contextual information while reusing the shared `vtcode_commons`
5//! classification system.
6
7use crate::llm::provider::LLMError;
8use crate::retry_after::retry_after_from_llm_metadata;
9use crate::tools::registry::{ToolErrorType, ToolExecutionError};
10use crate::tools::unified_error::{UnifiedErrorKind, UnifiedToolError};
11use serde::{Deserialize, Serialize};
12use thiserror::Error;
13pub use vtcode_commons::{BackoffStrategy, ErrorCategory, Retryability};
14
15/// Result type alias for VT Code operations.
16pub type Result<T> = std::result::Result<T, VtCodeError>;
17
18/// Core error type for VT Code operations.
19///
20/// Uses `thiserror::Error` for automatic `std::error::Error` implementation
21/// and provides clear error messages with context.
22#[derive(Debug, Error, Serialize, Deserialize)]
23#[error("{category}: {message}")]
24pub struct VtCodeError {
25    /// Error category for categorization and handling.
26    pub category: ErrorCategory,
27
28    /// Machine-readable error code.
29    pub code: ErrorCode,
30
31    /// Human-readable error message.
32    pub message: String,
33
34    /// Optional context for debugging.
35    #[serde(skip_serializing_if = "Option::is_none")]
36    pub context: Option<String>,
37
38    /// Optional backoff hint for the next retry attempt in milliseconds.
39    #[serde(skip_serializing_if = "Option::is_none")]
40    pub retry_after_ms: Option<u64>,
41
42    /// Optional source error for chained errors.
43    #[serde(skip)]
44    #[source]
45    pub source: Option<Box<dyn std::error::Error + Send + Sync>>,
46}
47
48/// Machine-readable error codes for precise error identification.
49#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
50pub enum ErrorCode {
51    // Input errors
52    InvalidArgument,
53    ValidationFailed,
54    ParseError,
55
56    // Execution errors
57    CommandFailed,
58    ToolExecutionFailed,
59    Timeout,
60
61    // Network errors
62    ConnectionFailed,
63    RequestFailed,
64    RateLimited,
65    ServiceUnavailable,
66
67    // LLM errors
68    AuthenticationFailed,
69    LLMProviderError,
70    TokenLimitExceeded,
71    ContextTooLong,
72
73    // Config errors
74    ConfigInvalid,
75    ConfigMissing,
76    ConfigParseFailed,
77
78    // Security errors
79    PermissionDenied,
80    PolicyViolation,
81    PlanModeViolation,
82    SandboxViolation,
83    DotfileProtection,
84
85    // System errors
86    IoError,
87    OutOfMemory,
88    ResourceUnavailable,
89    ResourceNotFound,
90
91    // Internal errors
92    ToolNotFound,
93    CircuitOpen,
94    Cancelled,
95    Unexpected,
96    NotImplemented,
97}
98
99impl VtCodeError {
100    /// Create a new error with the given category, code, and message.
101    pub fn new<S: Into<String>>(category: ErrorCategory, code: ErrorCode, message: S) -> Self {
102        Self {
103            category,
104            code,
105            message: message.into(),
106            context: None,
107            retry_after_ms: None,
108            source: None,
109        }
110    }
111
112    /// Add context to the error.
113    pub fn with_context<S: Into<String>>(mut self, context: S) -> Self {
114        self.context = Some(context.into());
115        self
116    }
117
118    /// Add a retry-after hint to the error.
119    pub fn with_retry_after(mut self, retry_after: std::time::Duration) -> Self {
120        self.retry_after_ms = Some(retry_after.as_millis().min(u128::from(u64::MAX)) as u64);
121        self
122    }
123
124    /// Set the source error for error chaining.
125    pub fn with_source<E: std::error::Error + Send + Sync + 'static>(mut self, source: E) -> Self {
126        self.source = Some(Box::new(source));
127        self
128    }
129
130    /// Returns the retry-after hint as a duration when present.
131    pub fn retry_after(&self) -> Option<std::time::Duration> {
132        self.retry_after_ms.map(std::time::Duration::from_millis)
133    }
134
135    /// Returns whether the error can be retried safely.
136    pub const fn is_retryable(&self) -> bool {
137        self.category.is_retryable()
138    }
139
140    /// Returns the retry strategy for this error category.
141    pub fn retryability(&self) -> Retryability {
142        self.category.retryability()
143    }
144
145    /// Convenience method for input errors.
146    pub fn input<S: Into<String>>(code: ErrorCode, message: S) -> Self {
147        Self::new(ErrorCategory::InvalidParameters, code, message)
148    }
149
150    /// Convenience method for execution errors.
151    pub fn execution<S: Into<String>>(code: ErrorCode, message: S) -> Self {
152        Self::new(ErrorCategory::ExecutionError, code, message)
153    }
154
155    /// Convenience method for network errors.
156    pub fn network<S: Into<String>>(code: ErrorCode, message: S) -> Self {
157        Self::new(ErrorCategory::Network, code, message)
158    }
159
160    /// Convenience method for LLM errors.
161    pub fn llm<S: Into<String>>(code: ErrorCode, message: S) -> Self {
162        Self::new(ErrorCategory::ExecutionError, code, message)
163    }
164
165    /// Convenience method for config errors.
166    pub fn config<S: Into<String>>(code: ErrorCode, message: S) -> Self {
167        Self::new(ErrorCategory::InvalidParameters, code, message)
168    }
169
170    /// Convenience method for security errors.
171    pub fn security<S: Into<String>>(code: ErrorCode, message: S) -> Self {
172        Self::new(ErrorCategory::PolicyViolation, code, message)
173    }
174
175    /// Convenience method for system errors.
176    pub fn system<S: Into<String>>(code: ErrorCode, message: S) -> Self {
177        Self::new(ErrorCategory::ExecutionError, code, message)
178    }
179
180    /// Convenience method for internal errors.
181    pub fn internal<S: Into<String>>(code: ErrorCode, message: S) -> Self {
182        Self::new(ErrorCategory::ExecutionError, code, message)
183    }
184
185    /// Create an error from a canonical category using the default error code.
186    pub fn from_category<S: Into<String>>(category: ErrorCategory, message: S) -> Self {
187        Self::new(category, ErrorCode::from_category(category), message)
188    }
189}
190
191impl ErrorCode {
192    /// Map a canonical error category to a default machine-readable code.
193    pub const fn from_category(category: ErrorCategory) -> Self {
194        match category {
195            ErrorCategory::Network => ErrorCode::ConnectionFailed,
196            ErrorCategory::Timeout => ErrorCode::Timeout,
197            ErrorCategory::RateLimit => ErrorCode::RateLimited,
198            ErrorCategory::ServiceUnavailable => ErrorCode::ServiceUnavailable,
199            ErrorCategory::CircuitOpen => ErrorCode::CircuitOpen,
200            ErrorCategory::Authentication => ErrorCode::AuthenticationFailed,
201            ErrorCategory::InvalidParameters => ErrorCode::InvalidArgument,
202            ErrorCategory::ToolNotFound => ErrorCode::ToolNotFound,
203            ErrorCategory::ResourceNotFound => ErrorCode::ResourceNotFound,
204            ErrorCategory::PermissionDenied => ErrorCode::PermissionDenied,
205            ErrorCategory::PolicyViolation => ErrorCode::PolicyViolation,
206            ErrorCategory::PlanModeViolation => ErrorCode::PlanModeViolation,
207            ErrorCategory::SandboxFailure => ErrorCode::SandboxViolation,
208            ErrorCategory::ResourceExhausted => ErrorCode::ResourceUnavailable,
209            ErrorCategory::Cancelled => ErrorCode::Cancelled,
210            ErrorCategory::ExecutionError => ErrorCode::Unexpected,
211        }
212    }
213
214    fn from_unified_kind(kind: UnifiedErrorKind) -> Self {
215        match kind {
216            UnifiedErrorKind::Timeout => ErrorCode::Timeout,
217            UnifiedErrorKind::Network => ErrorCode::ConnectionFailed,
218            UnifiedErrorKind::RateLimit => ErrorCode::RateLimited,
219            UnifiedErrorKind::ArgumentValidation => ErrorCode::ValidationFailed,
220            UnifiedErrorKind::ToolNotFound => ErrorCode::ToolNotFound,
221            UnifiedErrorKind::PermissionDenied => ErrorCode::PermissionDenied,
222            UnifiedErrorKind::SandboxFailure => ErrorCode::SandboxViolation,
223            UnifiedErrorKind::InternalError => ErrorCode::Unexpected,
224            UnifiedErrorKind::CircuitOpen => ErrorCode::CircuitOpen,
225            UnifiedErrorKind::ResourceExhausted => ErrorCode::ResourceUnavailable,
226            UnifiedErrorKind::Cancelled => ErrorCode::Cancelled,
227            UnifiedErrorKind::PolicyViolation => ErrorCode::PolicyViolation,
228            UnifiedErrorKind::PlanModeViolation => ErrorCode::PlanModeViolation,
229            UnifiedErrorKind::ExecutionFailed | UnifiedErrorKind::Unknown => {
230                ErrorCode::ToolExecutionFailed
231            }
232        }
233    }
234
235    fn from_tool_error_type(error_type: ToolErrorType) -> Self {
236        match error_type {
237            ToolErrorType::InvalidParameters => ErrorCode::ValidationFailed,
238            ToolErrorType::ToolNotFound => ErrorCode::ToolNotFound,
239            ToolErrorType::PermissionDenied => ErrorCode::PermissionDenied,
240            ToolErrorType::ResourceNotFound => ErrorCode::ResourceNotFound,
241            ToolErrorType::NetworkError => ErrorCode::ConnectionFailed,
242            ToolErrorType::Timeout => ErrorCode::Timeout,
243            ToolErrorType::ExecutionError => ErrorCode::ToolExecutionFailed,
244            ToolErrorType::PolicyViolation => ErrorCode::PolicyViolation,
245        }
246    }
247}
248
249// Implement conversions from common error types
250impl From<std::io::Error> for VtCodeError {
251    fn from(err: std::io::Error) -> Self {
252        VtCodeError::system(ErrorCode::IoError, err.to_string()).with_source(err)
253    }
254}
255
256impl From<serde_json::Error> for VtCodeError {
257    fn from(err: serde_json::Error) -> Self {
258        VtCodeError::config(ErrorCode::ConfigParseFailed, err.to_string()).with_source(err)
259    }
260}
261
262impl From<reqwest::Error> for VtCodeError {
263    fn from(err: reqwest::Error) -> Self {
264        let code = if err.is_timeout() {
265            ErrorCode::Timeout
266        } else if err.is_connect() {
267            ErrorCode::ConnectionFailed
268        } else {
269            ErrorCode::RequestFailed
270        };
271        VtCodeError::network(code, err.to_string()).with_source(err)
272    }
273}
274
275impl From<anyhow::Error> for VtCodeError {
276    fn from(err: anyhow::Error) -> Self {
277        let category = vtcode_commons::classify_anyhow_error(&err);
278        VtCodeError::new(
279            category,
280            ErrorCode::from_category(category),
281            err.to_string(),
282        )
283        .with_context(format!("{err:#}"))
284    }
285}
286
287impl From<LLMError> for VtCodeError {
288    fn from(err: LLMError) -> Self {
289        let category = ErrorCategory::from(&err);
290        let code = match &err {
291            LLMError::Authentication { .. } => ErrorCode::AuthenticationFailed,
292            LLMError::RateLimit { .. } => {
293                if category == ErrorCategory::ResourceExhausted {
294                    ErrorCode::from_category(category)
295                } else {
296                    ErrorCode::RateLimited
297                }
298            }
299            LLMError::InvalidRequest { .. } => ErrorCode::ValidationFailed,
300            LLMError::Network { message, .. } => {
301                if vtcode_commons::classify_error_message(message) == ErrorCategory::Timeout {
302                    ErrorCode::Timeout
303                } else {
304                    ErrorCode::ConnectionFailed
305                }
306            }
307            LLMError::Provider { metadata, .. } => {
308                if category == ErrorCategory::ResourceExhausted {
309                    ErrorCode::from_category(category)
310                } else {
311                    metadata
312                        .as_ref()
313                        .and_then(|meta| meta.status)
314                        .map(|status| match status {
315                            408 => ErrorCode::Timeout,
316                            429 => ErrorCode::RateLimited,
317                            500 | 502 | 503 | 504 | 529 => ErrorCode::ServiceUnavailable,
318                            _ => ErrorCode::LLMProviderError,
319                        })
320                        .unwrap_or(ErrorCode::LLMProviderError)
321                }
322            }
323        };
324        let message = llm_error_message(&err);
325        let retry_after = llm_retry_after(&err);
326
327        let error = VtCodeError::new(category, code, message).with_source(err);
328        if let Some(retry_after) = retry_after {
329            error.with_retry_after(retry_after)
330        } else {
331            error
332        }
333    }
334}
335
336impl From<UnifiedToolError> for VtCodeError {
337    fn from(err: UnifiedToolError) -> Self {
338        let mut error = VtCodeError::new(
339            ErrorCategory::from(err.kind),
340            ErrorCode::from_unified_kind(err.kind),
341            err.user_message.clone(),
342        );
343
344        if let Some(ctx) = &err.debug_context {
345            let mut metadata = vec![
346                format!("tool={}", ctx.tool_name),
347                format!("attempt={}", ctx.attempt),
348            ];
349            if let Some(invocation_id) = &ctx.invocation_id {
350                metadata.push(format!("invocation_id={invocation_id}"));
351            }
352            metadata.extend(
353                ctx.metadata
354                    .iter()
355                    .map(|(key, value)| format!("{key}={value}")),
356            );
357            error = error.with_context(metadata.join(", "));
358        }
359
360        error.with_source(err)
361    }
362}
363
364impl From<ToolExecutionError> for VtCodeError {
365    fn from(err: ToolExecutionError) -> Self {
366        let category = ErrorCategory::from(err.error_type);
367        let mut error = VtCodeError::new(
368            category,
369            ErrorCode::from_tool_error_type(err.error_type),
370            err.message.clone(),
371        );
372
373        let mut context_parts = Vec::new();
374        if let Some(original_error) = &err.original_error {
375            context_parts.push(format!("original_error={original_error}"));
376        }
377        if !err.recovery_suggestions.is_empty() {
378            context_parts.push(format!(
379                "recovery_suggestions={}",
380                err.recovery_suggestions.join(" | ")
381            ));
382        }
383        if !context_parts.is_empty() {
384            error = error.with_context(context_parts.join(", "));
385        }
386
387        error
388    }
389}
390
391fn llm_error_message(error: &LLMError) -> String {
392    match error {
393        LLMError::Authentication { message, .. }
394        | LLMError::InvalidRequest { message, .. }
395        | LLMError::Network { message, .. }
396        | LLMError::Provider { message, .. } => message.clone(),
397        LLMError::RateLimit { metadata } => metadata
398            .as_ref()
399            .and_then(|meta| meta.message.clone())
400            .unwrap_or_else(|| "rate limit exceeded".to_string()),
401    }
402}
403
404fn llm_retry_after(error: &LLMError) -> Option<std::time::Duration> {
405    let metadata = match error {
406        LLMError::Authentication { metadata, .. }
407        | LLMError::RateLimit { metadata }
408        | LLMError::InvalidRequest { metadata, .. }
409        | LLMError::Network { metadata, .. }
410        | LLMError::Provider { metadata, .. } => metadata.as_ref(),
411    }?;
412
413    retry_after_from_llm_metadata(metadata)
414}
415
416#[cfg(test)]
417mod tests {
418    use super::*;
419    use crate::llm::provider::LLMErrorMetadata;
420    use crate::tools::unified_error::DebugContext;
421
422    #[test]
423    fn test_error_creation() {
424        let err = VtCodeError::input(ErrorCode::InvalidArgument, "Invalid argument");
425        assert_eq!(err.category, ErrorCategory::InvalidParameters);
426        assert_eq!(err.code, ErrorCode::InvalidArgument);
427        assert_eq!(err.message, "Invalid argument");
428    }
429
430    #[test]
431    fn test_error_with_context() {
432        let err = VtCodeError::input(ErrorCode::InvalidArgument, "Invalid argument")
433            .with_context("While parsing user input");
434        assert_eq!(err.context, Some("While parsing user input".to_string()));
435    }
436
437    #[test]
438    fn test_error_with_source() {
439        let io_err = std::io::Error::other("IO error");
440        let err =
441            VtCodeError::system(ErrorCode::IoError, "File operation failed").with_source(io_err);
442        assert!(err.source.is_some());
443    }
444
445    #[test]
446    fn test_error_category_display() {
447        let err = VtCodeError::network(ErrorCode::ConnectionFailed, "Connection failed");
448        let display = format!("{}", err);
449        assert!(display.contains("Network error"));
450        assert!(display.contains("Connection failed"));
451    }
452
453    #[test]
454    fn test_error_serialization_skips_source() {
455        let io_err = std::io::Error::other("IO error");
456        let err = VtCodeError::system(ErrorCode::IoError, "File operation failed")
457            .with_context("While reading config")
458            .with_source(io_err);
459
460        let json = serde_json::to_string(&err).expect("vtcode error should serialize");
461        assert!(json.contains("\"message\":\"File operation failed\""));
462        assert!(json.contains("\"context\":\"While reading config\""));
463        assert!(!json.contains("source"));
464    }
465
466    #[test]
467    fn test_error_with_retry_after() {
468        let err = VtCodeError::network(ErrorCode::RateLimited, "rate limit")
469            .with_retry_after(std::time::Duration::from_secs(2));
470        assert_eq!(err.retry_after(), Some(std::time::Duration::from_secs(2)));
471    }
472
473    #[test]
474    fn test_llm_error_conversion_preserves_retry_after() {
475        let err = LLMError::RateLimit {
476            metadata: Some(LLMErrorMetadata::new(
477                "OpenAI",
478                Some(429),
479                Some("rate_limit".to_string()),
480                Some("req-1".to_string()),
481                None,
482                Some("3".to_string()),
483                Some("try again later".to_string()),
484            )),
485        };
486
487        let converted = VtCodeError::from(err);
488        assert_eq!(converted.category, ErrorCategory::RateLimit);
489        assert_eq!(converted.code, ErrorCode::RateLimited);
490        assert_eq!(
491            converted.retry_after(),
492            Some(std::time::Duration::from_secs(3))
493        );
494    }
495
496    #[test]
497    fn test_llm_error_conversion_preserves_fractional_retry_after() {
498        let err = LLMError::RateLimit {
499            metadata: Some(LLMErrorMetadata::new(
500                "OpenAI",
501                Some(429),
502                Some("rate_limit".to_string()),
503                Some("req-1".to_string()),
504                None,
505                Some("0.5".to_string()),
506                Some("try again later".to_string()),
507            )),
508        };
509
510        let converted = VtCodeError::from(err);
511        assert_eq!(
512            converted.retry_after(),
513            Some(std::time::Duration::from_millis(500))
514        );
515    }
516
517    #[test]
518    fn test_llm_quota_exhaustion_uses_resource_exhausted_code() {
519        let err = LLMError::RateLimit {
520            metadata: Some(LLMErrorMetadata::new(
521                "OpenAI",
522                Some(429),
523                Some("insufficient_quota".to_string()),
524                None,
525                None,
526                None,
527                Some("quota exceeded".to_string()),
528            )),
529        };
530
531        let converted = VtCodeError::from(err);
532        assert_eq!(converted.category, ErrorCategory::ResourceExhausted);
533        assert_eq!(converted.code, ErrorCode::ResourceUnavailable);
534    }
535
536    #[test]
537    fn test_unified_tool_error_conversion_preserves_context() {
538        let err = UnifiedToolError::new(UnifiedErrorKind::Network, "network down").with_context(
539            DebugContext {
540                tool_name: "read_file".to_string(),
541                invocation_id: Some("inv-1".to_string()),
542                attempt: 2,
543                metadata: vec![("duration_ms".to_string(), "1500".to_string())],
544            },
545        );
546
547        let converted = VtCodeError::from(err);
548        assert_eq!(converted.category, ErrorCategory::Network);
549        assert_eq!(converted.code, ErrorCode::ConnectionFailed);
550        assert!(
551            converted
552                .context
553                .as_deref()
554                .is_some_and(|ctx| ctx.contains("tool=read_file"))
555        );
556    }
557
558    #[test]
559    fn test_tool_execution_error_conversion_uses_original_context() {
560        let err = ToolExecutionError::with_original_error(
561            "unified_exec".to_string(),
562            ToolErrorType::Timeout,
563            "Tool execution failed".to_string(),
564            "timed out waiting for process".to_string(),
565        );
566
567        let converted = VtCodeError::from(err);
568        assert_eq!(converted.category, ErrorCategory::Timeout);
569        assert_eq!(converted.code, ErrorCode::Timeout);
570        assert!(
571            converted
572                .context
573                .as_deref()
574                .is_some_and(|ctx| ctx.contains("original_error=timed out waiting for process"))
575        );
576    }
577}