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!(classify_error_message("rate limit exceeded"), ErrorCategory::RateLimit);
703 }
704
705 #[test]
706 fn service_unavailable_is_classified() {
707 assert_eq!(
708 classify_error_message("503 service unavailable"),
709 ErrorCategory::ServiceUnavailable
710 );
711 }
712
713 #[test]
714 fn authentication_errors() {
715 assert_eq!(
716 classify_error_message("invalid api key provided"),
717 ErrorCategory::Authentication
718 );
719 assert_eq!(classify_error_message("401 unauthorized"), ErrorCategory::Authentication);
720 }
721
722 #[test]
723 fn billing_errors_are_resource_exhausted() {
724 assert_eq!(
725 classify_error_message("you have reached your weekly usage limit"),
726 ErrorCategory::ResourceExhausted
727 );
728 assert_eq!(
729 classify_error_message("quota exceeded for this model"),
730 ErrorCategory::ResourceExhausted
731 );
732 }
733
734 #[test]
735 fn timeout_errors() {
736 assert_eq!(classify_error_message("connection timeout"), ErrorCategory::Timeout);
737 assert_eq!(classify_error_message("request timed out after 30s"), ErrorCategory::Timeout);
738 }
739
740 #[test]
741 fn network_errors() {
742 assert_eq!(classify_error_message("connection reset by peer"), ErrorCategory::Network);
743 assert_eq!(classify_error_message("dns name resolution failed"), ErrorCategory::Network);
744 }
745
746 #[test]
747 fn tool_not_found() {
748 assert_eq!(
749 classify_error_message("unknown tool: ask_questions"),
750 ErrorCategory::ToolNotFound
751 );
752 }
753
754 #[test]
755 fn resource_not_found() {
756 assert_eq!(
757 classify_error_message("no such file or directory: /tmp/missing"),
758 ErrorCategory::ResourceNotFound
759 );
760 assert_eq!(
761 classify_error_message("Path 'crates/codegen/vtcode-core/src/agent' does not exist"),
762 ErrorCategory::ResourceNotFound
763 );
764 }
765
766 #[test]
767 fn patch_format_errors_are_invalid_parameters() {
768 assert_eq!(
773 classify_error_message("invalid patch format: missing '*** Begin Patch' marker"),
774 ErrorCategory::InvalidParameters
775 );
776 assert_eq!(
777 classify_error_message(
778 "invalid patch format: input looks like a standard unified diff (---/+++ format)"
779 ),
780 ErrorCategory::InvalidParameters
781 );
782 assert_eq!(
783 classify_error_message("invalid patch hunk on line 5: unexpected end of input"),
784 ErrorCategory::InvalidParameters
785 );
786 assert_eq!(
787 classify_error_message("cannot parse empty patch input"),
788 ErrorCategory::InvalidParameters
789 );
790 assert_eq!(
791 classify_error_message("patch does not contain any operations"),
792 ErrorCategory::InvalidParameters
793 );
794 assert_eq!(
795 classify_error_message(
796 "semantic patch anchor 'fn main' for 'src/main.rs' could not be resolved"
797 ),
798 ErrorCategory::InvalidParameters
799 );
800 }
801
802 #[test]
803 fn permission_denied() {
804 assert_eq!(
805 classify_error_message("permission denied: /etc/shadow"),
806 ErrorCategory::PermissionDenied
807 );
808 }
809
810 #[test]
811 fn cancelled_operations() {
812 assert_eq!(classify_error_message("operation cancelled by user"), ErrorCategory::Cancelled);
813 }
814
815 #[test]
816 fn planning_policy_violation() {
817 assert_eq!(
818 classify_error_message("not allowed in planning workflow"),
819 ErrorCategory::PolicyViolation
820 );
821 }
822
823 #[test]
824 fn sandbox_failure() {
825 assert_eq!(
826 classify_error_message("sandbox denied this operation"),
827 ErrorCategory::SandboxFailure
828 );
829 }
830
831 #[test]
832 fn unknown_error_is_execution_error() {
833 assert_eq!(classify_error_message("something went wrong"), ErrorCategory::ExecutionError);
834 }
835
836 #[test]
837 fn invalid_parameters() {
838 assert_eq!(
839 classify_error_message("invalid argument: missing path field"),
840 ErrorCategory::InvalidParameters
841 );
842 assert_eq!(
843 classify_error_message(
844 "Failed to parse arguments for read_file handler: invalid type: boolean `false`"
845 ),
846 ErrorCategory::InvalidParameters
847 );
848 assert_eq!(
849 classify_error_message("at least one item is required for 'create'"),
850 ErrorCategory::InvalidParameters
851 );
852 assert_eq!(
853 classify_error_message(
854 "structural pattern preflight failed: pattern is not parseable as Rust syntax"
855 ),
856 ErrorCategory::InvalidParameters
857 );
858 }
859
860 #[test]
863 fn retryable_categories() {
864 assert!(ErrorCategory::Network.is_retryable());
865 assert!(ErrorCategory::Timeout.is_retryable());
866 assert!(ErrorCategory::RateLimit.is_retryable());
867 assert!(ErrorCategory::ServiceUnavailable.is_retryable());
868 assert!(ErrorCategory::CircuitOpen.is_retryable());
869 }
870
871 #[test]
872 fn non_retryable_categories() {
873 assert!(!ErrorCategory::Authentication.is_retryable());
874 assert!(!ErrorCategory::InvalidParameters.is_retryable());
875 assert!(!ErrorCategory::PolicyViolation.is_retryable());
876 assert!(!ErrorCategory::ResourceExhausted.is_retryable());
877 assert!(!ErrorCategory::Cancelled.is_retryable());
878 }
879
880 #[test]
881 fn permanent_error_detection() {
882 assert!(ErrorCategory::Authentication.is_permanent());
883 assert!(ErrorCategory::PolicyViolation.is_permanent());
884 assert!(!ErrorCategory::Network.is_permanent());
885 assert!(!ErrorCategory::Timeout.is_permanent());
886 }
887
888 #[test]
889 fn llm_mistake_detection() {
890 assert!(ErrorCategory::InvalidParameters.is_llm_mistake());
891 assert!(!ErrorCategory::Network.is_llm_mistake());
892 assert!(!ErrorCategory::Timeout.is_llm_mistake());
893 }
894
895 #[test]
898 fn llm_error_authentication_converts() {
899 let err =
900 crate::llm::LLMError::Authentication { message: "bad key".to_string(), metadata: None };
901 assert_eq!(ErrorCategory::from(&err), ErrorCategory::Authentication);
902 }
903
904 #[test]
905 fn llm_error_rate_limit_converts() {
906 let err = crate::llm::LLMError::RateLimit { metadata: None };
907 assert_eq!(ErrorCategory::from(&err), ErrorCategory::RateLimit);
908 }
909
910 #[test]
911 fn llm_error_quota_exhaustion_converts() {
912 let err = crate::llm::LLMError::RateLimit {
913 metadata: Some(crate::llm::LLMErrorMetadata::new(
914 "openai",
915 Some(429),
916 Some("insufficient_quota".to_string()),
917 None,
918 None,
919 None,
920 Some("quota exceeded".to_string()),
921 )),
922 };
923
924 assert_eq!(ErrorCategory::from(&err), ErrorCategory::ResourceExhausted);
925 }
926
927 #[test]
928 fn llm_error_network_converts() {
929 let err = crate::llm::LLMError::Network {
930 message: "connection refused".to_string(),
931 metadata: None,
932 };
933 assert_eq!(ErrorCategory::from(&err), ErrorCategory::Network);
934 }
935
936 #[test]
937 fn llm_error_provider_with_status_code() {
938 use crate::llm::LLMErrorMetadata;
939 let err = crate::llm::LLMError::Provider {
940 message: "error".to_string(),
941 metadata: Some(LLMErrorMetadata::new(
942 "openai",
943 Some(503),
944 None,
945 None,
946 None,
947 None,
948 None,
949 )),
950 };
951 assert_eq!(ErrorCategory::from(&err), ErrorCategory::ServiceUnavailable);
952 }
953
954 #[test]
955 fn minimax_invalid_response_is_service_unavailable() {
956 assert_eq!(
957 classify_error_message("Invalid response from MiniMax: missing choices"),
958 ErrorCategory::ServiceUnavailable
959 );
960 assert_eq!(
961 classify_error_message("Invalid response format: missing message"),
962 ErrorCategory::ServiceUnavailable
963 );
964 }
965
966 #[test]
969 fn retryable_llm_messages() {
970 assert!(is_retryable_llm_error_message("429 too many requests"));
971 assert!(is_retryable_llm_error_message("500 internal server error"));
972 assert!(is_retryable_llm_error_message("connection timeout"));
973 assert!(is_retryable_llm_error_message("network error"));
974 }
975
976 #[test]
977 fn non_retryable_llm_messages() {
978 assert!(!is_retryable_llm_error_message("invalid api key"));
979 assert!(!is_retryable_llm_error_message("weekly usage limit reached"));
980 assert!(!is_retryable_llm_error_message("permission denied"));
981 }
982
983 #[test]
986 fn recovery_suggestions_non_empty() {
987 for cat in [
988 ErrorCategory::Network,
989 ErrorCategory::Timeout,
990 ErrorCategory::RateLimit,
991 ErrorCategory::Authentication,
992 ErrorCategory::InvalidParameters,
993 ErrorCategory::ToolNotFound,
994 ErrorCategory::ResourceNotFound,
995 ErrorCategory::PermissionDenied,
996 ErrorCategory::PolicyViolation,
997 ErrorCategory::ExecutionError,
998 ] {
999 assert!(
1000 !cat.recovery_suggestions().is_empty(),
1001 "Missing recovery suggestions for {cat:?}"
1002 );
1003 }
1004 }
1005
1006 #[test]
1009 fn user_labels_are_non_empty() {
1010 assert!(!ErrorCategory::Network.user_label().is_empty());
1011 assert!(!ErrorCategory::ExecutionError.user_label().is_empty());
1012 }
1013
1014 #[test]
1017 fn display_matches_user_label() {
1018 assert_eq!(format!("{}", ErrorCategory::RateLimit), ErrorCategory::RateLimit.user_label());
1019 }
1020}