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