1use std::borrow::Cow;
22use std::fmt;
23use std::fmt::Write;
24use std::time::Duration;
25
26#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize)]
29pub enum ErrorCategory {
30 Network,
33 Timeout,
35 RateLimit,
37 ServiceUnavailable,
39 CircuitOpen,
41
42 Authentication,
45 InvalidParameters,
47 ToolNotFound,
49 ResourceNotFound,
51 PermissionDenied,
53 PolicyViolation,
55 PlanningPolicyViolation,
57 SandboxFailure,
59 ResourceExhausted,
61 Cancelled,
63 ExecutionError,
65}
66
67#[derive(Debug, Clone, PartialEq, Eq)]
69pub enum Retryability {
70 Retryable {
72 max_attempts: u32,
74 backoff: BackoffStrategy,
76 },
77 NonRetryable,
79 RequiresIntervention,
81}
82
83#[derive(Debug, Clone, PartialEq, Eq)]
85pub enum BackoffStrategy {
86 Exponential { base: Duration, max: Duration },
88 Fixed(Duration),
90}
91
92impl ErrorCategory {
93 #[inline]
95 #[must_use]
96 pub const fn is_retryable(&self) -> bool {
97 matches!(
98 self,
99 ErrorCategory::Network
100 | ErrorCategory::Timeout
101 | ErrorCategory::RateLimit
102 | ErrorCategory::ServiceUnavailable
103 | ErrorCategory::CircuitOpen
104 )
105 }
106
107 #[inline]
109 #[must_use]
110 pub const fn should_trip_circuit_breaker(&self) -> bool {
111 matches!(
112 self,
113 ErrorCategory::Network
114 | ErrorCategory::Timeout
115 | ErrorCategory::RateLimit
116 | ErrorCategory::ServiceUnavailable
117 | ErrorCategory::ExecutionError
118 )
119 }
120
121 #[inline]
124 #[must_use]
125 pub const fn is_llm_mistake(&self) -> bool {
126 matches!(self, ErrorCategory::InvalidParameters)
127 }
128
129 #[inline]
131 #[must_use]
132 pub const fn is_permanent(&self) -> bool {
133 matches!(
134 self,
135 ErrorCategory::Authentication
136 | ErrorCategory::PolicyViolation
137 | ErrorCategory::PlanningPolicyViolation
138 | ErrorCategory::ResourceExhausted
139 )
140 }
141
142 #[must_use]
144 pub fn retryability(&self) -> Retryability {
145 match self {
146 ErrorCategory::Network | ErrorCategory::ServiceUnavailable => Retryability::Retryable {
147 max_attempts: 3,
148 backoff: BackoffStrategy::Exponential {
149 base: Duration::from_millis(500),
150 max: Duration::from_secs(10),
151 },
152 },
153 ErrorCategory::Timeout => Retryability::Retryable {
154 max_attempts: 2,
155 backoff: BackoffStrategy::Exponential {
156 base: Duration::from_millis(1000),
157 max: Duration::from_secs(15),
158 },
159 },
160 ErrorCategory::RateLimit => Retryability::Retryable {
161 max_attempts: 3,
162 backoff: BackoffStrategy::Exponential {
163 base: Duration::from_secs(1),
164 max: Duration::from_secs(30),
165 },
166 },
167 ErrorCategory::CircuitOpen => Retryability::Retryable {
168 max_attempts: 1,
169 backoff: BackoffStrategy::Fixed(Duration::from_secs(10)),
170 },
171 ErrorCategory::PermissionDenied => Retryability::RequiresIntervention,
172 _ => Retryability::NonRetryable,
173 }
174 }
175
176 #[must_use]
179 pub fn recovery_suggestions(&self) -> Vec<Cow<'static, str>> {
180 match self {
181 ErrorCategory::Network => vec![
182 Cow::Borrowed("Check network connectivity"),
183 Cow::Borrowed("Retry the operation after a brief delay"),
184 Cow::Borrowed("Verify external service availability"),
185 ],
186 ErrorCategory::Timeout => vec![
187 Cow::Borrowed("Increase timeout values if appropriate"),
188 Cow::Borrowed("Break large operations into smaller chunks"),
189 Cow::Borrowed("Check system resources and performance"),
190 ],
191 ErrorCategory::RateLimit => vec![
192 Cow::Borrowed("Wait before retrying the request"),
193 Cow::Borrowed("Reduce request frequency"),
194 Cow::Borrowed("Check provider rate limit documentation"),
195 ],
196 ErrorCategory::ServiceUnavailable => vec![
197 Cow::Borrowed("The service is temporarily unavailable"),
198 Cow::Borrowed("Retry after a brief delay"),
199 Cow::Borrowed("Check service status page if available"),
200 ],
201 ErrorCategory::CircuitOpen => vec![
202 Cow::Borrowed("This tool has been temporarily disabled due to repeated failures"),
203 Cow::Borrowed("Wait for the circuit breaker cooldown period"),
204 Cow::Borrowed("Try an alternative approach"),
205 ],
206 ErrorCategory::Authentication => vec![
207 Cow::Borrowed("Verify your API key or credentials"),
208 Cow::Borrowed("Check that your account is active and has sufficient permissions"),
209 Cow::Borrowed("Ensure environment variables for API keys are set correctly"),
210 ],
211 ErrorCategory::InvalidParameters => vec![
212 Cow::Borrowed("Check parameter names and types against the tool schema"),
213 Cow::Borrowed("Ensure required parameters are provided"),
214 Cow::Borrowed("Verify parameter values are within acceptable ranges"),
215 ],
216 ErrorCategory::ToolNotFound => vec![
217 Cow::Borrowed("Verify the tool name is spelled correctly"),
218 Cow::Borrowed("Check if the tool is available in the current context"),
219 ],
220 ErrorCategory::ResourceNotFound => vec![
221 Cow::Borrowed("Verify file paths and resource locations"),
222 Cow::Borrowed("Check if files exist and are accessible"),
223 Cow::Borrowed("Use list_dir to explore available resources"),
224 ],
225 ErrorCategory::PermissionDenied => vec![
226 Cow::Borrowed("Check file permissions and access rights"),
227 Cow::Borrowed("Ensure workspace boundaries are respected"),
228 ],
229 ErrorCategory::PolicyViolation => vec![
230 Cow::Borrowed("Review workspace policies and restrictions"),
231 Cow::Borrowed("Use alternative tools that comply with policies"),
232 ],
233 ErrorCategory::PlanningPolicyViolation => vec![
234 Cow::Borrowed(
235 "This operation is not allowed in the Planning workflow with read-only permissions",
236 ),
237 Cow::Borrowed("Exit the Planning workflow to perform mutating operations"),
238 ],
239 ErrorCategory::SandboxFailure => vec![
240 Cow::Borrowed("The sandbox denied this operation"),
241 Cow::Borrowed("Check sandbox configuration and permissions"),
242 ],
243 ErrorCategory::ResourceExhausted => vec![
244 Cow::Borrowed("Check your account usage limits and billing status"),
245 Cow::Borrowed("Review resource consumption and optimize if possible"),
246 ],
247 ErrorCategory::Cancelled => vec![Cow::Borrowed("The operation was cancelled")],
248 ErrorCategory::ExecutionError => vec![
249 Cow::Borrowed("Review error details for specific issues"),
250 Cow::Borrowed("Check tool documentation for known limitations"),
251 ],
252 }
253 }
254
255 #[must_use]
257 pub const fn user_label(&self) -> &'static str {
258 match self {
259 ErrorCategory::Network => "Network error",
260 ErrorCategory::Timeout => "Request timed out",
261 ErrorCategory::RateLimit => "Rate limit exceeded",
262 ErrorCategory::ServiceUnavailable => "Service temporarily unavailable",
263 ErrorCategory::CircuitOpen => "Tool temporarily disabled",
264 ErrorCategory::Authentication => "Authentication failed",
265 ErrorCategory::InvalidParameters => "Invalid parameters",
266 ErrorCategory::ToolNotFound => "Tool not found",
267 ErrorCategory::ResourceNotFound => "Resource not found",
268 ErrorCategory::PermissionDenied => "Permission denied",
269 ErrorCategory::PolicyViolation => "Blocked by policy",
270 ErrorCategory::PlanningPolicyViolation => "Not allowed in planning workflow",
271 ErrorCategory::SandboxFailure => "Sandbox denied",
272 ErrorCategory::ResourceExhausted => "Resource limit reached",
273 ErrorCategory::Cancelled => "Operation cancelled",
274 ErrorCategory::ExecutionError => "Execution failed",
275 }
276 }
277
278 #[must_use]
283 pub fn auth_recovery_guidance(
284 &self,
285 provider_label: &str,
286 env_key: &str,
287 workspace_env_path: Option<&str>,
288 ) -> Vec<String> {
289 if !matches!(self, ErrorCategory::Authentication) {
290 return vec![];
291 }
292
293 let mut guidance = Vec::with_capacity(4);
294 guidance.push(format!("Authentication failed for {provider_label}."));
295
296 if env_key.is_empty() {
297 guidance.push(
298 "This provider uses managed authentication — run the provider's login command."
299 .to_string(),
300 );
301 } else {
302 guidance.push(format!("To fix, set your API key ({env_key}):"));
303 guidance.push(format!(" 1. Export: export {env_key}=your-key"));
304 if let Some(env_path) = workspace_env_path {
305 guidance.push(format!(" 2. Or add to {env_path}: {env_key}=your-key"));
306 } else {
307 guidance.push(format!(" 2. Or add to workspace .env: {env_key}=your-key"));
308 }
309 guidance.push(" 3. Or run /model to enter a key interactively".to_string());
310 }
311
312 guidance
313 }
314}
315
316impl fmt::Display for ErrorCategory {
317 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
318 f.write_str(self.user_label())
319 }
320}
321
322#[must_use]
332pub fn classify_anyhow_error(err: &anyhow::Error) -> ErrorCategory {
333 let msg = err.to_string().to_ascii_lowercase();
334 classify_error_message(&msg)
335}
336
337#[inline]
342#[must_use]
343pub fn classify_error_message(msg: &str) -> ErrorCategory {
344 let msg = if msg.as_bytes().iter().any(|b| b.is_ascii_uppercase()) {
345 Cow::Owned(msg.to_ascii_lowercase())
346 } else {
347 Cow::Borrowed(msg)
348 };
349
350 if contains_any(
352 &msg,
353 &[
354 "policy violation",
355 "denied by policy",
356 "tool permission denied",
357 "safety validation failed",
358 "not allowed in planning workflow",
359 "only available when planning workflow is active",
360 "workspace boundary",
361 "blocked by policy",
362 ],
363 ) {
364 return ErrorCategory::PolicyViolation;
365 }
366
367 if contains_any(
369 &msg,
370 &[
371 "planning workflow",
372 "read-only permissions",
373 concat!("read-only ", "mode"),
374 "planning_policy_violation",
375 ],
376 ) {
377 return ErrorCategory::PlanningPolicyViolation;
378 }
379
380 if contains_any(
382 &msg,
383 &[
384 "invalid api key",
385 "authentication failed",
386 "unauthorized",
387 "401",
388 "invalid credentials",
389 ],
390 ) {
391 return ErrorCategory::Authentication;
392 }
393
394 if contains_any(
396 &msg,
397 &[
398 "weekly usage limit",
399 "daily usage limit",
400 "monthly spending limit",
401 "insufficient credits",
402 "quota exceeded",
403 "billing",
404 "payment required",
405 ],
406 ) {
407 return ErrorCategory::ResourceExhausted;
408 }
409
410 if contains_any(
412 &msg,
413 &[
414 "invalid argument",
415 "invalid parameters",
416 "invalid type",
417 "malformed",
418 "failed to parse arguments",
419 "failed to parse argument",
420 "missing required",
421 "at least one item is required",
422 "is required for",
423 "schema validation",
424 "argument validation failed",
425 "unknown field",
426 "unknown variant",
427 "expected struct",
428 "expected enum",
429 "type mismatch",
430 "must be an absolute path",
431 "not parseable",
432 "parseable as",
433 "invalid patch format",
441 "invalid patch hunk",
442 "invalid patch operation",
443 "cannot parse empty patch",
444 "patch does not contain",
445 "semantic patch anchor",
446 ],
447 ) {
448 return ErrorCategory::InvalidParameters;
449 }
450
451 if contains_any(
453 &msg,
454 &[
455 "tool not found",
456 "unknown tool",
457 "unsupported tool",
458 "no such tool",
459 ],
460 ) {
461 return ErrorCategory::ToolNotFound;
462 }
463
464 if contains_any(
466 &msg,
467 &[
468 "no such file",
469 "no such directory",
470 "file not found",
471 "directory not found",
472 "resource not found",
473 "path not found",
474 "does not exist",
475 "enoent",
476 ],
477 ) {
478 return ErrorCategory::ResourceNotFound;
479 }
480
481 if contains_any(
483 &msg,
484 &[
485 "permission denied",
486 "access denied",
487 "operation not permitted",
488 "eacces",
489 "eperm",
490 "forbidden",
491 "403",
492 ],
493 ) {
494 return ErrorCategory::PermissionDenied;
495 }
496
497 if contains_any(&msg, &["cancelled", "interrupted", "canceled"]) {
499 return ErrorCategory::Cancelled;
500 }
501
502 if contains_any(&msg, &["circuit breaker", "circuit open"]) {
504 return ErrorCategory::CircuitOpen;
505 }
506
507 if contains_any(&msg, &["sandbox denied", "sandbox failure"]) {
509 return ErrorCategory::SandboxFailure;
510 }
511
512 if contains_any(&msg, &["rate limit", "too many requests", "429", "throttl"]) {
514 return ErrorCategory::RateLimit;
515 }
516
517 if contains_any(&msg, &["timeout", "timed out", "deadline exceeded"]) {
519 return ErrorCategory::Timeout;
520 }
521
522 if contains_any(
524 &msg,
525 &[
526 "invalid response format: missing choices",
527 "invalid response format: missing message",
528 "missing choices in response",
529 "missing message in choice",
530 "no choices in response",
531 "invalid response from ",
532 "empty response body",
533 "response did not contain",
534 "unexpected response format",
535 "failed to parse response",
536 ],
537 ) {
538 return ErrorCategory::ServiceUnavailable;
539 }
540
541 if contains_any(
543 &msg,
544 &[
545 "service unavailable",
546 "temporarily unavailable",
547 "internal server error",
548 "bad gateway",
549 "gateway timeout",
550 "overloaded",
551 "500",
552 "502",
553 "503",
554 "504",
555 ],
556 ) {
557 return ErrorCategory::ServiceUnavailable;
558 }
559
560 if contains_any(
562 &msg,
563 &[
564 "network",
565 "connection reset",
566 "connection refused",
567 "broken pipe",
568 "dns",
569 "name resolution",
570 "try again",
571 "retry later",
572 "upstream connect error",
573 "tls handshake",
574 "socket hang up",
575 "econnreset",
576 "etimedout",
577 "error decoding response body",
581 ],
582 ) {
583 return ErrorCategory::Network;
584 }
585
586 if contains_any(&msg, &["out of memory", "disk full", "no space left"]) {
588 return ErrorCategory::ResourceExhausted;
589 }
590
591 ErrorCategory::ExecutionError
593}
594
595#[inline]
600#[must_use]
601pub fn is_retryable_llm_error_message(msg: &str) -> bool {
602 let category = classify_error_message(msg);
603 category.is_retryable()
604}
605
606#[inline]
607fn contains_any(message: &str, markers: &[&str]) -> bool {
608 markers.iter().any(|marker| message.contains(marker))
609}
610
611impl From<&crate::llm::LLMError> for ErrorCategory {
616 fn from(err: &crate::llm::LLMError) -> Self {
617 match err {
618 crate::llm::LLMError::Authentication { .. } => ErrorCategory::Authentication,
619 crate::llm::LLMError::RateLimit { metadata } => {
620 classify_llm_metadata(metadata.as_deref(), ErrorCategory::RateLimit)
621 }
622 crate::llm::LLMError::InvalidRequest { .. } => ErrorCategory::InvalidParameters,
623 crate::llm::LLMError::Network { .. } => ErrorCategory::Network,
624 crate::llm::LLMError::Provider { message, metadata } => {
625 let metadata_category =
626 classify_llm_metadata(metadata.as_deref(), ErrorCategory::ExecutionError);
627 if metadata_category != ErrorCategory::ExecutionError {
628 return metadata_category;
629 }
630
631 if let Some(meta) = metadata
633 && let Some(status) = meta.status
634 {
635 return match status {
636 401 => ErrorCategory::Authentication,
637 403 => ErrorCategory::PermissionDenied,
638 404 => ErrorCategory::ResourceNotFound,
639 429 => ErrorCategory::RateLimit,
640 400 => ErrorCategory::InvalidParameters,
641 500 | 502 | 503 | 504 => ErrorCategory::ServiceUnavailable,
642 408 => ErrorCategory::Timeout,
643 _ => classify_error_message(message),
644 };
645 }
646 classify_error_message(message)
648 }
649 }
650 }
651}
652
653fn classify_llm_metadata(
654 metadata: Option<&crate::llm::LLMErrorMetadata>,
655 fallback: ErrorCategory,
656) -> ErrorCategory {
657 let Some(metadata) = metadata else {
658 return fallback;
659 };
660
661 let mut hint = String::new();
662 if let Some(code) = &metadata.code {
663 hint.push_str(code);
664 hint.push(' ');
665 }
666 if let Some(message) = &metadata.message {
667 hint.push_str(message);
668 hint.push(' ');
669 }
670 if let Some(status) = metadata.status {
671 let _ = write!(&mut hint, "{status}");
672 }
673
674 let classified = classify_error_message(&hint);
675 if classified == ErrorCategory::ExecutionError {
676 fallback
677 } else {
678 classified
679 }
680}
681
682#[cfg(test)]
683mod tests {
684 use super::*;
685
686 #[test]
689 fn policy_violation_takes_priority_over_permission() {
690 assert_eq!(
691 classify_error_message("tool permission denied by policy"),
692 ErrorCategory::PolicyViolation
693 );
694 }
695
696 #[test]
697 fn rate_limit_classified_correctly() {
698 assert_eq!(
699 classify_error_message("provider returned 429 Too Many Requests"),
700 ErrorCategory::RateLimit
701 );
702 assert_eq!(
703 classify_error_message("rate limit exceeded"),
704 ErrorCategory::RateLimit
705 );
706 }
707
708 #[test]
709 fn service_unavailable_is_classified() {
710 assert_eq!(
711 classify_error_message("503 service unavailable"),
712 ErrorCategory::ServiceUnavailable
713 );
714 }
715
716 #[test]
717 fn authentication_errors() {
718 assert_eq!(
719 classify_error_message("invalid api key provided"),
720 ErrorCategory::Authentication
721 );
722 assert_eq!(
723 classify_error_message("401 unauthorized"),
724 ErrorCategory::Authentication
725 );
726 }
727
728 #[test]
729 fn billing_errors_are_resource_exhausted() {
730 assert_eq!(
731 classify_error_message("you have reached your weekly usage limit"),
732 ErrorCategory::ResourceExhausted
733 );
734 assert_eq!(
735 classify_error_message("quota exceeded for this model"),
736 ErrorCategory::ResourceExhausted
737 );
738 }
739
740 #[test]
741 fn timeout_errors() {
742 assert_eq!(
743 classify_error_message("connection timeout"),
744 ErrorCategory::Timeout
745 );
746 assert_eq!(
747 classify_error_message("request timed out after 30s"),
748 ErrorCategory::Timeout
749 );
750 }
751
752 #[test]
753 fn network_errors() {
754 assert_eq!(
755 classify_error_message("connection reset by peer"),
756 ErrorCategory::Network
757 );
758 assert_eq!(
759 classify_error_message("dns name resolution failed"),
760 ErrorCategory::Network
761 );
762 }
763
764 #[test]
765 fn tool_not_found() {
766 assert_eq!(
767 classify_error_message("unknown tool: ask_questions"),
768 ErrorCategory::ToolNotFound
769 );
770 }
771
772 #[test]
773 fn resource_not_found() {
774 assert_eq!(
775 classify_error_message("no such file or directory: /tmp/missing"),
776 ErrorCategory::ResourceNotFound
777 );
778 assert_eq!(
779 classify_error_message("Path 'vtcode-core/src/agent' does not exist"),
780 ErrorCategory::ResourceNotFound
781 );
782 }
783
784 #[test]
785 fn patch_format_errors_are_invalid_parameters() {
786 assert_eq!(
791 classify_error_message("invalid patch format: missing '*** Begin Patch' marker"),
792 ErrorCategory::InvalidParameters
793 );
794 assert_eq!(
795 classify_error_message(
796 "invalid patch format: input looks like a standard unified diff (---/+++ format)"
797 ),
798 ErrorCategory::InvalidParameters
799 );
800 assert_eq!(
801 classify_error_message("invalid patch hunk on line 5: unexpected end of input"),
802 ErrorCategory::InvalidParameters
803 );
804 assert_eq!(
805 classify_error_message("cannot parse empty patch input"),
806 ErrorCategory::InvalidParameters
807 );
808 assert_eq!(
809 classify_error_message("patch does not contain any operations"),
810 ErrorCategory::InvalidParameters
811 );
812 assert_eq!(
813 classify_error_message(
814 "semantic patch anchor 'fn main' for 'src/main.rs' could not be resolved"
815 ),
816 ErrorCategory::InvalidParameters
817 );
818 }
819
820 #[test]
821 fn permission_denied() {
822 assert_eq!(
823 classify_error_message("permission denied: /etc/shadow"),
824 ErrorCategory::PermissionDenied
825 );
826 }
827
828 #[test]
829 fn cancelled_operations() {
830 assert_eq!(
831 classify_error_message("operation cancelled by user"),
832 ErrorCategory::Cancelled
833 );
834 }
835
836 #[test]
837 fn planning_policy_violation() {
838 assert_eq!(
839 classify_error_message("not allowed in planning workflow"),
840 ErrorCategory::PolicyViolation
841 );
842 }
843
844 #[test]
845 fn sandbox_failure() {
846 assert_eq!(
847 classify_error_message("sandbox denied this operation"),
848 ErrorCategory::SandboxFailure
849 );
850 }
851
852 #[test]
853 fn unknown_error_is_execution_error() {
854 assert_eq!(
855 classify_error_message("something went wrong"),
856 ErrorCategory::ExecutionError
857 );
858 }
859
860 #[test]
861 fn invalid_parameters() {
862 assert_eq!(
863 classify_error_message("invalid argument: missing path field"),
864 ErrorCategory::InvalidParameters
865 );
866 assert_eq!(
867 classify_error_message(
868 "Failed to parse arguments for read_file handler: invalid type: boolean `false`"
869 ),
870 ErrorCategory::InvalidParameters
871 );
872 assert_eq!(
873 classify_error_message("at least one item is required for 'create'"),
874 ErrorCategory::InvalidParameters
875 );
876 assert_eq!(
877 classify_error_message(
878 "structural pattern preflight failed: pattern is not parseable as Rust syntax"
879 ),
880 ErrorCategory::InvalidParameters
881 );
882 }
883
884 #[test]
887 fn retryable_categories() {
888 assert!(ErrorCategory::Network.is_retryable());
889 assert!(ErrorCategory::Timeout.is_retryable());
890 assert!(ErrorCategory::RateLimit.is_retryable());
891 assert!(ErrorCategory::ServiceUnavailable.is_retryable());
892 assert!(ErrorCategory::CircuitOpen.is_retryable());
893 }
894
895 #[test]
896 fn non_retryable_categories() {
897 assert!(!ErrorCategory::Authentication.is_retryable());
898 assert!(!ErrorCategory::InvalidParameters.is_retryable());
899 assert!(!ErrorCategory::PolicyViolation.is_retryable());
900 assert!(!ErrorCategory::ResourceExhausted.is_retryable());
901 assert!(!ErrorCategory::Cancelled.is_retryable());
902 }
903
904 #[test]
905 fn permanent_error_detection() {
906 assert!(ErrorCategory::Authentication.is_permanent());
907 assert!(ErrorCategory::PolicyViolation.is_permanent());
908 assert!(!ErrorCategory::Network.is_permanent());
909 assert!(!ErrorCategory::Timeout.is_permanent());
910 }
911
912 #[test]
913 fn llm_mistake_detection() {
914 assert!(ErrorCategory::InvalidParameters.is_llm_mistake());
915 assert!(!ErrorCategory::Network.is_llm_mistake());
916 assert!(!ErrorCategory::Timeout.is_llm_mistake());
917 }
918
919 #[test]
922 fn llm_error_authentication_converts() {
923 let err = crate::llm::LLMError::Authentication {
924 message: "bad key".to_string(),
925 metadata: None,
926 };
927 assert_eq!(ErrorCategory::from(&err), ErrorCategory::Authentication);
928 }
929
930 #[test]
931 fn llm_error_rate_limit_converts() {
932 let err = crate::llm::LLMError::RateLimit { metadata: None };
933 assert_eq!(ErrorCategory::from(&err), ErrorCategory::RateLimit);
934 }
935
936 #[test]
937 fn llm_error_quota_exhaustion_converts() {
938 let err = crate::llm::LLMError::RateLimit {
939 metadata: Some(crate::llm::LLMErrorMetadata::new(
940 "openai",
941 Some(429),
942 Some("insufficient_quota".to_string()),
943 None,
944 None,
945 None,
946 Some("quota exceeded".to_string()),
947 )),
948 };
949
950 assert_eq!(ErrorCategory::from(&err), ErrorCategory::ResourceExhausted);
951 }
952
953 #[test]
954 fn llm_error_network_converts() {
955 let err = crate::llm::LLMError::Network {
956 message: "connection refused".to_string(),
957 metadata: None,
958 };
959 assert_eq!(ErrorCategory::from(&err), ErrorCategory::Network);
960 }
961
962 #[test]
963 fn llm_error_provider_with_status_code() {
964 use crate::llm::LLMErrorMetadata;
965 let err = crate::llm::LLMError::Provider {
966 message: "error".to_string(),
967 metadata: Some(LLMErrorMetadata::new(
968 "openai",
969 Some(503),
970 None,
971 None,
972 None,
973 None,
974 None,
975 )),
976 };
977 assert_eq!(ErrorCategory::from(&err), ErrorCategory::ServiceUnavailable);
978 }
979
980 #[test]
981 fn minimax_invalid_response_is_service_unavailable() {
982 assert_eq!(
983 classify_error_message("Invalid response from MiniMax: missing choices"),
984 ErrorCategory::ServiceUnavailable
985 );
986 assert_eq!(
987 classify_error_message("Invalid response format: missing message"),
988 ErrorCategory::ServiceUnavailable
989 );
990 }
991
992 #[test]
995 fn retryable_llm_messages() {
996 assert!(is_retryable_llm_error_message("429 too many requests"));
997 assert!(is_retryable_llm_error_message("500 internal server error"));
998 assert!(is_retryable_llm_error_message("connection timeout"));
999 assert!(is_retryable_llm_error_message("network error"));
1000 }
1001
1002 #[test]
1003 fn non_retryable_llm_messages() {
1004 assert!(!is_retryable_llm_error_message("invalid api key"));
1005 assert!(!is_retryable_llm_error_message(
1006 "weekly usage limit reached"
1007 ));
1008 assert!(!is_retryable_llm_error_message("permission denied"));
1009 }
1010
1011 #[test]
1014 fn recovery_suggestions_non_empty() {
1015 for cat in [
1016 ErrorCategory::Network,
1017 ErrorCategory::Timeout,
1018 ErrorCategory::RateLimit,
1019 ErrorCategory::Authentication,
1020 ErrorCategory::InvalidParameters,
1021 ErrorCategory::ToolNotFound,
1022 ErrorCategory::ResourceNotFound,
1023 ErrorCategory::PermissionDenied,
1024 ErrorCategory::PolicyViolation,
1025 ErrorCategory::ExecutionError,
1026 ] {
1027 assert!(
1028 !cat.recovery_suggestions().is_empty(),
1029 "Missing recovery suggestions for {cat:?}"
1030 );
1031 }
1032 }
1033
1034 #[test]
1037 fn user_labels_are_non_empty() {
1038 assert!(!ErrorCategory::Network.user_label().is_empty());
1039 assert!(!ErrorCategory::ExecutionError.user_label().is_empty());
1040 }
1041
1042 #[test]
1045 fn display_matches_user_label() {
1046 assert_eq!(
1047 format!("{}", ErrorCategory::RateLimit),
1048 ErrorCategory::RateLimit.user_label()
1049 );
1050 }
1051}