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("This operation is not allowed in the Planning workflow with read-only permissions"),
235 Cow::Borrowed("Exit the Planning workflow to perform mutating operations"),
236 ],
237 ErrorCategory::SandboxFailure => vec![
238 Cow::Borrowed("The sandbox denied this operation"),
239 Cow::Borrowed("Check sandbox configuration and permissions"),
240 ],
241 ErrorCategory::ResourceExhausted => vec![
242 Cow::Borrowed("Check your account usage limits and billing status"),
243 Cow::Borrowed("Review resource consumption and optimize if possible"),
244 ],
245 ErrorCategory::Cancelled => vec![Cow::Borrowed("The operation was cancelled")],
246 ErrorCategory::ExecutionError => vec![
247 Cow::Borrowed("Review error details for specific issues"),
248 Cow::Borrowed("Check tool documentation for known limitations"),
249 ],
250 }
251 }
252
253 #[must_use]
255 pub const fn user_label(&self) -> &'static str {
256 match self {
257 ErrorCategory::Network => "Network error",
258 ErrorCategory::Timeout => "Request timed out",
259 ErrorCategory::RateLimit => "Rate limit exceeded",
260 ErrorCategory::ServiceUnavailable => "Service temporarily unavailable",
261 ErrorCategory::CircuitOpen => "Tool temporarily disabled",
262 ErrorCategory::Authentication => "Authentication failed",
263 ErrorCategory::InvalidParameters => "Invalid parameters",
264 ErrorCategory::ToolNotFound => "Tool not found",
265 ErrorCategory::ResourceNotFound => "Resource not found",
266 ErrorCategory::PermissionDenied => "Permission denied",
267 ErrorCategory::PolicyViolation => "Blocked by policy",
268 ErrorCategory::PlanningPolicyViolation => "Not allowed in planning workflow",
269 ErrorCategory::SandboxFailure => "Sandbox denied",
270 ErrorCategory::ResourceExhausted => "Resource limit reached",
271 ErrorCategory::Cancelled => "Operation cancelled",
272 ErrorCategory::ExecutionError => "Execution failed",
273 }
274 }
275
276 #[must_use]
281 pub fn auth_recovery_guidance(
282 &self,
283 provider_label: &str,
284 env_key: &str,
285 workspace_env_path: Option<&str>,
286 ) -> Vec<String> {
287 if !matches!(self, ErrorCategory::Authentication) {
288 return vec![];
289 }
290
291 let mut guidance = Vec::with_capacity(4);
292 guidance.push(format!("Authentication failed for {provider_label}."));
293
294 if env_key.is_empty() {
295 guidance.push("This provider uses managed authentication — run the provider's login command.".to_string());
296 } else {
297 guidance.push(format!("To fix, set your API key ({env_key}):"));
298 guidance.push(format!(" 1. Export: export {env_key}=your-key"));
299 if let Some(env_path) = workspace_env_path {
300 guidance.push(format!(" 2. Or add to {env_path}: {env_key}=your-key"));
301 } else {
302 guidance.push(format!(" 2. Or add to workspace .env: {env_key}=your-key"));
303 }
304 guidance.push(" 3. Or run /model to enter a key interactively".to_string());
305 }
306
307 guidance
308 }
309}
310
311impl fmt::Display for ErrorCategory {
312 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
313 f.write_str(self.user_label())
314 }
315}
316
317#[must_use]
327pub fn classify_anyhow_error(err: &anyhow::Error) -> ErrorCategory {
328 let msg = err.to_string().to_ascii_lowercase();
329 classify_error_message(&msg)
330}
331
332#[inline]
337#[must_use]
338pub fn classify_error_message(msg: &str) -> ErrorCategory {
339 let msg = if msg.as_bytes().iter().any(|b| b.is_ascii_uppercase()) {
340 Cow::Owned(msg.to_ascii_lowercase())
341 } else {
342 Cow::Borrowed(msg)
343 };
344
345 if contains_any(
347 &msg,
348 &[
349 "policy violation",
350 "denied by policy",
351 "tool permission denied",
352 "safety validation failed",
353 "not allowed in planning workflow",
354 "only available when planning workflow is active",
355 "workspace boundary",
356 "blocked by policy",
357 ],
358 ) {
359 return ErrorCategory::PolicyViolation;
360 }
361
362 if contains_any(
364 &msg,
365 &[
366 "planning workflow",
367 "read-only permissions",
368 concat!("read-only ", "mode"),
369 "planning_policy_violation",
370 ],
371 ) {
372 return ErrorCategory::PlanningPolicyViolation;
373 }
374
375 if contains_any(
377 &msg,
378 &[
379 "invalid api key",
380 "authentication failed",
381 "unauthorized",
382 "401",
383 "invalid credentials",
384 ],
385 ) {
386 return ErrorCategory::Authentication;
387 }
388
389 if contains_any(
391 &msg,
392 &[
393 "weekly usage limit",
394 "daily usage limit",
395 "monthly spending limit",
396 "insufficient credits",
397 "quota exceeded",
398 "billing",
399 "payment required",
400 ],
401 ) {
402 return ErrorCategory::ResourceExhausted;
403 }
404
405 if contains_any(
407 &msg,
408 &[
409 "invalid argument",
410 "invalid parameters",
411 "invalid type",
412 "malformed",
413 "failed to parse arguments",
414 "failed to parse argument",
415 "missing required",
416 "at least one item is required",
417 "is required for",
418 "schema validation",
419 "argument validation failed",
420 "unknown field",
421 "unknown variant",
422 "expected struct",
423 "expected enum",
424 "type mismatch",
425 "must be an absolute path",
426 "not parseable",
427 "parseable as",
428 "invalid patch format",
436 "invalid patch hunk",
437 "invalid patch operation",
438 "cannot parse empty patch",
439 "patch does not contain",
440 "semantic patch anchor",
441 ],
442 ) {
443 return ErrorCategory::InvalidParameters;
444 }
445
446 if contains_any(&msg, &["tool not found", "unknown tool", "unsupported tool", "no such tool"]) {
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 "error decoding response body",
568 ],
569 ) {
570 return ErrorCategory::Network;
571 }
572
573 if contains_any(&msg, &["out of memory", "disk full", "no space left"]) {
575 return ErrorCategory::ResourceExhausted;
576 }
577
578 ErrorCategory::ExecutionError
580}
581
582#[inline]
587#[must_use]
588pub fn is_retryable_llm_error_message(msg: &str) -> bool {
589 let category = classify_error_message(msg);
590 category.is_retryable()
591}
592
593#[inline]
594fn contains_any(message: &str, markers: &[&str]) -> bool {
595 markers.iter().any(|marker| message.contains(marker))
596}
597
598impl From<&crate::llm::LLMError> for ErrorCategory {
603 fn from(err: &crate::llm::LLMError) -> Self {
604 match err {
605 crate::llm::LLMError::Authentication { .. } => ErrorCategory::Authentication,
606 crate::llm::LLMError::RateLimit { metadata } => {
607 classify_llm_metadata(metadata.as_deref(), ErrorCategory::RateLimit)
608 }
609 crate::llm::LLMError::InvalidRequest { .. } => ErrorCategory::InvalidParameters,
610 crate::llm::LLMError::Network { .. } => ErrorCategory::Network,
611 crate::llm::LLMError::Provider { message, metadata } => {
612 let metadata_category = classify_llm_metadata(metadata.as_deref(), ErrorCategory::ExecutionError);
613 if metadata_category != ErrorCategory::ExecutionError {
614 return metadata_category;
615 }
616
617 if let Some(meta) = metadata
619 && let Some(status) = meta.status
620 {
621 return match status {
622 401 => ErrorCategory::Authentication,
623 403 => ErrorCategory::PermissionDenied,
624 404 => ErrorCategory::ResourceNotFound,
625 429 => ErrorCategory::RateLimit,
626 400 => ErrorCategory::InvalidParameters,
627 500 | 502 | 503 | 504 => ErrorCategory::ServiceUnavailable,
628 408 => ErrorCategory::Timeout,
629 _ => classify_error_message(message),
630 };
631 }
632 classify_error_message(message)
634 }
635 }
636 }
637}
638
639fn classify_llm_metadata(metadata: Option<&crate::llm::LLMErrorMetadata>, fallback: ErrorCategory) -> 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!(classify_error_message("tool permission denied by policy"), ErrorCategory::PolicyViolation);
674 }
675
676 #[test]
677 fn rate_limit_classified_correctly() {
678 assert_eq!(classify_error_message("provider returned 429 Too Many Requests"), ErrorCategory::RateLimit);
679 assert_eq!(classify_error_message("rate limit exceeded"), ErrorCategory::RateLimit);
680 }
681
682 #[test]
683 fn service_unavailable_is_classified() {
684 assert_eq!(classify_error_message("503 service unavailable"), ErrorCategory::ServiceUnavailable);
685 }
686
687 #[test]
688 fn authentication_errors() {
689 assert_eq!(classify_error_message("invalid api key provided"), ErrorCategory::Authentication);
690 assert_eq!(classify_error_message("401 unauthorized"), ErrorCategory::Authentication);
691 }
692
693 #[test]
694 fn billing_errors_are_resource_exhausted() {
695 assert_eq!(
696 classify_error_message("you have reached your weekly usage limit"),
697 ErrorCategory::ResourceExhausted
698 );
699 assert_eq!(classify_error_message("quota exceeded for this model"), ErrorCategory::ResourceExhausted);
700 }
701
702 #[test]
703 fn timeout_errors() {
704 assert_eq!(classify_error_message("connection timeout"), ErrorCategory::Timeout);
705 assert_eq!(classify_error_message("request timed out after 30s"), ErrorCategory::Timeout);
706 }
707
708 #[test]
709 fn network_errors() {
710 assert_eq!(classify_error_message("connection reset by peer"), ErrorCategory::Network);
711 assert_eq!(classify_error_message("dns name resolution failed"), ErrorCategory::Network);
712 }
713
714 #[test]
715 fn tool_not_found() {
716 assert_eq!(classify_error_message("unknown tool: ask_questions"), ErrorCategory::ToolNotFound);
717 }
718
719 #[test]
720 fn resource_not_found() {
721 assert_eq!(classify_error_message("no such file or directory: /tmp/missing"), ErrorCategory::ResourceNotFound);
722 assert_eq!(
723 classify_error_message("Path 'crates/codegen/vtcode-core/src/agent' does not exist"),
724 ErrorCategory::ResourceNotFound
725 );
726 }
727
728 #[test]
729 fn patch_format_errors_are_invalid_parameters() {
730 assert_eq!(
735 classify_error_message("invalid patch format: missing '*** Begin Patch' marker"),
736 ErrorCategory::InvalidParameters
737 );
738 assert_eq!(
739 classify_error_message("invalid patch format: input looks like a standard unified diff (---/+++ format)"),
740 ErrorCategory::InvalidParameters
741 );
742 assert_eq!(
743 classify_error_message("invalid patch hunk on line 5: unexpected end of input"),
744 ErrorCategory::InvalidParameters
745 );
746 assert_eq!(classify_error_message("cannot parse empty patch input"), ErrorCategory::InvalidParameters);
747 assert_eq!(classify_error_message("patch does not contain any operations"), ErrorCategory::InvalidParameters);
748 assert_eq!(
749 classify_error_message("semantic patch anchor 'fn main' for 'src/main.rs' could not be resolved"),
750 ErrorCategory::InvalidParameters
751 );
752 }
753
754 #[test]
755 fn permission_denied() {
756 assert_eq!(classify_error_message("permission denied: /etc/shadow"), ErrorCategory::PermissionDenied);
757 }
758
759 #[test]
760 fn cancelled_operations() {
761 assert_eq!(classify_error_message("operation cancelled by user"), ErrorCategory::Cancelled);
762 }
763
764 #[test]
765 fn planning_policy_violation() {
766 assert_eq!(classify_error_message("not allowed in planning workflow"), ErrorCategory::PolicyViolation);
767 }
768
769 #[test]
770 fn sandbox_failure() {
771 assert_eq!(classify_error_message("sandbox denied this operation"), ErrorCategory::SandboxFailure);
772 }
773
774 #[test]
775 fn unknown_error_is_execution_error() {
776 assert_eq!(classify_error_message("something went wrong"), ErrorCategory::ExecutionError);
777 }
778
779 #[test]
780 fn invalid_parameters() {
781 assert_eq!(classify_error_message("invalid argument: missing path field"), ErrorCategory::InvalidParameters);
782 assert_eq!(
783 classify_error_message("Failed to parse arguments for read_file handler: invalid type: boolean `false`"),
784 ErrorCategory::InvalidParameters
785 );
786 assert_eq!(
787 classify_error_message("at least one item is required for 'create'"),
788 ErrorCategory::InvalidParameters
789 );
790 assert_eq!(
791 classify_error_message("structural pattern preflight failed: pattern is not parseable as Rust syntax"),
792 ErrorCategory::InvalidParameters
793 );
794 }
795
796 #[test]
799 fn retryable_categories() {
800 assert!(ErrorCategory::Network.is_retryable());
801 assert!(ErrorCategory::Timeout.is_retryable());
802 assert!(ErrorCategory::RateLimit.is_retryable());
803 assert!(ErrorCategory::ServiceUnavailable.is_retryable());
804 assert!(ErrorCategory::CircuitOpen.is_retryable());
805 }
806
807 #[test]
808 fn non_retryable_categories() {
809 assert!(!ErrorCategory::Authentication.is_retryable());
810 assert!(!ErrorCategory::InvalidParameters.is_retryable());
811 assert!(!ErrorCategory::PolicyViolation.is_retryable());
812 assert!(!ErrorCategory::ResourceExhausted.is_retryable());
813 assert!(!ErrorCategory::Cancelled.is_retryable());
814 }
815
816 #[test]
817 fn permanent_error_detection() {
818 assert!(ErrorCategory::Authentication.is_permanent());
819 assert!(ErrorCategory::PolicyViolation.is_permanent());
820 assert!(!ErrorCategory::Network.is_permanent());
821 assert!(!ErrorCategory::Timeout.is_permanent());
822 }
823
824 #[test]
825 fn llm_mistake_detection() {
826 assert!(ErrorCategory::InvalidParameters.is_llm_mistake());
827 assert!(!ErrorCategory::Network.is_llm_mistake());
828 assert!(!ErrorCategory::Timeout.is_llm_mistake());
829 }
830
831 #[test]
834 fn llm_error_authentication_converts() {
835 let err = crate::llm::LLMError::Authentication { message: "bad key".to_string(), metadata: None };
836 assert_eq!(ErrorCategory::from(&err), ErrorCategory::Authentication);
837 }
838
839 #[test]
840 fn llm_error_rate_limit_converts() {
841 let err = crate::llm::LLMError::RateLimit { metadata: None };
842 assert_eq!(ErrorCategory::from(&err), ErrorCategory::RateLimit);
843 }
844
845 #[test]
846 fn llm_error_quota_exhaustion_converts() {
847 let err = crate::llm::LLMError::RateLimit {
848 metadata: Some(crate::llm::LLMErrorMetadata::new(
849 "openai",
850 Some(429),
851 Some("insufficient_quota".to_string()),
852 None,
853 None,
854 None,
855 Some("quota exceeded".to_string()),
856 )),
857 };
858
859 assert_eq!(ErrorCategory::from(&err), ErrorCategory::ResourceExhausted);
860 }
861
862 #[test]
863 fn llm_error_network_converts() {
864 let err = crate::llm::LLMError::Network {
865 message: "connection refused".to_string(),
866 metadata: None,
867 };
868 assert_eq!(ErrorCategory::from(&err), ErrorCategory::Network);
869 }
870
871 #[test]
872 fn llm_error_provider_with_status_code() {
873 use crate::llm::LLMErrorMetadata;
874 let err = crate::llm::LLMError::Provider {
875 message: "error".to_string(),
876 metadata: Some(LLMErrorMetadata::new("openai", Some(503), None, None, None, None, None)),
877 };
878 assert_eq!(ErrorCategory::from(&err), ErrorCategory::ServiceUnavailable);
879 }
880
881 #[test]
882 fn minimax_invalid_response_is_service_unavailable() {
883 assert_eq!(
884 classify_error_message("Invalid response from MiniMax: missing choices"),
885 ErrorCategory::ServiceUnavailable
886 );
887 assert_eq!(
888 classify_error_message("Invalid response format: missing message"),
889 ErrorCategory::ServiceUnavailable
890 );
891 }
892
893 #[test]
896 fn retryable_llm_messages() {
897 assert!(is_retryable_llm_error_message("429 too many requests"));
898 assert!(is_retryable_llm_error_message("500 internal server error"));
899 assert!(is_retryable_llm_error_message("connection timeout"));
900 assert!(is_retryable_llm_error_message("network error"));
901 }
902
903 #[test]
904 fn non_retryable_llm_messages() {
905 assert!(!is_retryable_llm_error_message("invalid api key"));
906 assert!(!is_retryable_llm_error_message("weekly usage limit reached"));
907 assert!(!is_retryable_llm_error_message("permission denied"));
908 }
909
910 #[test]
913 fn recovery_suggestions_non_empty() {
914 for cat in [
915 ErrorCategory::Network,
916 ErrorCategory::Timeout,
917 ErrorCategory::RateLimit,
918 ErrorCategory::Authentication,
919 ErrorCategory::InvalidParameters,
920 ErrorCategory::ToolNotFound,
921 ErrorCategory::ResourceNotFound,
922 ErrorCategory::PermissionDenied,
923 ErrorCategory::PolicyViolation,
924 ErrorCategory::ExecutionError,
925 ] {
926 assert!(!cat.recovery_suggestions().is_empty(), "Missing recovery suggestions for {cat:?}");
927 }
928 }
929
930 #[test]
933 fn user_labels_are_non_empty() {
934 assert!(!ErrorCategory::Network.user_label().is_empty());
935 assert!(!ErrorCategory::ExecutionError.user_label().is_empty());
936 }
937
938 #[test]
941 fn display_matches_user_label() {
942 assert_eq!(format!("{}", ErrorCategory::RateLimit), ErrorCategory::RateLimit.user_label());
943 }
944}