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