Skip to main content

vtcode_commons/
error_category.rs

1//! Unified error categorization system for consistent error classification across VT Code.
2//!
3//! This module provides a single canonical `ErrorCategory` enum that unifies the
4//! previously separate classification systems in `registry::error` (8-variant `ToolErrorType`)
5//! and `unified_error` (16-variant `UnifiedErrorKind`). Both systems now map through
6//! this shared taxonomy for consistent retry decisions and error handling.
7//!
8//! # Error Categories
9//!
10//! Errors are divided into **retryable** (transient) and **non-retryable** (permanent)
11//! categories, with sub-classifications for specific handling strategies.
12//!
13//! # Design Decisions
14//!
15//! - String-based fallback is preserved only for `anyhow::Error` chains where the
16//!   original type is erased. Typed `From` conversions are preferred.
17//! - Policy violations are explicitly separated from OS-level permission denials.
18//! - Rate limiting is a distinct category (not merged with network errors).
19//! - Circuit breaker open is categorized separately for recovery flow routing.
20
21use std::borrow::Cow;
22use std::fmt;
23use std::fmt::Write;
24use std::time::Duration;
25
26/// Canonical error category used throughout VT Code for consistent
27/// retry decisions, user-facing messages, and error handling strategies.
28#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize)]
29pub enum ErrorCategory {
30    // === Retryable (Transient) ===
31    /// Network connectivity issue (connection reset, DNS failure, etc.)
32    Network,
33    /// Request timed out or deadline exceeded
34    Timeout,
35    /// Rate limit exceeded (HTTP 429, provider throttling)
36    RateLimit,
37    /// External service temporarily unavailable (HTTP 5xx)
38    ServiceUnavailable,
39    /// Circuit breaker is open for this tool/service
40    CircuitOpen,
41
42    // === Non-Retryable (Permanent) ===
43    /// Authentication or authorization failure (invalid API key, expired token)
44    Authentication,
45    /// Invalid parameters, arguments, or schema validation failure
46    InvalidParameters,
47    /// Tool not found or unavailable
48    ToolNotFound,
49    /// Resource not found (file, directory, path does not exist)
50    ResourceNotFound,
51    /// OS-level permission denied (file permissions, EACCES, EPERM)
52    PermissionDenied,
53    /// Policy violation (workspace boundary, tool deny policy, safety gate)
54    PolicyViolation,
55    /// Plan mode violation (mutating tool in read-only mode)
56    PlanModeViolation,
57    /// Sandbox execution failure
58    SandboxFailure,
59    /// Resource exhausted (quota, billing, spending limit, disk, memory)
60    ResourceExhausted,
61    /// User cancelled the operation
62    Cancelled,
63    /// General execution error (catch-all for unclassified failures)
64    ExecutionError,
65}
66
67/// Describes whether and how an error can be retried.
68#[derive(Debug, Clone, PartialEq, Eq)]
69pub enum Retryability {
70    /// Error is transient and may succeed on retry.
71    Retryable {
72        /// Suggested maximum retry attempts.
73        max_attempts: u32,
74        /// Suggested backoff strategy.
75        backoff: BackoffStrategy,
76    },
77    /// Error is permanent and should NOT be retried.
78    NonRetryable,
79    /// Error requires human intervention before proceeding.
80    RequiresIntervention,
81}
82
83/// Backoff strategy for retryable errors.
84#[derive(Debug, Clone, PartialEq, Eq)]
85pub enum BackoffStrategy {
86    /// Exponential backoff with base delay and maximum cap.
87    Exponential { base: Duration, max: Duration },
88    /// Fixed delay between retries (e.g., for rate-limited APIs with Retry-After).
89    Fixed(Duration),
90}
91
92impl ErrorCategory {
93    /// Whether this error category is safe to retry.
94    #[inline]
95    #[must_use]
96    pub const fn is_retryable(&self) -> bool {
97        matches!(
98            self,
99            ErrorCategory::Network
100                | ErrorCategory::Timeout
101                | ErrorCategory::RateLimit
102                | ErrorCategory::ServiceUnavailable
103                | ErrorCategory::CircuitOpen
104        )
105    }
106
107    /// Whether this category should count toward circuit breaker transitions.
108    #[inline]
109    #[must_use]
110    pub const fn should_trip_circuit_breaker(&self) -> bool {
111        matches!(
112            self,
113            ErrorCategory::Network
114                | ErrorCategory::Timeout
115                | ErrorCategory::RateLimit
116                | ErrorCategory::ServiceUnavailable
117                | ErrorCategory::ExecutionError
118        )
119    }
120
121    /// Whether this error is an LLM argument mistake (should not count toward
122    /// circuit breaker thresholds).
123    #[inline]
124    #[must_use]
125    pub const fn is_llm_mistake(&self) -> bool {
126        matches!(self, ErrorCategory::InvalidParameters)
127    }
128
129    /// Whether this error represents a permanent, non-recoverable condition.
130    #[inline]
131    #[must_use]
132    pub const fn is_permanent(&self) -> bool {
133        matches!(
134            self,
135            ErrorCategory::Authentication
136                | ErrorCategory::PolicyViolation
137                | ErrorCategory::PlanModeViolation
138                | ErrorCategory::ResourceExhausted
139        )
140    }
141
142    /// Get the recommended retryability for this error category.
143    #[must_use]
144    pub fn retryability(&self) -> Retryability {
145        match self {
146            ErrorCategory::Network | ErrorCategory::ServiceUnavailable => Retryability::Retryable {
147                max_attempts: 3,
148                backoff: BackoffStrategy::Exponential {
149                    base: Duration::from_millis(500),
150                    max: Duration::from_secs(10),
151                },
152            },
153            ErrorCategory::Timeout => Retryability::Retryable {
154                max_attempts: 2,
155                backoff: BackoffStrategy::Exponential {
156                    base: Duration::from_millis(1000),
157                    max: Duration::from_secs(15),
158                },
159            },
160            ErrorCategory::RateLimit => Retryability::Retryable {
161                max_attempts: 3,
162                backoff: BackoffStrategy::Exponential {
163                    base: Duration::from_secs(1),
164                    max: Duration::from_secs(30),
165                },
166            },
167            ErrorCategory::CircuitOpen => Retryability::Retryable {
168                max_attempts: 1,
169                backoff: BackoffStrategy::Fixed(Duration::from_secs(10)),
170            },
171            ErrorCategory::PermissionDenied => Retryability::RequiresIntervention,
172            _ => Retryability::NonRetryable,
173        }
174    }
175
176    /// Get recovery suggestions for this error category.
177    /// Returns static strings to avoid allocation.
178    #[must_use]
179    pub fn recovery_suggestions(&self) -> Vec<Cow<'static, str>> {
180        match self {
181            ErrorCategory::Network => vec![
182                Cow::Borrowed("Check network connectivity"),
183                Cow::Borrowed("Retry the operation after a brief delay"),
184                Cow::Borrowed("Verify external service availability"),
185            ],
186            ErrorCategory::Timeout => vec![
187                Cow::Borrowed("Increase timeout values if appropriate"),
188                Cow::Borrowed("Break large operations into smaller chunks"),
189                Cow::Borrowed("Check system resources and performance"),
190            ],
191            ErrorCategory::RateLimit => vec![
192                Cow::Borrowed("Wait before retrying the request"),
193                Cow::Borrowed("Reduce request frequency"),
194                Cow::Borrowed("Check provider rate limit documentation"),
195            ],
196            ErrorCategory::ServiceUnavailable => vec![
197                Cow::Borrowed("The service is temporarily unavailable"),
198                Cow::Borrowed("Retry after a brief delay"),
199                Cow::Borrowed("Check service status page if available"),
200            ],
201            ErrorCategory::CircuitOpen => vec![
202                Cow::Borrowed("This tool has been temporarily disabled due to repeated failures"),
203                Cow::Borrowed("Wait for the circuit breaker cooldown period"),
204                Cow::Borrowed("Try an alternative approach"),
205            ],
206            ErrorCategory::Authentication => vec![
207                Cow::Borrowed("Verify your API key or credentials"),
208                Cow::Borrowed("Check that your account is active and has sufficient permissions"),
209                Cow::Borrowed("Ensure environment variables for API keys are set correctly"),
210            ],
211            ErrorCategory::InvalidParameters => vec![
212                Cow::Borrowed("Check parameter names and types against the tool schema"),
213                Cow::Borrowed("Ensure required parameters are provided"),
214                Cow::Borrowed("Verify parameter values are within acceptable ranges"),
215            ],
216            ErrorCategory::ToolNotFound => vec![
217                Cow::Borrowed("Verify the tool name is spelled correctly"),
218                Cow::Borrowed("Check if the tool is available in the current context"),
219            ],
220            ErrorCategory::ResourceNotFound => vec![
221                Cow::Borrowed("Verify file paths and resource locations"),
222                Cow::Borrowed("Check if files exist and are accessible"),
223                Cow::Borrowed("Use list_dir to explore available resources"),
224            ],
225            ErrorCategory::PermissionDenied => vec![
226                Cow::Borrowed("Check file permissions and access rights"),
227                Cow::Borrowed("Ensure workspace boundaries are respected"),
228            ],
229            ErrorCategory::PolicyViolation => vec![
230                Cow::Borrowed("Review workspace policies and restrictions"),
231                Cow::Borrowed("Use alternative tools that comply with policies"),
232            ],
233            ErrorCategory::PlanModeViolation => vec![
234                Cow::Borrowed("This operation is not allowed in plan/read-only mode"),
235                Cow::Borrowed("Exit plan mode to perform mutating operations"),
236            ],
237            ErrorCategory::SandboxFailure => vec![
238                Cow::Borrowed("The sandbox denied this operation"),
239                Cow::Borrowed("Check sandbox configuration and permissions"),
240            ],
241            ErrorCategory::ResourceExhausted => vec![
242                Cow::Borrowed("Check your account usage limits and billing status"),
243                Cow::Borrowed("Review resource consumption and optimize if possible"),
244            ],
245            ErrorCategory::Cancelled => vec![Cow::Borrowed("The operation was cancelled")],
246            ErrorCategory::ExecutionError => vec![
247                Cow::Borrowed("Review error details for specific issues"),
248                Cow::Borrowed("Check tool documentation for known limitations"),
249            ],
250        }
251    }
252
253    /// Get a concise, user-friendly label for this error category.
254    #[must_use]
255    pub const fn user_label(&self) -> &'static str {
256        match self {
257            ErrorCategory::Network => "Network error",
258            ErrorCategory::Timeout => "Request timed out",
259            ErrorCategory::RateLimit => "Rate limit exceeded",
260            ErrorCategory::ServiceUnavailable => "Service temporarily unavailable",
261            ErrorCategory::CircuitOpen => "Tool temporarily disabled",
262            ErrorCategory::Authentication => "Authentication failed",
263            ErrorCategory::InvalidParameters => "Invalid parameters",
264            ErrorCategory::ToolNotFound => "Tool not found",
265            ErrorCategory::ResourceNotFound => "Resource not found",
266            ErrorCategory::PermissionDenied => "Permission denied",
267            ErrorCategory::PolicyViolation => "Blocked by policy",
268            ErrorCategory::PlanModeViolation => "Not allowed in plan mode",
269            ErrorCategory::SandboxFailure => "Sandbox denied",
270            ErrorCategory::ResourceExhausted => "Resource limit reached",
271            ErrorCategory::Cancelled => "Operation cancelled",
272            ErrorCategory::ExecutionError => "Execution failed",
273        }
274    }
275}
276
277impl fmt::Display for ErrorCategory {
278    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
279        f.write_str(self.user_label())
280    }
281}
282
283// ---------------------------------------------------------------------------
284// Classify from anyhow::Error (string-based fallback for erased types)
285// ---------------------------------------------------------------------------
286
287/// Classify an `anyhow::Error` into a canonical `ErrorCategory`.
288///
289/// This uses string matching as a last resort when the original error type has
290/// been erased through `anyhow` wrapping. Typed conversions (e.g., `From<LLMError>`)
291/// should be preferred where the original error type is available.
292#[must_use]
293pub fn classify_anyhow_error(err: &anyhow::Error) -> ErrorCategory {
294    let msg = err.to_string().to_ascii_lowercase();
295    classify_error_message(&msg)
296}
297
298/// Classify an error message string into an `ErrorCategory`.
299///
300/// Marker groups are checked in priority order to handle overlapping patterns
301/// (e.g., "tool permission denied by policy" → `PolicyViolation`, not `PermissionDenied`).
302#[inline]
303#[must_use]
304pub fn classify_error_message(msg: &str) -> ErrorCategory {
305    let msg = if msg.as_bytes().iter().any(|b| b.is_ascii_uppercase()) {
306        Cow::Owned(msg.to_ascii_lowercase())
307    } else {
308        Cow::Borrowed(msg)
309    };
310
311    // --- Priority 1: Policy violations (before permission checks) ---
312    if contains_any(
313        &msg,
314        &[
315            "policy violation",
316            "denied by policy",
317            "tool permission denied",
318            "safety validation failed",
319            "not allowed in plan mode",
320            "only available when plan mode is active",
321            "workspace boundary",
322            "blocked by policy",
323        ],
324    ) {
325        return ErrorCategory::PolicyViolation;
326    }
327
328    // --- Priority 2: Plan mode violations ---
329    if contains_any(
330        &msg,
331        &["plan mode", "read-only mode", "plan_mode_violation"],
332    ) {
333        return ErrorCategory::PlanModeViolation;
334    }
335
336    // --- Priority 3: Authentication / Authorization ---
337    if contains_any(
338        &msg,
339        &[
340            "invalid api key",
341            "authentication failed",
342            "unauthorized",
343            "401",
344            "invalid credentials",
345        ],
346    ) {
347        return ErrorCategory::Authentication;
348    }
349
350    // --- Priority 4: Non-retryable resource exhaustion (billing, quotas) ---
351    if contains_any(
352        &msg,
353        &[
354            "weekly usage limit",
355            "daily usage limit",
356            "monthly spending limit",
357            "insufficient credits",
358            "quota exceeded",
359            "billing",
360            "payment required",
361        ],
362    ) {
363        return ErrorCategory::ResourceExhausted;
364    }
365
366    // --- Priority 5: Invalid parameters ---
367    if contains_any(
368        &msg,
369        &[
370            "invalid argument",
371            "invalid parameters",
372            "invalid type",
373            "malformed",
374            "failed to parse arguments",
375            "failed to parse argument",
376            "missing required",
377            "at least one item is required",
378            "is required for",
379            "schema validation",
380            "argument validation failed",
381            "unknown field",
382            "unknown variant",
383            "expected struct",
384            "expected enum",
385            "type mismatch",
386            "must be an absolute path",
387            "not parseable",
388            "parseable as",
389        ],
390    ) {
391        return ErrorCategory::InvalidParameters;
392    }
393
394    // --- Priority 6: Tool not found ---
395    if contains_any(
396        &msg,
397        &[
398            "tool not found",
399            "unknown tool",
400            "unsupported tool",
401            "no such tool",
402        ],
403    ) {
404        return ErrorCategory::ToolNotFound;
405    }
406
407    // --- Priority 7: Resource not found ---
408    if contains_any(
409        &msg,
410        &[
411            "no such file",
412            "no such directory",
413            "file not found",
414            "directory not found",
415            "resource not found",
416            "path not found",
417            "does not exist",
418            "enoent",
419        ],
420    ) {
421        return ErrorCategory::ResourceNotFound;
422    }
423
424    // --- Priority 8: Permission denied (OS-level) ---
425    if contains_any(
426        &msg,
427        &[
428            "permission denied",
429            "access denied",
430            "operation not permitted",
431            "eacces",
432            "eperm",
433            "forbidden",
434            "403",
435        ],
436    ) {
437        return ErrorCategory::PermissionDenied;
438    }
439
440    // --- Priority 9: Cancellation ---
441    if contains_any(&msg, &["cancelled", "interrupted", "canceled"]) {
442        return ErrorCategory::Cancelled;
443    }
444
445    // --- Priority 10: Circuit breaker ---
446    if contains_any(&msg, &["circuit breaker", "circuit open"]) {
447        return ErrorCategory::CircuitOpen;
448    }
449
450    // --- Priority 11: Sandbox ---
451    if contains_any(&msg, &["sandbox denied", "sandbox failure"]) {
452        return ErrorCategory::SandboxFailure;
453    }
454
455    // --- Priority 12: Rate limiting (before general network) ---
456    if contains_any(&msg, &["rate limit", "too many requests", "429", "throttl"]) {
457        return ErrorCategory::RateLimit;
458    }
459
460    // --- Priority 13: Timeout ---
461    if contains_any(&msg, &["timeout", "timed out", "deadline exceeded"]) {
462        return ErrorCategory::Timeout;
463    }
464
465    // --- Priority 14: Provider transient response-shape failures ---
466    if contains_any(
467        &msg,
468        &[
469            "invalid response format: missing choices",
470            "invalid response format: missing message",
471            "missing choices in response",
472            "missing message in choice",
473            "no choices in response",
474            "invalid response from ",
475            "empty response body",
476            "response did not contain",
477            "unexpected response format",
478            "failed to parse response",
479        ],
480    ) {
481        return ErrorCategory::ServiceUnavailable;
482    }
483
484    // --- Priority 15: Service unavailable (HTTP 5xx and related) ---
485    if contains_any(
486        &msg,
487        &[
488            "service unavailable",
489            "temporarily unavailable",
490            "internal server error",
491            "bad gateway",
492            "gateway timeout",
493            "overloaded",
494            "500",
495            "502",
496            "503",
497            "504",
498        ],
499    ) {
500        return ErrorCategory::ServiceUnavailable;
501    }
502
503    // --- Priority 16: Network (connectivity, DNS, transport) ---
504    if contains_any(
505        &msg,
506        &[
507            "network",
508            "connection reset",
509            "connection refused",
510            "broken pipe",
511            "dns",
512            "name resolution",
513            "try again",
514            "retry later",
515            "upstream connect error",
516            "tls handshake",
517            "socket hang up",
518            "econnreset",
519            "etimedout",
520        ],
521    ) {
522        return ErrorCategory::Network;
523    }
524
525    // --- Priority 17: Resource exhausted (memory, disk) ---
526    if contains_any(&msg, &["out of memory", "disk full", "no space left"]) {
527        return ErrorCategory::ResourceExhausted;
528    }
529
530    // --- Fallback ---
531    ErrorCategory::ExecutionError
532}
533
534/// Check if an LLM error message is retryable (used by the LLM request retry loop).
535///
536/// This is a focused classifier for LLM provider errors, combining
537/// non-retryable and retryable marker checks for the request retry path.
538#[inline]
539#[must_use]
540pub fn is_retryable_llm_error_message(msg: &str) -> bool {
541    let category = classify_error_message(msg);
542    category.is_retryable()
543}
544
545#[inline]
546fn contains_any(message: &str, markers: &[&str]) -> bool {
547    markers.iter().any(|marker| message.contains(marker))
548}
549
550// ---------------------------------------------------------------------------
551// Typed conversions from known error types
552// ---------------------------------------------------------------------------
553
554impl From<&crate::llm::LLMError> for ErrorCategory {
555    fn from(err: &crate::llm::LLMError) -> Self {
556        match err {
557            crate::llm::LLMError::Authentication { .. } => ErrorCategory::Authentication,
558            crate::llm::LLMError::RateLimit { metadata } => {
559                classify_llm_metadata(metadata.as_deref(), ErrorCategory::RateLimit)
560            }
561            crate::llm::LLMError::InvalidRequest { .. } => ErrorCategory::InvalidParameters,
562            crate::llm::LLMError::Network { .. } => ErrorCategory::Network,
563            crate::llm::LLMError::Provider { message, metadata } => {
564                let metadata_category =
565                    classify_llm_metadata(metadata.as_deref(), ErrorCategory::ExecutionError);
566                if metadata_category != ErrorCategory::ExecutionError {
567                    return metadata_category;
568                }
569
570                // Check metadata status code first for precise classification
571                if let Some(meta) = metadata
572                    && let Some(status) = meta.status
573                {
574                    return match status {
575                        401 => ErrorCategory::Authentication,
576                        403 => ErrorCategory::PermissionDenied,
577                        404 => ErrorCategory::ResourceNotFound,
578                        429 => ErrorCategory::RateLimit,
579                        400 => ErrorCategory::InvalidParameters,
580                        500 | 502 | 503 | 504 => ErrorCategory::ServiceUnavailable,
581                        408 => ErrorCategory::Timeout,
582                        _ => classify_error_message(message),
583                    };
584                }
585                // Fall back to message-based classification
586                classify_error_message(message)
587            }
588        }
589    }
590}
591
592fn classify_llm_metadata(
593    metadata: Option<&crate::llm::LLMErrorMetadata>,
594    fallback: ErrorCategory,
595) -> ErrorCategory {
596    let Some(metadata) = metadata else {
597        return fallback;
598    };
599
600    let mut hint = String::new();
601    if let Some(code) = &metadata.code {
602        hint.push_str(code);
603        hint.push(' ');
604    }
605    if let Some(message) = &metadata.message {
606        hint.push_str(message);
607        hint.push(' ');
608    }
609    if let Some(status) = metadata.status {
610        let _ = write!(&mut hint, "{status}");
611    }
612
613    let classified = classify_error_message(&hint);
614    if classified == ErrorCategory::ExecutionError {
615        fallback
616    } else {
617        classified
618    }
619}
620
621#[cfg(test)]
622mod tests {
623    use super::*;
624
625    // --- classify_error_message tests ---
626
627    #[test]
628    fn policy_violation_takes_priority_over_permission() {
629        assert_eq!(
630            classify_error_message("tool permission denied by policy"),
631            ErrorCategory::PolicyViolation
632        );
633    }
634
635    #[test]
636    fn rate_limit_classified_correctly() {
637        assert_eq!(
638            classify_error_message("provider returned 429 Too Many Requests"),
639            ErrorCategory::RateLimit
640        );
641        assert_eq!(
642            classify_error_message("rate limit exceeded"),
643            ErrorCategory::RateLimit
644        );
645    }
646
647    #[test]
648    fn service_unavailable_is_classified() {
649        assert_eq!(
650            classify_error_message("503 service unavailable"),
651            ErrorCategory::ServiceUnavailable
652        );
653    }
654
655    #[test]
656    fn authentication_errors() {
657        assert_eq!(
658            classify_error_message("invalid api key provided"),
659            ErrorCategory::Authentication
660        );
661        assert_eq!(
662            classify_error_message("401 unauthorized"),
663            ErrorCategory::Authentication
664        );
665    }
666
667    #[test]
668    fn billing_errors_are_resource_exhausted() {
669        assert_eq!(
670            classify_error_message("you have reached your weekly usage limit"),
671            ErrorCategory::ResourceExhausted
672        );
673        assert_eq!(
674            classify_error_message("quota exceeded for this model"),
675            ErrorCategory::ResourceExhausted
676        );
677    }
678
679    #[test]
680    fn timeout_errors() {
681        assert_eq!(
682            classify_error_message("connection timeout"),
683            ErrorCategory::Timeout
684        );
685        assert_eq!(
686            classify_error_message("request timed out after 30s"),
687            ErrorCategory::Timeout
688        );
689    }
690
691    #[test]
692    fn network_errors() {
693        assert_eq!(
694            classify_error_message("connection reset by peer"),
695            ErrorCategory::Network
696        );
697        assert_eq!(
698            classify_error_message("dns name resolution failed"),
699            ErrorCategory::Network
700        );
701    }
702
703    #[test]
704    fn tool_not_found() {
705        assert_eq!(
706            classify_error_message("unknown tool: ask_questions"),
707            ErrorCategory::ToolNotFound
708        );
709    }
710
711    #[test]
712    fn resource_not_found() {
713        assert_eq!(
714            classify_error_message("no such file or directory: /tmp/missing"),
715            ErrorCategory::ResourceNotFound
716        );
717        assert_eq!(
718            classify_error_message("Path 'vtcode-core/src/agent' does not exist"),
719            ErrorCategory::ResourceNotFound
720        );
721    }
722
723    #[test]
724    fn permission_denied() {
725        assert_eq!(
726            classify_error_message("permission denied: /etc/shadow"),
727            ErrorCategory::PermissionDenied
728        );
729    }
730
731    #[test]
732    fn cancelled_operations() {
733        assert_eq!(
734            classify_error_message("operation cancelled by user"),
735            ErrorCategory::Cancelled
736        );
737    }
738
739    #[test]
740    fn plan_mode_violation() {
741        assert_eq!(
742            classify_error_message("not allowed in plan mode"),
743            ErrorCategory::PolicyViolation
744        );
745    }
746
747    #[test]
748    fn sandbox_failure() {
749        assert_eq!(
750            classify_error_message("sandbox denied this operation"),
751            ErrorCategory::SandboxFailure
752        );
753    }
754
755    #[test]
756    fn unknown_error_is_execution_error() {
757        assert_eq!(
758            classify_error_message("something went wrong"),
759            ErrorCategory::ExecutionError
760        );
761    }
762
763    #[test]
764    fn invalid_parameters() {
765        assert_eq!(
766            classify_error_message("invalid argument: missing path field"),
767            ErrorCategory::InvalidParameters
768        );
769        assert_eq!(
770            classify_error_message(
771                "Failed to parse arguments for read_file handler: invalid type: boolean `false`"
772            ),
773            ErrorCategory::InvalidParameters
774        );
775        assert_eq!(
776            classify_error_message("at least one item is required for 'create'"),
777            ErrorCategory::InvalidParameters
778        );
779        assert_eq!(
780            classify_error_message(
781                "structural pattern preflight failed: pattern is not parseable as Rust syntax"
782            ),
783            ErrorCategory::InvalidParameters
784        );
785    }
786
787    // --- Retryability tests ---
788
789    #[test]
790    fn retryable_categories() {
791        assert!(ErrorCategory::Network.is_retryable());
792        assert!(ErrorCategory::Timeout.is_retryable());
793        assert!(ErrorCategory::RateLimit.is_retryable());
794        assert!(ErrorCategory::ServiceUnavailable.is_retryable());
795        assert!(ErrorCategory::CircuitOpen.is_retryable());
796    }
797
798    #[test]
799    fn non_retryable_categories() {
800        assert!(!ErrorCategory::Authentication.is_retryable());
801        assert!(!ErrorCategory::InvalidParameters.is_retryable());
802        assert!(!ErrorCategory::PolicyViolation.is_retryable());
803        assert!(!ErrorCategory::ResourceExhausted.is_retryable());
804        assert!(!ErrorCategory::Cancelled.is_retryable());
805    }
806
807    #[test]
808    fn permanent_error_detection() {
809        assert!(ErrorCategory::Authentication.is_permanent());
810        assert!(ErrorCategory::PolicyViolation.is_permanent());
811        assert!(!ErrorCategory::Network.is_permanent());
812        assert!(!ErrorCategory::Timeout.is_permanent());
813    }
814
815    #[test]
816    fn llm_mistake_detection() {
817        assert!(ErrorCategory::InvalidParameters.is_llm_mistake());
818        assert!(!ErrorCategory::Network.is_llm_mistake());
819        assert!(!ErrorCategory::Timeout.is_llm_mistake());
820    }
821
822    // --- LLM error conversion ---
823
824    #[test]
825    fn llm_error_authentication_converts() {
826        let err = crate::llm::LLMError::Authentication {
827            message: "bad key".to_string(),
828            metadata: None,
829        };
830        assert_eq!(ErrorCategory::from(&err), ErrorCategory::Authentication);
831    }
832
833    #[test]
834    fn llm_error_rate_limit_converts() {
835        let err = crate::llm::LLMError::RateLimit { metadata: None };
836        assert_eq!(ErrorCategory::from(&err), ErrorCategory::RateLimit);
837    }
838
839    #[test]
840    fn llm_error_quota_exhaustion_converts() {
841        let err = crate::llm::LLMError::RateLimit {
842            metadata: Some(crate::llm::LLMErrorMetadata::new(
843                "openai",
844                Some(429),
845                Some("insufficient_quota".to_string()),
846                None,
847                None,
848                None,
849                Some("quota exceeded".to_string()),
850            )),
851        };
852
853        assert_eq!(ErrorCategory::from(&err), ErrorCategory::ResourceExhausted);
854    }
855
856    #[test]
857    fn llm_error_network_converts() {
858        let err = crate::llm::LLMError::Network {
859            message: "connection refused".to_string(),
860            metadata: None,
861        };
862        assert_eq!(ErrorCategory::from(&err), ErrorCategory::Network);
863    }
864
865    #[test]
866    fn llm_error_provider_with_status_code() {
867        use crate::llm::LLMErrorMetadata;
868        let err = crate::llm::LLMError::Provider {
869            message: "error".to_string(),
870            metadata: Some(LLMErrorMetadata::new(
871                "openai",
872                Some(503),
873                None,
874                None,
875                None,
876                None,
877                None,
878            )),
879        };
880        assert_eq!(ErrorCategory::from(&err), ErrorCategory::ServiceUnavailable);
881    }
882
883    #[test]
884    fn minimax_invalid_response_is_service_unavailable() {
885        assert_eq!(
886            classify_error_message("Invalid response from MiniMax: missing choices"),
887            ErrorCategory::ServiceUnavailable
888        );
889        assert_eq!(
890            classify_error_message("Invalid response format: missing message"),
891            ErrorCategory::ServiceUnavailable
892        );
893    }
894
895    // --- is_retryable_llm_error_message ---
896
897    #[test]
898    fn retryable_llm_messages() {
899        assert!(is_retryable_llm_error_message("429 too many requests"));
900        assert!(is_retryable_llm_error_message("500 internal server error"));
901        assert!(is_retryable_llm_error_message("connection timeout"));
902        assert!(is_retryable_llm_error_message("network error"));
903    }
904
905    #[test]
906    fn non_retryable_llm_messages() {
907        assert!(!is_retryable_llm_error_message("invalid api key"));
908        assert!(!is_retryable_llm_error_message(
909            "weekly usage limit reached"
910        ));
911        assert!(!is_retryable_llm_error_message("permission denied"));
912    }
913
914    // --- Recovery suggestions ---
915
916    #[test]
917    fn recovery_suggestions_non_empty() {
918        for cat in [
919            ErrorCategory::Network,
920            ErrorCategory::Timeout,
921            ErrorCategory::RateLimit,
922            ErrorCategory::Authentication,
923            ErrorCategory::InvalidParameters,
924            ErrorCategory::ToolNotFound,
925            ErrorCategory::ResourceNotFound,
926            ErrorCategory::PermissionDenied,
927            ErrorCategory::PolicyViolation,
928            ErrorCategory::ExecutionError,
929        ] {
930            assert!(
931                !cat.recovery_suggestions().is_empty(),
932                "Missing recovery suggestions for {:?}",
933                cat
934            );
935        }
936    }
937
938    // --- User label ---
939
940    #[test]
941    fn user_labels_are_non_empty() {
942        assert!(!ErrorCategory::Network.user_label().is_empty());
943        assert!(!ErrorCategory::ExecutionError.user_label().is_empty());
944    }
945
946    // --- Display ---
947
948    #[test]
949    fn display_matches_user_label() {
950        assert_eq!(
951            format!("{}", ErrorCategory::RateLimit),
952            ErrorCategory::RateLimit.user_label()
953        );
954    }
955}