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