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