1use 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
15pub type Result<T> = std::result::Result<T, VtCodeError>;
17
18#[derive(Debug, Error, Serialize, Deserialize)]
23#[error("{category}: {message}")]
24pub struct VtCodeError {
25 pub category: ErrorCategory,
27
28 pub code: ErrorCode,
30
31 pub message: String,
33
34 #[serde(skip_serializing_if = "Option::is_none")]
36 pub context: Option<String>,
37
38 #[serde(skip_serializing_if = "Option::is_none")]
40 pub retry_after_ms: Option<u64>,
41
42 #[serde(skip)]
44 #[source]
45 pub source: Option<Box<dyn std::error::Error + Send + Sync>>,
46}
47
48#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
50pub enum ErrorCode {
51 InvalidArgument,
53 ValidationFailed,
54 ParseError,
55
56 CommandFailed,
58 ToolExecutionFailed,
59 Timeout,
60
61 ConnectionFailed,
63 RequestFailed,
64 RateLimited,
65 ServiceUnavailable,
66
67 AuthenticationFailed,
69 LLMProviderError,
70 TokenLimitExceeded,
71 ContextTooLong,
72
73 ConfigInvalid,
75 ConfigMissing,
76 ConfigParseFailed,
77
78 PermissionDenied,
80 PolicyViolation,
81 PlanModeViolation,
82 SandboxViolation,
83 DotfileProtection,
84
85 IoError,
87 OutOfMemory,
88 ResourceUnavailable,
89 ResourceNotFound,
90
91 ToolNotFound,
93 CircuitOpen,
94 Cancelled,
95 Unexpected,
96 NotImplemented,
97}
98
99impl VtCodeError {
100 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 pub fn with_context<S: Into<String>>(mut self, context: S) -> Self {
114 self.context = Some(context.into());
115 self
116 }
117
118 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 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 pub fn retry_after(&self) -> Option<std::time::Duration> {
132 self.retry_after_ms.map(std::time::Duration::from_millis)
133 }
134
135 pub const fn is_retryable(&self) -> bool {
137 self.category.is_retryable()
138 }
139
140 pub fn retryability(&self) -> Retryability {
142 self.category.retryability()
143 }
144
145 pub fn input<S: Into<String>>(code: ErrorCode, message: S) -> Self {
147 Self::new(ErrorCategory::InvalidParameters, code, message)
148 }
149
150 pub fn execution<S: Into<String>>(code: ErrorCode, message: S) -> Self {
152 Self::new(ErrorCategory::ExecutionError, code, message)
153 }
154
155 pub fn network<S: Into<String>>(code: ErrorCode, message: S) -> Self {
157 Self::new(ErrorCategory::Network, code, message)
158 }
159
160 pub fn llm<S: Into<String>>(code: ErrorCode, message: S) -> Self {
162 Self::new(ErrorCategory::ExecutionError, code, message)
163 }
164
165 pub fn config<S: Into<String>>(code: ErrorCode, message: S) -> Self {
167 Self::new(ErrorCategory::InvalidParameters, code, message)
168 }
169
170 pub fn security<S: Into<String>>(code: ErrorCode, message: S) -> Self {
172 Self::new(ErrorCategory::PolicyViolation, code, message)
173 }
174
175 pub fn system<S: Into<String>>(code: ErrorCode, message: S) -> Self {
177 Self::new(ErrorCategory::ExecutionError, code, message)
178 }
179
180 pub fn internal<S: Into<String>>(code: ErrorCode, message: S) -> Self {
182 Self::new(ErrorCategory::ExecutionError, code, message)
183 }
184
185 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 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
249impl 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}