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    /// Planning workflow violation (mutating tool without read-only capabilities)
56    PlanningPolicyViolation,
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::PlanningPolicyViolation
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::PlanningPolicyViolation => vec![
234                Cow::Borrowed(
235                    "This operation is not allowed in the Planning workflow with read-only permissions",
236                ),
237                Cow::Borrowed("Exit the Planning workflow to perform mutating operations"),
238            ],
239            ErrorCategory::SandboxFailure => vec![
240                Cow::Borrowed("The sandbox denied this operation"),
241                Cow::Borrowed("Check sandbox configuration and permissions"),
242            ],
243            ErrorCategory::ResourceExhausted => vec![
244                Cow::Borrowed("Check your account usage limits and billing status"),
245                Cow::Borrowed("Review resource consumption and optimize if possible"),
246            ],
247            ErrorCategory::Cancelled => vec![Cow::Borrowed("The operation was cancelled")],
248            ErrorCategory::ExecutionError => vec![
249                Cow::Borrowed("Review error details for specific issues"),
250                Cow::Borrowed("Check tool documentation for known limitations"),
251            ],
252        }
253    }
254
255    /// Get a concise, user-friendly label for this error category.
256    #[must_use]
257    pub const fn user_label(&self) -> &'static str {
258        match self {
259            ErrorCategory::Network => "Network error",
260            ErrorCategory::Timeout => "Request timed out",
261            ErrorCategory::RateLimit => "Rate limit exceeded",
262            ErrorCategory::ServiceUnavailable => "Service temporarily unavailable",
263            ErrorCategory::CircuitOpen => "Tool temporarily disabled",
264            ErrorCategory::Authentication => "Authentication failed",
265            ErrorCategory::InvalidParameters => "Invalid parameters",
266            ErrorCategory::ToolNotFound => "Tool not found",
267            ErrorCategory::ResourceNotFound => "Resource not found",
268            ErrorCategory::PermissionDenied => "Permission denied",
269            ErrorCategory::PolicyViolation => "Blocked by policy",
270            ErrorCategory::PlanningPolicyViolation => "Not allowed in planning workflow",
271            ErrorCategory::SandboxFailure => "Sandbox denied",
272            ErrorCategory::ResourceExhausted => "Resource limit reached",
273            ErrorCategory::Cancelled => "Operation cancelled",
274            ErrorCategory::ExecutionError => "Execution failed",
275        }
276    }
277}
278
279impl fmt::Display for ErrorCategory {
280    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
281        f.write_str(self.user_label())
282    }
283}
284
285// ---------------------------------------------------------------------------
286// Classify from anyhow::Error (string-based fallback for erased types)
287// ---------------------------------------------------------------------------
288
289/// Classify an `anyhow::Error` into a canonical `ErrorCategory`.
290///
291/// This uses string matching as a last resort when the original error type has
292/// been erased through `anyhow` wrapping. Typed conversions (e.g., `From<LLMError>`)
293/// should be preferred where the original error type is available.
294#[must_use]
295pub fn classify_anyhow_error(err: &anyhow::Error) -> ErrorCategory {
296    let msg = err.to_string().to_ascii_lowercase();
297    classify_error_message(&msg)
298}
299
300/// Classify an error message string into an `ErrorCategory`.
301///
302/// Marker groups are checked in priority order to handle overlapping patterns
303/// (e.g., "tool permission denied by policy" → `PolicyViolation`, not `PermissionDenied`).
304#[inline]
305#[must_use]
306pub fn classify_error_message(msg: &str) -> ErrorCategory {
307    let msg = if msg.as_bytes().iter().any(|b| b.is_ascii_uppercase()) {
308        Cow::Owned(msg.to_ascii_lowercase())
309    } else {
310        Cow::Borrowed(msg)
311    };
312
313    // --- Priority 1: Policy violations (before permission checks) ---
314    if contains_any(
315        &msg,
316        &[
317            "policy violation",
318            "denied by policy",
319            "tool permission denied",
320            "safety validation failed",
321            "not allowed in planning workflow",
322            "only available when planning workflow is active",
323            "workspace boundary",
324            "blocked by policy",
325        ],
326    ) {
327        return ErrorCategory::PolicyViolation;
328    }
329
330    // --- Priority 2: Planning workflow violations ---
331    if contains_any(
332        &msg,
333        &[
334            "planning workflow",
335            "read-only permissions",
336            concat!("read-only ", "mode"),
337            "planning_policy_violation",
338        ],
339    ) {
340        return ErrorCategory::PlanningPolicyViolation;
341    }
342
343    // --- Priority 3: Authentication / Authorization ---
344    if contains_any(
345        &msg,
346        &[
347            "invalid api key",
348            "authentication failed",
349            "unauthorized",
350            "401",
351            "invalid credentials",
352        ],
353    ) {
354        return ErrorCategory::Authentication;
355    }
356
357    // --- Priority 4: Non-retryable resource exhaustion (billing, quotas) ---
358    if contains_any(
359        &msg,
360        &[
361            "weekly usage limit",
362            "daily usage limit",
363            "monthly spending limit",
364            "insufficient credits",
365            "quota exceeded",
366            "billing",
367            "payment required",
368        ],
369    ) {
370        return ErrorCategory::ResourceExhausted;
371    }
372
373    // --- Priority 5: Invalid parameters ---
374    if contains_any(
375        &msg,
376        &[
377            "invalid argument",
378            "invalid parameters",
379            "invalid type",
380            "malformed",
381            "failed to parse arguments",
382            "failed to parse argument",
383            "missing required",
384            "at least one item is required",
385            "is required for",
386            "schema validation",
387            "argument validation failed",
388            "unknown field",
389            "unknown variant",
390            "expected struct",
391            "expected enum",
392            "type mismatch",
393            "must be an absolute path",
394            "not parseable",
395            "parseable as",
396        ],
397    ) {
398        return ErrorCategory::InvalidParameters;
399    }
400
401    // --- Priority 6: Tool not found ---
402    if contains_any(
403        &msg,
404        &[
405            "tool not found",
406            "unknown tool",
407            "unsupported tool",
408            "no such tool",
409        ],
410    ) {
411        return ErrorCategory::ToolNotFound;
412    }
413
414    // --- Priority 7: Resource not found ---
415    if contains_any(
416        &msg,
417        &[
418            "no such file",
419            "no such directory",
420            "file not found",
421            "directory not found",
422            "resource not found",
423            "path not found",
424            "does not exist",
425            "enoent",
426        ],
427    ) {
428        return ErrorCategory::ResourceNotFound;
429    }
430
431    // --- Priority 8: Permission denied (OS-level) ---
432    if contains_any(
433        &msg,
434        &[
435            "permission denied",
436            "access denied",
437            "operation not permitted",
438            "eacces",
439            "eperm",
440            "forbidden",
441            "403",
442        ],
443    ) {
444        return ErrorCategory::PermissionDenied;
445    }
446
447    // --- Priority 9: Cancellation ---
448    if contains_any(&msg, &["cancelled", "interrupted", "canceled"]) {
449        return ErrorCategory::Cancelled;
450    }
451
452    // --- Priority 10: Circuit breaker ---
453    if contains_any(&msg, &["circuit breaker", "circuit open"]) {
454        return ErrorCategory::CircuitOpen;
455    }
456
457    // --- Priority 11: Sandbox ---
458    if contains_any(&msg, &["sandbox denied", "sandbox failure"]) {
459        return ErrorCategory::SandboxFailure;
460    }
461
462    // --- Priority 12: Rate limiting (before general network) ---
463    if contains_any(&msg, &["rate limit", "too many requests", "429", "throttl"]) {
464        return ErrorCategory::RateLimit;
465    }
466
467    // --- Priority 13: Timeout ---
468    if contains_any(&msg, &["timeout", "timed out", "deadline exceeded"]) {
469        return ErrorCategory::Timeout;
470    }
471
472    // --- Priority 14: Provider transient response-shape failures ---
473    if contains_any(
474        &msg,
475        &[
476            "invalid response format: missing choices",
477            "invalid response format: missing message",
478            "missing choices in response",
479            "missing message in choice",
480            "no choices in response",
481            "invalid response from ",
482            "empty response body",
483            "response did not contain",
484            "unexpected response format",
485            "failed to parse response",
486        ],
487    ) {
488        return ErrorCategory::ServiceUnavailable;
489    }
490
491    // --- Priority 15: Service unavailable (HTTP 5xx and related) ---
492    if contains_any(
493        &msg,
494        &[
495            "service unavailable",
496            "temporarily unavailable",
497            "internal server error",
498            "bad gateway",
499            "gateway timeout",
500            "overloaded",
501            "500",
502            "502",
503            "503",
504            "504",
505        ],
506    ) {
507        return ErrorCategory::ServiceUnavailable;
508    }
509
510    // --- Priority 16: Network (connectivity, DNS, transport) ---
511    if contains_any(
512        &msg,
513        &[
514            "network",
515            "connection reset",
516            "connection refused",
517            "broken pipe",
518            "dns",
519            "name resolution",
520            "try again",
521            "retry later",
522            "upstream connect error",
523            "tls handshake",
524            "socket hang up",
525            "econnreset",
526            "etimedout",
527        ],
528    ) {
529        return ErrorCategory::Network;
530    }
531
532    // --- Priority 17: Resource exhausted (memory, disk) ---
533    if contains_any(&msg, &["out of memory", "disk full", "no space left"]) {
534        return ErrorCategory::ResourceExhausted;
535    }
536
537    // --- Fallback ---
538    ErrorCategory::ExecutionError
539}
540
541/// Check if an LLM error message is retryable (used by the LLM request retry loop).
542///
543/// This is a focused classifier for LLM provider errors, combining
544/// non-retryable and retryable marker checks for the request retry path.
545#[inline]
546#[must_use]
547pub fn is_retryable_llm_error_message(msg: &str) -> bool {
548    let category = classify_error_message(msg);
549    category.is_retryable()
550}
551
552#[inline]
553fn contains_any(message: &str, markers: &[&str]) -> bool {
554    markers.iter().any(|marker| message.contains(marker))
555}
556
557// ---------------------------------------------------------------------------
558// Typed conversions from known error types
559// ---------------------------------------------------------------------------
560
561impl From<&crate::llm::LLMError> for ErrorCategory {
562    fn from(err: &crate::llm::LLMError) -> Self {
563        match err {
564            crate::llm::LLMError::Authentication { .. } => ErrorCategory::Authentication,
565            crate::llm::LLMError::RateLimit { metadata } => {
566                classify_llm_metadata(metadata.as_deref(), ErrorCategory::RateLimit)
567            }
568            crate::llm::LLMError::InvalidRequest { .. } => ErrorCategory::InvalidParameters,
569            crate::llm::LLMError::Network { .. } => ErrorCategory::Network,
570            crate::llm::LLMError::Provider { message, metadata } => {
571                let metadata_category =
572                    classify_llm_metadata(metadata.as_deref(), ErrorCategory::ExecutionError);
573                if metadata_category != ErrorCategory::ExecutionError {
574                    return metadata_category;
575                }
576
577                // Check metadata status code first for precise classification
578                if let Some(meta) = metadata
579                    && let Some(status) = meta.status
580                {
581                    return match status {
582                        401 => ErrorCategory::Authentication,
583                        403 => ErrorCategory::PermissionDenied,
584                        404 => ErrorCategory::ResourceNotFound,
585                        429 => ErrorCategory::RateLimit,
586                        400 => ErrorCategory::InvalidParameters,
587                        500 | 502 | 503 | 504 => ErrorCategory::ServiceUnavailable,
588                        408 => ErrorCategory::Timeout,
589                        _ => classify_error_message(message),
590                    };
591                }
592                // Fall back to message-based classification
593                classify_error_message(message)
594            }
595        }
596    }
597}
598
599fn classify_llm_metadata(
600    metadata: Option<&crate::llm::LLMErrorMetadata>,
601    fallback: ErrorCategory,
602) -> ErrorCategory {
603    let Some(metadata) = metadata else {
604        return fallback;
605    };
606
607    let mut hint = String::new();
608    if let Some(code) = &metadata.code {
609        hint.push_str(code);
610        hint.push(' ');
611    }
612    if let Some(message) = &metadata.message {
613        hint.push_str(message);
614        hint.push(' ');
615    }
616    if let Some(status) = metadata.status {
617        let _ = write!(&mut hint, "{status}");
618    }
619
620    let classified = classify_error_message(&hint);
621    if classified == ErrorCategory::ExecutionError {
622        fallback
623    } else {
624        classified
625    }
626}
627
628#[cfg(test)]
629mod tests {
630    use super::*;
631
632    // --- classify_error_message tests ---
633
634    #[test]
635    fn policy_violation_takes_priority_over_permission() {
636        assert_eq!(
637            classify_error_message("tool permission denied by policy"),
638            ErrorCategory::PolicyViolation
639        );
640    }
641
642    #[test]
643    fn rate_limit_classified_correctly() {
644        assert_eq!(
645            classify_error_message("provider returned 429 Too Many Requests"),
646            ErrorCategory::RateLimit
647        );
648        assert_eq!(
649            classify_error_message("rate limit exceeded"),
650            ErrorCategory::RateLimit
651        );
652    }
653
654    #[test]
655    fn service_unavailable_is_classified() {
656        assert_eq!(
657            classify_error_message("503 service unavailable"),
658            ErrorCategory::ServiceUnavailable
659        );
660    }
661
662    #[test]
663    fn authentication_errors() {
664        assert_eq!(
665            classify_error_message("invalid api key provided"),
666            ErrorCategory::Authentication
667        );
668        assert_eq!(
669            classify_error_message("401 unauthorized"),
670            ErrorCategory::Authentication
671        );
672    }
673
674    #[test]
675    fn billing_errors_are_resource_exhausted() {
676        assert_eq!(
677            classify_error_message("you have reached your weekly usage limit"),
678            ErrorCategory::ResourceExhausted
679        );
680        assert_eq!(
681            classify_error_message("quota exceeded for this model"),
682            ErrorCategory::ResourceExhausted
683        );
684    }
685
686    #[test]
687    fn timeout_errors() {
688        assert_eq!(
689            classify_error_message("connection timeout"),
690            ErrorCategory::Timeout
691        );
692        assert_eq!(
693            classify_error_message("request timed out after 30s"),
694            ErrorCategory::Timeout
695        );
696    }
697
698    #[test]
699    fn network_errors() {
700        assert_eq!(
701            classify_error_message("connection reset by peer"),
702            ErrorCategory::Network
703        );
704        assert_eq!(
705            classify_error_message("dns name resolution failed"),
706            ErrorCategory::Network
707        );
708    }
709
710    #[test]
711    fn tool_not_found() {
712        assert_eq!(
713            classify_error_message("unknown tool: ask_questions"),
714            ErrorCategory::ToolNotFound
715        );
716    }
717
718    #[test]
719    fn resource_not_found() {
720        assert_eq!(
721            classify_error_message("no such file or directory: /tmp/missing"),
722            ErrorCategory::ResourceNotFound
723        );
724        assert_eq!(
725            classify_error_message("Path 'vtcode-core/src/agent' does not exist"),
726            ErrorCategory::ResourceNotFound
727        );
728    }
729
730    #[test]
731    fn permission_denied() {
732        assert_eq!(
733            classify_error_message("permission denied: /etc/shadow"),
734            ErrorCategory::PermissionDenied
735        );
736    }
737
738    #[test]
739    fn cancelled_operations() {
740        assert_eq!(
741            classify_error_message("operation cancelled by user"),
742            ErrorCategory::Cancelled
743        );
744    }
745
746    #[test]
747    fn planning_policy_violation() {
748        assert_eq!(
749            classify_error_message("not allowed in planning workflow"),
750            ErrorCategory::PolicyViolation
751        );
752    }
753
754    #[test]
755    fn sandbox_failure() {
756        assert_eq!(
757            classify_error_message("sandbox denied this operation"),
758            ErrorCategory::SandboxFailure
759        );
760    }
761
762    #[test]
763    fn unknown_error_is_execution_error() {
764        assert_eq!(
765            classify_error_message("something went wrong"),
766            ErrorCategory::ExecutionError
767        );
768    }
769
770    #[test]
771    fn invalid_parameters() {
772        assert_eq!(
773            classify_error_message("invalid argument: missing path field"),
774            ErrorCategory::InvalidParameters
775        );
776        assert_eq!(
777            classify_error_message(
778                "Failed to parse arguments for read_file handler: invalid type: boolean `false`"
779            ),
780            ErrorCategory::InvalidParameters
781        );
782        assert_eq!(
783            classify_error_message("at least one item is required for 'create'"),
784            ErrorCategory::InvalidParameters
785        );
786        assert_eq!(
787            classify_error_message(
788                "structural pattern preflight failed: pattern is not parseable as Rust syntax"
789            ),
790            ErrorCategory::InvalidParameters
791        );
792    }
793
794    // --- Retryability tests ---
795
796    #[test]
797    fn retryable_categories() {
798        assert!(ErrorCategory::Network.is_retryable());
799        assert!(ErrorCategory::Timeout.is_retryable());
800        assert!(ErrorCategory::RateLimit.is_retryable());
801        assert!(ErrorCategory::ServiceUnavailable.is_retryable());
802        assert!(ErrorCategory::CircuitOpen.is_retryable());
803    }
804
805    #[test]
806    fn non_retryable_categories() {
807        assert!(!ErrorCategory::Authentication.is_retryable());
808        assert!(!ErrorCategory::InvalidParameters.is_retryable());
809        assert!(!ErrorCategory::PolicyViolation.is_retryable());
810        assert!(!ErrorCategory::ResourceExhausted.is_retryable());
811        assert!(!ErrorCategory::Cancelled.is_retryable());
812    }
813
814    #[test]
815    fn permanent_error_detection() {
816        assert!(ErrorCategory::Authentication.is_permanent());
817        assert!(ErrorCategory::PolicyViolation.is_permanent());
818        assert!(!ErrorCategory::Network.is_permanent());
819        assert!(!ErrorCategory::Timeout.is_permanent());
820    }
821
822    #[test]
823    fn llm_mistake_detection() {
824        assert!(ErrorCategory::InvalidParameters.is_llm_mistake());
825        assert!(!ErrorCategory::Network.is_llm_mistake());
826        assert!(!ErrorCategory::Timeout.is_llm_mistake());
827    }
828
829    // --- LLM error conversion ---
830
831    #[test]
832    fn llm_error_authentication_converts() {
833        let err = crate::llm::LLMError::Authentication {
834            message: "bad key".to_string(),
835            metadata: None,
836        };
837        assert_eq!(ErrorCategory::from(&err), ErrorCategory::Authentication);
838    }
839
840    #[test]
841    fn llm_error_rate_limit_converts() {
842        let err = crate::llm::LLMError::RateLimit { metadata: None };
843        assert_eq!(ErrorCategory::from(&err), ErrorCategory::RateLimit);
844    }
845
846    #[test]
847    fn llm_error_quota_exhaustion_converts() {
848        let err = crate::llm::LLMError::RateLimit {
849            metadata: Some(crate::llm::LLMErrorMetadata::new(
850                "openai",
851                Some(429),
852                Some("insufficient_quota".to_string()),
853                None,
854                None,
855                None,
856                Some("quota exceeded".to_string()),
857            )),
858        };
859
860        assert_eq!(ErrorCategory::from(&err), ErrorCategory::ResourceExhausted);
861    }
862
863    #[test]
864    fn llm_error_network_converts() {
865        let err = crate::llm::LLMError::Network {
866            message: "connection refused".to_string(),
867            metadata: None,
868        };
869        assert_eq!(ErrorCategory::from(&err), ErrorCategory::Network);
870    }
871
872    #[test]
873    fn llm_error_provider_with_status_code() {
874        use crate::llm::LLMErrorMetadata;
875        let err = crate::llm::LLMError::Provider {
876            message: "error".to_string(),
877            metadata: Some(LLMErrorMetadata::new(
878                "openai",
879                Some(503),
880                None,
881                None,
882                None,
883                None,
884                None,
885            )),
886        };
887        assert_eq!(ErrorCategory::from(&err), ErrorCategory::ServiceUnavailable);
888    }
889
890    #[test]
891    fn minimax_invalid_response_is_service_unavailable() {
892        assert_eq!(
893            classify_error_message("Invalid response from MiniMax: missing choices"),
894            ErrorCategory::ServiceUnavailable
895        );
896        assert_eq!(
897            classify_error_message("Invalid response format: missing message"),
898            ErrorCategory::ServiceUnavailable
899        );
900    }
901
902    // --- is_retryable_llm_error_message ---
903
904    #[test]
905    fn retryable_llm_messages() {
906        assert!(is_retryable_llm_error_message("429 too many requests"));
907        assert!(is_retryable_llm_error_message("500 internal server error"));
908        assert!(is_retryable_llm_error_message("connection timeout"));
909        assert!(is_retryable_llm_error_message("network error"));
910    }
911
912    #[test]
913    fn non_retryable_llm_messages() {
914        assert!(!is_retryable_llm_error_message("invalid api key"));
915        assert!(!is_retryable_llm_error_message(
916            "weekly usage limit reached"
917        ));
918        assert!(!is_retryable_llm_error_message("permission denied"));
919    }
920
921    // --- Recovery suggestions ---
922
923    #[test]
924    fn recovery_suggestions_non_empty() {
925        for cat in [
926            ErrorCategory::Network,
927            ErrorCategory::Timeout,
928            ErrorCategory::RateLimit,
929            ErrorCategory::Authentication,
930            ErrorCategory::InvalidParameters,
931            ErrorCategory::ToolNotFound,
932            ErrorCategory::ResourceNotFound,
933            ErrorCategory::PermissionDenied,
934            ErrorCategory::PolicyViolation,
935            ErrorCategory::ExecutionError,
936        ] {
937            assert!(
938                !cat.recovery_suggestions().is_empty(),
939                "Missing recovery suggestions for {cat:?}"
940            );
941        }
942    }
943
944    // --- User label ---
945
946    #[test]
947    fn user_labels_are_non_empty() {
948        assert!(!ErrorCategory::Network.user_label().is_empty());
949        assert!(!ErrorCategory::ExecutionError.user_label().is_empty());
950    }
951
952    // --- Display ---
953
954    #[test]
955    fn display_matches_user_label() {
956        assert_eq!(
957            format!("{}", ErrorCategory::RateLimit),
958            ErrorCategory::RateLimit.user_label()
959        );
960    }
961}