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 ],
578 ) {
579 return ErrorCategory::Network;
580 }
581
582 if contains_any(&msg, &["out of memory", "disk full", "no space left"]) {
584 return ErrorCategory::ResourceExhausted;
585 }
586
587 ErrorCategory::ExecutionError
589}
590
591#[inline]
596#[must_use]
597pub fn is_retryable_llm_error_message(msg: &str) -> bool {
598 let category = classify_error_message(msg);
599 category.is_retryable()
600}
601
602#[inline]
603fn contains_any(message: &str, markers: &[&str]) -> bool {
604 markers.iter().any(|marker| message.contains(marker))
605}
606
607impl From<&crate::llm::LLMError> for ErrorCategory {
612 fn from(err: &crate::llm::LLMError) -> Self {
613 match err {
614 crate::llm::LLMError::Authentication { .. } => ErrorCategory::Authentication,
615 crate::llm::LLMError::RateLimit { metadata } => {
616 classify_llm_metadata(metadata.as_deref(), ErrorCategory::RateLimit)
617 }
618 crate::llm::LLMError::InvalidRequest { .. } => ErrorCategory::InvalidParameters,
619 crate::llm::LLMError::Network { .. } => ErrorCategory::Network,
620 crate::llm::LLMError::Provider { message, metadata } => {
621 let metadata_category =
622 classify_llm_metadata(metadata.as_deref(), ErrorCategory::ExecutionError);
623 if metadata_category != ErrorCategory::ExecutionError {
624 return metadata_category;
625 }
626
627 if let Some(meta) = metadata
629 && let Some(status) = meta.status
630 {
631 return match status {
632 401 => ErrorCategory::Authentication,
633 403 => ErrorCategory::PermissionDenied,
634 404 => ErrorCategory::ResourceNotFound,
635 429 => ErrorCategory::RateLimit,
636 400 => ErrorCategory::InvalidParameters,
637 500 | 502 | 503 | 504 => ErrorCategory::ServiceUnavailable,
638 408 => ErrorCategory::Timeout,
639 _ => classify_error_message(message),
640 };
641 }
642 classify_error_message(message)
644 }
645 }
646 }
647}
648
649fn classify_llm_metadata(
650 metadata: Option<&crate::llm::LLMErrorMetadata>,
651 fallback: ErrorCategory,
652) -> ErrorCategory {
653 let Some(metadata) = metadata else {
654 return fallback;
655 };
656
657 let mut hint = String::new();
658 if let Some(code) = &metadata.code {
659 hint.push_str(code);
660 hint.push(' ');
661 }
662 if let Some(message) = &metadata.message {
663 hint.push_str(message);
664 hint.push(' ');
665 }
666 if let Some(status) = metadata.status {
667 let _ = write!(&mut hint, "{status}");
668 }
669
670 let classified = classify_error_message(&hint);
671 if classified == ErrorCategory::ExecutionError {
672 fallback
673 } else {
674 classified
675 }
676}
677
678#[cfg(test)]
679mod tests {
680 use super::*;
681
682 #[test]
685 fn policy_violation_takes_priority_over_permission() {
686 assert_eq!(
687 classify_error_message("tool permission denied by policy"),
688 ErrorCategory::PolicyViolation
689 );
690 }
691
692 #[test]
693 fn rate_limit_classified_correctly() {
694 assert_eq!(
695 classify_error_message("provider returned 429 Too Many Requests"),
696 ErrorCategory::RateLimit
697 );
698 assert_eq!(
699 classify_error_message("rate limit exceeded"),
700 ErrorCategory::RateLimit
701 );
702 }
703
704 #[test]
705 fn service_unavailable_is_classified() {
706 assert_eq!(
707 classify_error_message("503 service unavailable"),
708 ErrorCategory::ServiceUnavailable
709 );
710 }
711
712 #[test]
713 fn authentication_errors() {
714 assert_eq!(
715 classify_error_message("invalid api key provided"),
716 ErrorCategory::Authentication
717 );
718 assert_eq!(
719 classify_error_message("401 unauthorized"),
720 ErrorCategory::Authentication
721 );
722 }
723
724 #[test]
725 fn billing_errors_are_resource_exhausted() {
726 assert_eq!(
727 classify_error_message("you have reached your weekly usage limit"),
728 ErrorCategory::ResourceExhausted
729 );
730 assert_eq!(
731 classify_error_message("quota exceeded for this model"),
732 ErrorCategory::ResourceExhausted
733 );
734 }
735
736 #[test]
737 fn timeout_errors() {
738 assert_eq!(
739 classify_error_message("connection timeout"),
740 ErrorCategory::Timeout
741 );
742 assert_eq!(
743 classify_error_message("request timed out after 30s"),
744 ErrorCategory::Timeout
745 );
746 }
747
748 #[test]
749 fn network_errors() {
750 assert_eq!(
751 classify_error_message("connection reset by peer"),
752 ErrorCategory::Network
753 );
754 assert_eq!(
755 classify_error_message("dns name resolution failed"),
756 ErrorCategory::Network
757 );
758 }
759
760 #[test]
761 fn tool_not_found() {
762 assert_eq!(
763 classify_error_message("unknown tool: ask_questions"),
764 ErrorCategory::ToolNotFound
765 );
766 }
767
768 #[test]
769 fn resource_not_found() {
770 assert_eq!(
771 classify_error_message("no such file or directory: /tmp/missing"),
772 ErrorCategory::ResourceNotFound
773 );
774 assert_eq!(
775 classify_error_message("Path 'vtcode-core/src/agent' does not exist"),
776 ErrorCategory::ResourceNotFound
777 );
778 }
779
780 #[test]
781 fn patch_format_errors_are_invalid_parameters() {
782 assert_eq!(
787 classify_error_message("invalid patch format: missing '*** Begin Patch' marker"),
788 ErrorCategory::InvalidParameters
789 );
790 assert_eq!(
791 classify_error_message(
792 "invalid patch format: input looks like a standard unified diff (---/+++ format)"
793 ),
794 ErrorCategory::InvalidParameters
795 );
796 assert_eq!(
797 classify_error_message("invalid patch hunk on line 5: unexpected end of input"),
798 ErrorCategory::InvalidParameters
799 );
800 assert_eq!(
801 classify_error_message("cannot parse empty patch input"),
802 ErrorCategory::InvalidParameters
803 );
804 assert_eq!(
805 classify_error_message("patch does not contain any operations"),
806 ErrorCategory::InvalidParameters
807 );
808 assert_eq!(
809 classify_error_message(
810 "semantic patch anchor 'fn main' for 'src/main.rs' could not be resolved"
811 ),
812 ErrorCategory::InvalidParameters
813 );
814 }
815
816 #[test]
817 fn permission_denied() {
818 assert_eq!(
819 classify_error_message("permission denied: /etc/shadow"),
820 ErrorCategory::PermissionDenied
821 );
822 }
823
824 #[test]
825 fn cancelled_operations() {
826 assert_eq!(
827 classify_error_message("operation cancelled by user"),
828 ErrorCategory::Cancelled
829 );
830 }
831
832 #[test]
833 fn planning_policy_violation() {
834 assert_eq!(
835 classify_error_message("not allowed in planning workflow"),
836 ErrorCategory::PolicyViolation
837 );
838 }
839
840 #[test]
841 fn sandbox_failure() {
842 assert_eq!(
843 classify_error_message("sandbox denied this operation"),
844 ErrorCategory::SandboxFailure
845 );
846 }
847
848 #[test]
849 fn unknown_error_is_execution_error() {
850 assert_eq!(
851 classify_error_message("something went wrong"),
852 ErrorCategory::ExecutionError
853 );
854 }
855
856 #[test]
857 fn invalid_parameters() {
858 assert_eq!(
859 classify_error_message("invalid argument: missing path field"),
860 ErrorCategory::InvalidParameters
861 );
862 assert_eq!(
863 classify_error_message(
864 "Failed to parse arguments for read_file handler: invalid type: boolean `false`"
865 ),
866 ErrorCategory::InvalidParameters
867 );
868 assert_eq!(
869 classify_error_message("at least one item is required for 'create'"),
870 ErrorCategory::InvalidParameters
871 );
872 assert_eq!(
873 classify_error_message(
874 "structural pattern preflight failed: pattern is not parseable as Rust syntax"
875 ),
876 ErrorCategory::InvalidParameters
877 );
878 }
879
880 #[test]
883 fn retryable_categories() {
884 assert!(ErrorCategory::Network.is_retryable());
885 assert!(ErrorCategory::Timeout.is_retryable());
886 assert!(ErrorCategory::RateLimit.is_retryable());
887 assert!(ErrorCategory::ServiceUnavailable.is_retryable());
888 assert!(ErrorCategory::CircuitOpen.is_retryable());
889 }
890
891 #[test]
892 fn non_retryable_categories() {
893 assert!(!ErrorCategory::Authentication.is_retryable());
894 assert!(!ErrorCategory::InvalidParameters.is_retryable());
895 assert!(!ErrorCategory::PolicyViolation.is_retryable());
896 assert!(!ErrorCategory::ResourceExhausted.is_retryable());
897 assert!(!ErrorCategory::Cancelled.is_retryable());
898 }
899
900 #[test]
901 fn permanent_error_detection() {
902 assert!(ErrorCategory::Authentication.is_permanent());
903 assert!(ErrorCategory::PolicyViolation.is_permanent());
904 assert!(!ErrorCategory::Network.is_permanent());
905 assert!(!ErrorCategory::Timeout.is_permanent());
906 }
907
908 #[test]
909 fn llm_mistake_detection() {
910 assert!(ErrorCategory::InvalidParameters.is_llm_mistake());
911 assert!(!ErrorCategory::Network.is_llm_mistake());
912 assert!(!ErrorCategory::Timeout.is_llm_mistake());
913 }
914
915 #[test]
918 fn llm_error_authentication_converts() {
919 let err = crate::llm::LLMError::Authentication {
920 message: "bad key".to_string(),
921 metadata: None,
922 };
923 assert_eq!(ErrorCategory::from(&err), ErrorCategory::Authentication);
924 }
925
926 #[test]
927 fn llm_error_rate_limit_converts() {
928 let err = crate::llm::LLMError::RateLimit { metadata: None };
929 assert_eq!(ErrorCategory::from(&err), ErrorCategory::RateLimit);
930 }
931
932 #[test]
933 fn llm_error_quota_exhaustion_converts() {
934 let err = crate::llm::LLMError::RateLimit {
935 metadata: Some(crate::llm::LLMErrorMetadata::new(
936 "openai",
937 Some(429),
938 Some("insufficient_quota".to_string()),
939 None,
940 None,
941 None,
942 Some("quota exceeded".to_string()),
943 )),
944 };
945
946 assert_eq!(ErrorCategory::from(&err), ErrorCategory::ResourceExhausted);
947 }
948
949 #[test]
950 fn llm_error_network_converts() {
951 let err = crate::llm::LLMError::Network {
952 message: "connection refused".to_string(),
953 metadata: None,
954 };
955 assert_eq!(ErrorCategory::from(&err), ErrorCategory::Network);
956 }
957
958 #[test]
959 fn llm_error_provider_with_status_code() {
960 use crate::llm::LLMErrorMetadata;
961 let err = crate::llm::LLMError::Provider {
962 message: "error".to_string(),
963 metadata: Some(LLMErrorMetadata::new(
964 "openai",
965 Some(503),
966 None,
967 None,
968 None,
969 None,
970 None,
971 )),
972 };
973 assert_eq!(ErrorCategory::from(&err), ErrorCategory::ServiceUnavailable);
974 }
975
976 #[test]
977 fn minimax_invalid_response_is_service_unavailable() {
978 assert_eq!(
979 classify_error_message("Invalid response from MiniMax: missing choices"),
980 ErrorCategory::ServiceUnavailable
981 );
982 assert_eq!(
983 classify_error_message("Invalid response format: missing message"),
984 ErrorCategory::ServiceUnavailable
985 );
986 }
987
988 #[test]
991 fn retryable_llm_messages() {
992 assert!(is_retryable_llm_error_message("429 too many requests"));
993 assert!(is_retryable_llm_error_message("500 internal server error"));
994 assert!(is_retryable_llm_error_message("connection timeout"));
995 assert!(is_retryable_llm_error_message("network error"));
996 }
997
998 #[test]
999 fn non_retryable_llm_messages() {
1000 assert!(!is_retryable_llm_error_message("invalid api key"));
1001 assert!(!is_retryable_llm_error_message(
1002 "weekly usage limit reached"
1003 ));
1004 assert!(!is_retryable_llm_error_message("permission denied"));
1005 }
1006
1007 #[test]
1010 fn recovery_suggestions_non_empty() {
1011 for cat in [
1012 ErrorCategory::Network,
1013 ErrorCategory::Timeout,
1014 ErrorCategory::RateLimit,
1015 ErrorCategory::Authentication,
1016 ErrorCategory::InvalidParameters,
1017 ErrorCategory::ToolNotFound,
1018 ErrorCategory::ResourceNotFound,
1019 ErrorCategory::PermissionDenied,
1020 ErrorCategory::PolicyViolation,
1021 ErrorCategory::ExecutionError,
1022 ] {
1023 assert!(
1024 !cat.recovery_suggestions().is_empty(),
1025 "Missing recovery suggestions for {cat:?}"
1026 );
1027 }
1028 }
1029
1030 #[test]
1033 fn user_labels_are_non_empty() {
1034 assert!(!ErrorCategory::Network.user_label().is_empty());
1035 assert!(!ErrorCategory::ExecutionError.user_label().is_empty());
1036 }
1037
1038 #[test]
1041 fn display_matches_user_label() {
1042 assert_eq!(
1043 format!("{}", ErrorCategory::RateLimit),
1044 ErrorCategory::RateLimit.user_label()
1045 );
1046 }
1047}