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