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            "enoent",
407        ],
408    ) {
409        return ErrorCategory::ResourceNotFound;
410    }
411
412    // --- Priority 8: Permission denied (OS-level) ---
413    if contains_any(
414        &msg,
415        &[
416            "permission denied",
417            "access denied",
418            "operation not permitted",
419            "eacces",
420            "eperm",
421            "forbidden",
422            "403",
423        ],
424    ) {
425        return ErrorCategory::PermissionDenied;
426    }
427
428    // --- Priority 9: Cancellation ---
429    if contains_any(&msg, &["cancelled", "interrupted", "canceled"]) {
430        return ErrorCategory::Cancelled;
431    }
432
433    // --- Priority 10: Circuit breaker ---
434    if contains_any(&msg, &["circuit breaker", "circuit open"]) {
435        return ErrorCategory::CircuitOpen;
436    }
437
438    // --- Priority 11: Sandbox ---
439    if contains_any(&msg, &["sandbox denied", "sandbox failure"]) {
440        return ErrorCategory::SandboxFailure;
441    }
442
443    // --- Priority 12: Rate limiting (before general network) ---
444    if contains_any(&msg, &["rate limit", "too many requests", "429", "throttl"]) {
445        return ErrorCategory::RateLimit;
446    }
447
448    // --- Priority 13: Timeout ---
449    if contains_any(&msg, &["timeout", "timed out", "deadline exceeded"]) {
450        return ErrorCategory::Timeout;
451    }
452
453    // --- Priority 14: Provider transient response-shape failures ---
454    if contains_any(
455        &msg,
456        &[
457            "invalid response format: missing choices",
458            "invalid response format: missing message",
459            "missing choices in response",
460            "missing message in choice",
461            "no choices in response",
462            "invalid response from ",
463            "empty response body",
464            "response did not contain",
465            "unexpected response format",
466            "failed to parse response",
467        ],
468    ) {
469        return ErrorCategory::ServiceUnavailable;
470    }
471
472    // --- Priority 15: Service unavailable (HTTP 5xx and related) ---
473    if contains_any(
474        &msg,
475        &[
476            "service unavailable",
477            "temporarily unavailable",
478            "internal server error",
479            "bad gateway",
480            "gateway timeout",
481            "overloaded",
482            "500",
483            "502",
484            "503",
485            "504",
486        ],
487    ) {
488        return ErrorCategory::ServiceUnavailable;
489    }
490
491    // --- Priority 16: Network (connectivity, DNS, transport) ---
492    if contains_any(
493        &msg,
494        &[
495            "network",
496            "connection reset",
497            "connection refused",
498            "broken pipe",
499            "dns",
500            "name resolution",
501            "try again",
502            "retry later",
503            "upstream connect error",
504            "tls handshake",
505            "socket hang up",
506            "econnreset",
507            "etimedout",
508        ],
509    ) {
510        return ErrorCategory::Network;
511    }
512
513    // --- Priority 17: Resource exhausted (memory, disk) ---
514    if contains_any(&msg, &["out of memory", "disk full", "no space left"]) {
515        return ErrorCategory::ResourceExhausted;
516    }
517
518    // --- Fallback ---
519    ErrorCategory::ExecutionError
520}
521
522/// Check if an LLM error message is retryable (used by the LLM request retry loop).
523///
524/// This is a focused classifier for LLM provider errors, combining
525/// non-retryable and retryable marker checks for the request retry path.
526pub fn is_retryable_llm_error_message(msg: &str) -> bool {
527    let category = classify_error_message(msg);
528    category.is_retryable()
529}
530
531#[inline]
532fn contains_any(message: &str, markers: &[&str]) -> bool {
533    markers.iter().any(|marker| message.contains(marker))
534}
535
536// ---------------------------------------------------------------------------
537// Typed conversions from known error types
538// ---------------------------------------------------------------------------
539
540impl From<&crate::llm::LLMError> for ErrorCategory {
541    fn from(err: &crate::llm::LLMError) -> Self {
542        match err {
543            crate::llm::LLMError::Authentication { .. } => ErrorCategory::Authentication,
544            crate::llm::LLMError::RateLimit { metadata } => {
545                classify_llm_metadata(metadata.as_deref(), ErrorCategory::RateLimit)
546            }
547            crate::llm::LLMError::InvalidRequest { .. } => ErrorCategory::InvalidParameters,
548            crate::llm::LLMError::Network { .. } => ErrorCategory::Network,
549            crate::llm::LLMError::Provider { message, metadata } => {
550                let metadata_category =
551                    classify_llm_metadata(metadata.as_deref(), ErrorCategory::ExecutionError);
552                if metadata_category != ErrorCategory::ExecutionError {
553                    return metadata_category;
554                }
555
556                // Check metadata status code first for precise classification
557                if let Some(meta) = metadata
558                    && let Some(status) = meta.status
559                {
560                    return match status {
561                        401 => ErrorCategory::Authentication,
562                        403 => ErrorCategory::PermissionDenied,
563                        404 => ErrorCategory::ResourceNotFound,
564                        429 => ErrorCategory::RateLimit,
565                        400 => ErrorCategory::InvalidParameters,
566                        500 | 502 | 503 | 504 => ErrorCategory::ServiceUnavailable,
567                        408 => ErrorCategory::Timeout,
568                        _ => classify_error_message(message),
569                    };
570                }
571                // Fall back to message-based classification
572                classify_error_message(message)
573            }
574        }
575    }
576}
577
578fn classify_llm_metadata(
579    metadata: Option<&crate::llm::LLMErrorMetadata>,
580    fallback: ErrorCategory,
581) -> ErrorCategory {
582    let Some(metadata) = metadata else {
583        return fallback;
584    };
585
586    let mut hint = String::new();
587    if let Some(code) = &metadata.code {
588        hint.push_str(code);
589        hint.push(' ');
590    }
591    if let Some(message) = &metadata.message {
592        hint.push_str(message);
593        hint.push(' ');
594    }
595    if let Some(status) = metadata.status {
596        use std::fmt::Write;
597        let _ = write!(&mut hint, "{status}");
598    }
599
600    let classified = classify_error_message(&hint);
601    if classified == ErrorCategory::ExecutionError {
602        fallback
603    } else {
604        classified
605    }
606}
607
608#[cfg(test)]
609mod tests {
610    use super::*;
611
612    // --- classify_error_message tests ---
613
614    #[test]
615    fn policy_violation_takes_priority_over_permission() {
616        assert_eq!(
617            classify_error_message("tool permission denied by policy"),
618            ErrorCategory::PolicyViolation
619        );
620    }
621
622    #[test]
623    fn rate_limit_classified_correctly() {
624        assert_eq!(
625            classify_error_message("provider returned 429 Too Many Requests"),
626            ErrorCategory::RateLimit
627        );
628        assert_eq!(
629            classify_error_message("rate limit exceeded"),
630            ErrorCategory::RateLimit
631        );
632    }
633
634    #[test]
635    fn service_unavailable_is_classified() {
636        assert_eq!(
637            classify_error_message("503 service unavailable"),
638            ErrorCategory::ServiceUnavailable
639        );
640    }
641
642    #[test]
643    fn authentication_errors() {
644        assert_eq!(
645            classify_error_message("invalid api key provided"),
646            ErrorCategory::Authentication
647        );
648        assert_eq!(
649            classify_error_message("401 unauthorized"),
650            ErrorCategory::Authentication
651        );
652    }
653
654    #[test]
655    fn billing_errors_are_resource_exhausted() {
656        assert_eq!(
657            classify_error_message("you have reached your weekly usage limit"),
658            ErrorCategory::ResourceExhausted
659        );
660        assert_eq!(
661            classify_error_message("quota exceeded for this model"),
662            ErrorCategory::ResourceExhausted
663        );
664    }
665
666    #[test]
667    fn timeout_errors() {
668        assert_eq!(
669            classify_error_message("connection timeout"),
670            ErrorCategory::Timeout
671        );
672        assert_eq!(
673            classify_error_message("request timed out after 30s"),
674            ErrorCategory::Timeout
675        );
676    }
677
678    #[test]
679    fn network_errors() {
680        assert_eq!(
681            classify_error_message("connection reset by peer"),
682            ErrorCategory::Network
683        );
684        assert_eq!(
685            classify_error_message("dns name resolution failed"),
686            ErrorCategory::Network
687        );
688    }
689
690    #[test]
691    fn tool_not_found() {
692        assert_eq!(
693            classify_error_message("unknown tool: ask_questions"),
694            ErrorCategory::ToolNotFound
695        );
696    }
697
698    #[test]
699    fn resource_not_found() {
700        assert_eq!(
701            classify_error_message("no such file or directory: /tmp/missing"),
702            ErrorCategory::ResourceNotFound
703        );
704    }
705
706    #[test]
707    fn permission_denied() {
708        assert_eq!(
709            classify_error_message("permission denied: /etc/shadow"),
710            ErrorCategory::PermissionDenied
711        );
712    }
713
714    #[test]
715    fn cancelled_operations() {
716        assert_eq!(
717            classify_error_message("operation cancelled by user"),
718            ErrorCategory::Cancelled
719        );
720    }
721
722    #[test]
723    fn plan_mode_violation() {
724        assert_eq!(
725            classify_error_message("not allowed in plan mode"),
726            ErrorCategory::PolicyViolation
727        );
728    }
729
730    #[test]
731    fn sandbox_failure() {
732        assert_eq!(
733            classify_error_message("sandbox denied this operation"),
734            ErrorCategory::SandboxFailure
735        );
736    }
737
738    #[test]
739    fn unknown_error_is_execution_error() {
740        assert_eq!(
741            classify_error_message("something went wrong"),
742            ErrorCategory::ExecutionError
743        );
744    }
745
746    #[test]
747    fn invalid_parameters() {
748        assert_eq!(
749            classify_error_message("invalid argument: missing path field"),
750            ErrorCategory::InvalidParameters
751        );
752        assert_eq!(
753            classify_error_message(
754                "Failed to parse arguments for read_file handler: invalid type: boolean `false`"
755            ),
756            ErrorCategory::InvalidParameters
757        );
758        assert_eq!(
759            classify_error_message("at least one item is required for 'create'"),
760            ErrorCategory::InvalidParameters
761        );
762        assert_eq!(
763            classify_error_message(
764                "structural pattern preflight failed: pattern is not parseable as Rust syntax"
765            ),
766            ErrorCategory::InvalidParameters
767        );
768    }
769
770    // --- Retryability tests ---
771
772    #[test]
773    fn retryable_categories() {
774        assert!(ErrorCategory::Network.is_retryable());
775        assert!(ErrorCategory::Timeout.is_retryable());
776        assert!(ErrorCategory::RateLimit.is_retryable());
777        assert!(ErrorCategory::ServiceUnavailable.is_retryable());
778        assert!(ErrorCategory::CircuitOpen.is_retryable());
779    }
780
781    #[test]
782    fn non_retryable_categories() {
783        assert!(!ErrorCategory::Authentication.is_retryable());
784        assert!(!ErrorCategory::InvalidParameters.is_retryable());
785        assert!(!ErrorCategory::PolicyViolation.is_retryable());
786        assert!(!ErrorCategory::ResourceExhausted.is_retryable());
787        assert!(!ErrorCategory::Cancelled.is_retryable());
788    }
789
790    #[test]
791    fn permanent_error_detection() {
792        assert!(ErrorCategory::Authentication.is_permanent());
793        assert!(ErrorCategory::PolicyViolation.is_permanent());
794        assert!(!ErrorCategory::Network.is_permanent());
795        assert!(!ErrorCategory::Timeout.is_permanent());
796    }
797
798    #[test]
799    fn llm_mistake_detection() {
800        assert!(ErrorCategory::InvalidParameters.is_llm_mistake());
801        assert!(!ErrorCategory::Network.is_llm_mistake());
802        assert!(!ErrorCategory::Timeout.is_llm_mistake());
803    }
804
805    // --- LLM error conversion ---
806
807    #[test]
808    fn llm_error_authentication_converts() {
809        let err = crate::llm::LLMError::Authentication {
810            message: "bad key".to_string(),
811            metadata: None,
812        };
813        assert_eq!(ErrorCategory::from(&err), ErrorCategory::Authentication);
814    }
815
816    #[test]
817    fn llm_error_rate_limit_converts() {
818        let err = crate::llm::LLMError::RateLimit { metadata: None };
819        assert_eq!(ErrorCategory::from(&err), ErrorCategory::RateLimit);
820    }
821
822    #[test]
823    fn llm_error_quota_exhaustion_converts() {
824        let err = crate::llm::LLMError::RateLimit {
825            metadata: Some(crate::llm::LLMErrorMetadata::new(
826                "openai",
827                Some(429),
828                Some("insufficient_quota".to_string()),
829                None,
830                None,
831                None,
832                Some("quota exceeded".to_string()),
833            )),
834        };
835
836        assert_eq!(ErrorCategory::from(&err), ErrorCategory::ResourceExhausted);
837    }
838
839    #[test]
840    fn llm_error_network_converts() {
841        let err = crate::llm::LLMError::Network {
842            message: "connection refused".to_string(),
843            metadata: None,
844        };
845        assert_eq!(ErrorCategory::from(&err), ErrorCategory::Network);
846    }
847
848    #[test]
849    fn llm_error_provider_with_status_code() {
850        use crate::llm::LLMErrorMetadata;
851        let err = crate::llm::LLMError::Provider {
852            message: "error".to_string(),
853            metadata: Some(LLMErrorMetadata::new(
854                "openai",
855                Some(503),
856                None,
857                None,
858                None,
859                None,
860                None,
861            )),
862        };
863        assert_eq!(ErrorCategory::from(&err), ErrorCategory::ServiceUnavailable);
864    }
865
866    #[test]
867    fn minimax_invalid_response_is_service_unavailable() {
868        assert_eq!(
869            classify_error_message("Invalid response from MiniMax: missing choices"),
870            ErrorCategory::ServiceUnavailable
871        );
872        assert_eq!(
873            classify_error_message("Invalid response format: missing message"),
874            ErrorCategory::ServiceUnavailable
875        );
876    }
877
878    // --- is_retryable_llm_error_message ---
879
880    #[test]
881    fn retryable_llm_messages() {
882        assert!(is_retryable_llm_error_message("429 too many requests"));
883        assert!(is_retryable_llm_error_message("500 internal server error"));
884        assert!(is_retryable_llm_error_message("connection timeout"));
885        assert!(is_retryable_llm_error_message("network error"));
886    }
887
888    #[test]
889    fn non_retryable_llm_messages() {
890        assert!(!is_retryable_llm_error_message("invalid api key"));
891        assert!(!is_retryable_llm_error_message(
892            "weekly usage limit reached"
893        ));
894        assert!(!is_retryable_llm_error_message("permission denied"));
895    }
896
897    // --- Recovery suggestions ---
898
899    #[test]
900    fn recovery_suggestions_non_empty() {
901        for cat in [
902            ErrorCategory::Network,
903            ErrorCategory::Timeout,
904            ErrorCategory::RateLimit,
905            ErrorCategory::Authentication,
906            ErrorCategory::InvalidParameters,
907            ErrorCategory::ToolNotFound,
908            ErrorCategory::ResourceNotFound,
909            ErrorCategory::PermissionDenied,
910            ErrorCategory::PolicyViolation,
911            ErrorCategory::ExecutionError,
912        ] {
913            assert!(
914                !cat.recovery_suggestions().is_empty(),
915                "Missing recovery suggestions for {:?}",
916                cat
917            );
918        }
919    }
920
921    // --- User label ---
922
923    #[test]
924    fn user_labels_are_non_empty() {
925        assert!(!ErrorCategory::Network.user_label().is_empty());
926        assert!(!ErrorCategory::ExecutionError.user_label().is_empty());
927    }
928
929    // --- Display ---
930
931    #[test]
932    fn display_matches_user_label() {
933        assert_eq!(
934            format!("{}", ErrorCategory::RateLimit),
935            ErrorCategory::RateLimit.user_label()
936        );
937    }
938}