1#![expect(
2 clippy::let_underscore_must_use,
3 reason = "The category formatter intentionally ignores infallible formatting results."
4)]
5
6use std::borrow::Cow;
27use std::fmt;
28use std::fmt::Write;
29use std::time::Duration;
30
31#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize)]
34pub enum ErrorCategory {
35 Network,
38 Timeout,
40 RateLimit,
42 ServiceUnavailable,
44 CircuitOpen,
46
47 Authentication,
50 InvalidParameters,
52 ToolNotFound,
54 ResourceNotFound,
56 PermissionDenied,
58 PolicyViolation,
60 PlanningPolicyViolation,
62 SandboxFailure,
64 ResourceExhausted,
66 Cancelled,
68 ExecutionError,
70}
71
72#[derive(Debug, Clone, PartialEq, Eq)]
74pub enum Retryability {
75 Retryable {
77 max_attempts: u32,
79 backoff: BackoffStrategy,
81 },
82 NonRetryable,
84 RequiresIntervention,
86}
87
88#[derive(Debug, Clone, PartialEq, Eq)]
90pub enum BackoffStrategy {
91 Exponential { base: Duration, max: Duration },
93 Fixed(Duration),
95}
96
97impl ErrorCategory {
98 #[inline]
103 #[must_use]
104 pub const fn as_str(&self) -> &'static str {
105 self.user_label()
106 }
107
108 #[inline]
110 #[must_use]
111 pub const fn is_retryable(&self) -> bool {
112 matches!(
113 self,
114 ErrorCategory::Network
115 | ErrorCategory::Timeout
116 | ErrorCategory::RateLimit
117 | ErrorCategory::ServiceUnavailable
118 | ErrorCategory::CircuitOpen
119 )
120 }
121
122 #[inline]
124 #[must_use]
125 pub const fn should_trip_circuit_breaker(&self) -> bool {
126 matches!(
127 self,
128 ErrorCategory::Network
129 | ErrorCategory::Timeout
130 | ErrorCategory::RateLimit
131 | ErrorCategory::ServiceUnavailable
132 | ErrorCategory::ExecutionError
133 )
134 }
135
136 #[inline]
139 #[must_use]
140 const fn is_llm_mistake(&self) -> bool {
141 matches!(self, ErrorCategory::InvalidParameters)
142 }
143
144 #[inline]
146 #[must_use]
147 pub const fn is_permanent(&self) -> bool {
148 matches!(
149 self,
150 ErrorCategory::Authentication
151 | ErrorCategory::PolicyViolation
152 | ErrorCategory::PlanningPolicyViolation
153 | ErrorCategory::ResourceExhausted
154 )
155 }
156
157 #[must_use]
159 pub fn retryability(&self) -> Retryability {
160 match self {
161 ErrorCategory::Network | ErrorCategory::ServiceUnavailable => Retryability::Retryable {
162 max_attempts: 3,
163 backoff: BackoffStrategy::Exponential {
164 base: Duration::from_millis(500),
165 max: Duration::from_secs(10),
166 },
167 },
168 ErrorCategory::Timeout => Retryability::Retryable {
169 max_attempts: 2,
170 backoff: BackoffStrategy::Exponential {
171 base: Duration::from_millis(1000),
172 max: Duration::from_secs(15),
173 },
174 },
175 ErrorCategory::RateLimit => Retryability::Retryable {
176 max_attempts: 3,
177 backoff: BackoffStrategy::Exponential {
178 base: Duration::from_secs(1),
179 max: Duration::from_secs(30),
180 },
181 },
182 ErrorCategory::CircuitOpen => Retryability::Retryable {
183 max_attempts: 1,
184 backoff: BackoffStrategy::Fixed(Duration::from_secs(10)),
185 },
186 ErrorCategory::PermissionDenied => Retryability::RequiresIntervention,
187 _ => Retryability::NonRetryable,
188 }
189 }
190
191 #[must_use]
194 pub fn recovery_suggestions(&self) -> Vec<Cow<'static, str>> {
195 match self {
196 ErrorCategory::Network => vec![
197 Cow::Borrowed("Check network connectivity"),
198 Cow::Borrowed("Retry the operation after a brief delay"),
199 Cow::Borrowed("Verify external service availability"),
200 ],
201 ErrorCategory::Timeout => vec![
202 Cow::Borrowed("Increase timeout values if appropriate"),
203 Cow::Borrowed("Break large operations into smaller chunks"),
204 Cow::Borrowed("Check system resources and performance"),
205 ],
206 ErrorCategory::RateLimit => vec![
207 Cow::Borrowed("Wait before retrying the request"),
208 Cow::Borrowed("Reduce request frequency"),
209 Cow::Borrowed("Check provider rate limit documentation"),
210 ],
211 ErrorCategory::ServiceUnavailable => vec![
212 Cow::Borrowed("The service is temporarily unavailable"),
213 Cow::Borrowed("Retry after a brief delay"),
214 Cow::Borrowed("Check service status page if available"),
215 ],
216 ErrorCategory::CircuitOpen => vec![
217 Cow::Borrowed("This tool has been temporarily disabled due to repeated failures"),
218 Cow::Borrowed("Wait for the circuit breaker cooldown period"),
219 Cow::Borrowed("Try an alternative approach"),
220 ],
221 ErrorCategory::Authentication => vec![
222 Cow::Borrowed("Verify your API key or credentials"),
223 Cow::Borrowed("Check that your account is active and has sufficient permissions"),
224 Cow::Borrowed("Ensure environment variables for API keys are set correctly"),
225 ],
226 ErrorCategory::InvalidParameters => vec![
227 Cow::Borrowed("Check parameter names and types against the tool schema"),
228 Cow::Borrowed("Ensure required parameters are provided"),
229 Cow::Borrowed("Verify parameter values are within acceptable ranges"),
230 ],
231 ErrorCategory::ToolNotFound => vec![
232 Cow::Borrowed("Verify the tool name is spelled correctly"),
233 Cow::Borrowed("Check if the tool is available in the current context"),
234 ],
235 ErrorCategory::ResourceNotFound => vec![
236 Cow::Borrowed("Verify file paths and resource locations"),
237 Cow::Borrowed("Check if files exist and are accessible"),
238 Cow::Borrowed("Use list_dir to explore available resources"),
239 ],
240 ErrorCategory::PermissionDenied => vec![
241 Cow::Borrowed("Check file permissions and access rights"),
242 Cow::Borrowed("Ensure workspace boundaries are respected"),
243 ],
244 ErrorCategory::PolicyViolation => vec![
245 Cow::Borrowed("Review workspace policies and restrictions"),
246 Cow::Borrowed("Use alternative tools that comply with policies"),
247 ],
248 ErrorCategory::PlanningPolicyViolation => vec![
249 Cow::Borrowed("This operation is not allowed in the Planning workflow with read-only permissions"),
250 Cow::Borrowed("Exit the Planning workflow to perform mutating operations"),
251 ],
252 ErrorCategory::SandboxFailure => vec![
253 Cow::Borrowed("The sandbox denied this operation"),
254 Cow::Borrowed("Check sandbox configuration and permissions"),
255 ],
256 ErrorCategory::ResourceExhausted => vec![
257 Cow::Borrowed("Check your account usage limits and billing status"),
258 Cow::Borrowed("Review resource consumption and optimize if possible"),
259 ],
260 ErrorCategory::Cancelled => vec![Cow::Borrowed("The operation was cancelled")],
261 ErrorCategory::ExecutionError => vec![
262 Cow::Borrowed("Review error details for specific issues"),
263 Cow::Borrowed("Check tool documentation for known limitations"),
264 ],
265 }
266 }
267
268 #[must_use]
270 pub const fn user_label(&self) -> &'static str {
271 match self {
272 ErrorCategory::Network => "Network error",
273 ErrorCategory::Timeout => "Request timed out",
274 ErrorCategory::RateLimit => "Rate limit exceeded",
275 ErrorCategory::ServiceUnavailable => "Service temporarily unavailable",
276 ErrorCategory::CircuitOpen => "Tool temporarily disabled",
277 ErrorCategory::Authentication => "Authentication failed",
278 ErrorCategory::InvalidParameters => "Invalid parameters",
279 ErrorCategory::ToolNotFound => "Tool not found",
280 ErrorCategory::ResourceNotFound => "Resource not found",
281 ErrorCategory::PermissionDenied => "Permission denied",
282 ErrorCategory::PolicyViolation => "Blocked by policy",
283 ErrorCategory::PlanningPolicyViolation => "Not allowed in planning workflow",
284 ErrorCategory::SandboxFailure => "Sandbox denied",
285 ErrorCategory::ResourceExhausted => "Resource limit reached",
286 ErrorCategory::Cancelled => "Operation cancelled",
287 ErrorCategory::ExecutionError => "Execution failed",
288 }
289 }
290
291 #[must_use]
301 pub fn auth_recovery_guidance(
302 &self,
303 provider_label: &str,
304 provider_key: &str,
305 is_managed_auth: bool,
306 has_stored_credential: bool,
307 ) -> Vec<String> {
308 if !matches!(self, ErrorCategory::Authentication) {
309 return vec![];
310 }
311
312 if is_managed_auth {
313 vec![format!(
314 "Authentication failed for {provider_label}. Run /login {provider_key} to re-authenticate."
315 )]
316 } else if has_stored_credential {
317 vec![format!(
318 "Authentication failed for {provider_label}. The stored API key was rejected — run /secret add {provider_key} to replace it with a valid key."
319 )]
320 } else {
321 vec![format!(
322 "Authentication failed for {provider_label}. Run /secret add {provider_key} to store your API key in secure storage (OS keyring or encrypted file)."
323 )]
324 }
325 }
326}
327
328impl fmt::Display for ErrorCategory {
329 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
330 f.write_str(self.user_label())
331 }
332}
333
334#[must_use]
344pub fn classify_anyhow_error(err: &anyhow::Error) -> ErrorCategory {
345 let msg = err.to_string().to_ascii_lowercase();
346 classify_error_message(&msg)
347}
348
349#[must_use]
358pub fn is_context_capacity_error(err: &anyhow::Error) -> bool {
359 err.chain().any(|cause| is_context_capacity_message(&cause.to_string()))
360}
361
362#[must_use]
364pub fn is_context_capacity_message(message: &str) -> bool {
365 let message = message.to_ascii_lowercase();
366 contains_any(
367 &message,
368 &[
369 "context_length_exceeded",
370 "context length exceeded",
371 "maximum context length",
372 "maximum context window",
373 "context window exceeded",
374 "context window is too small",
375 "exceeds the model's maximum context",
376 "exceeds model context",
377 "exceeds the maximum context",
378 "prompt is too long",
379 "input is too long",
380 "input token count exceeds",
381 "exceeds the maximum number of tokens",
382 "maximum input tokens",
383 "too many tokens in the prompt",
384 "request exceeds the context",
385 ],
386 )
387}
388
389#[inline]
394#[must_use]
395pub fn classify_error_message(msg: &str) -> ErrorCategory {
396 let msg = if msg.as_bytes().iter().any(|b| b.is_ascii_uppercase()) {
397 Cow::Owned(msg.to_ascii_lowercase())
398 } else {
399 Cow::Borrowed(msg)
400 };
401
402 if contains_any(
404 &msg,
405 &[
406 "policy violation",
407 "denied by policy",
408 "tool permission denied",
409 "safety validation failed",
410 "not allowed in planning workflow",
411 "only available when planning workflow is active",
412 "workspace boundary",
413 "blocked by policy",
414 ],
415 ) {
416 return ErrorCategory::PolicyViolation;
417 }
418
419 if contains_any(
421 &msg,
422 &[
423 "planning workflow",
424 "read-only permissions",
425 concat!("read-only ", "mode"),
426 "planning_policy_violation",
427 ],
428 ) {
429 return ErrorCategory::PlanningPolicyViolation;
430 }
431
432 if contains_any(
434 &msg,
435 &[
436 "invalid api key",
437 "authentication failed",
438 "unauthorized",
439 "401",
440 "invalid credentials",
441 ],
442 ) {
443 return ErrorCategory::Authentication;
444 }
445
446 if contains_any(
448 &msg,
449 &[
450 "weekly usage limit",
451 "daily usage limit",
452 "monthly spending limit",
453 "insufficient credits",
454 "quota exceeded",
455 "billing",
456 "payment required",
457 ],
458 ) {
459 return ErrorCategory::ResourceExhausted;
460 }
461
462 if contains_any(
464 &msg,
465 &[
466 "invalid argument",
467 "invalid parameters",
468 "invalid type",
469 "malformed",
470 "failed to parse arguments",
471 "failed to parse argument",
472 "missing required",
473 "at least one item is required",
474 "is required for",
475 "schema validation",
476 "argument validation failed",
477 "unknown field",
478 "unknown variant",
479 "expected struct",
480 "expected enum",
481 "type mismatch",
482 "must be an absolute path",
483 "not parseable",
484 "parseable as",
485 "invalid patch format",
493 "invalid patch hunk",
494 "invalid patch operation",
495 "cannot parse empty patch",
496 "patch does not contain",
497 "semantic patch anchor",
498 ],
499 ) {
500 return ErrorCategory::InvalidParameters;
501 }
502
503 if contains_any(&msg, &["tool not found", "unknown tool", "unsupported tool", "no such tool"]) {
505 return ErrorCategory::ToolNotFound;
506 }
507
508 if contains_any(
510 &msg,
511 &[
512 "no such file",
513 "no such directory",
514 "file not found",
515 "directory not found",
516 "resource not found",
517 "path not found",
518 "does not exist",
519 "enoent",
520 ],
521 ) {
522 return ErrorCategory::ResourceNotFound;
523 }
524
525 if contains_any(
527 &msg,
528 &[
529 "permission denied",
530 "access denied",
531 "operation not permitted",
532 "eacces",
533 "eperm",
534 "forbidden",
535 "403",
536 ],
537 ) {
538 return ErrorCategory::PermissionDenied;
539 }
540
541 if contains_any(&msg, &["cancelled", "interrupted", "canceled"]) {
543 return ErrorCategory::Cancelled;
544 }
545
546 if contains_any(&msg, &["circuit breaker", "circuit open"]) {
548 return ErrorCategory::CircuitOpen;
549 }
550
551 if contains_any(&msg, &["sandbox denied", "sandbox failure"]) {
553 return ErrorCategory::SandboxFailure;
554 }
555
556 if contains_any(&msg, &["rate limit", "too many requests", "429", "throttl"]) {
558 return ErrorCategory::RateLimit;
559 }
560
561 if contains_any(&msg, &["timeout", "timed out", "deadline exceeded"]) {
563 return ErrorCategory::Timeout;
564 }
565
566 if contains_any(
568 &msg,
569 &[
570 "invalid response format: missing choices",
571 "invalid response format: missing message",
572 "missing choices in response",
573 "missing message in choice",
574 "no choices in response",
575 "invalid response from ",
576 "empty response body",
577 "response did not contain",
578 "unexpected response format",
579 "failed to parse response",
580 "stream disconnected",
586 "stream closed unexpectedly",
587 "incomplete stream",
588 "unexpected end of stream",
589 "truncated response",
590 "failed to decode stream",
591 "stream terminated",
592 ],
593 ) {
594 return ErrorCategory::ServiceUnavailable;
595 }
596
597 if contains_any(
599 &msg,
600 &[
601 "service unavailable",
602 "temporarily unavailable",
603 "internal server error",
604 "bad gateway",
605 "gateway timeout",
606 "overloaded",
607 "500",
608 "502",
609 "503",
610 "504",
611 ],
612 ) {
613 return ErrorCategory::ServiceUnavailable;
614 }
615
616 if contains_any(
618 &msg,
619 &[
620 "network",
621 "connection reset",
622 "connection refused",
623 "broken pipe",
624 "dns",
625 "name resolution",
626 "try again",
627 "retry later",
628 "upstream connect error",
629 "tls handshake",
630 "socket hang up",
631 "econnreset",
632 "etimedout",
633 "error decoding response body",
637 "connection closed before",
642 "stream reset",
643 "stream error received",
644 ],
645 ) {
646 return ErrorCategory::Network;
647 }
648
649 if contains_any(&msg, &["out of memory", "disk full", "no space left"]) {
651 return ErrorCategory::ResourceExhausted;
652 }
653
654 ErrorCategory::ExecutionError
656}
657
658#[inline]
663#[must_use]
664pub fn is_retryable_llm_error_message(msg: &str) -> bool {
665 let category = classify_error_message(msg);
666 category.is_retryable()
667}
668
669#[inline]
670fn contains_any(message: &str, markers: &[&str]) -> bool {
671 markers.iter().any(|marker| message.contains(marker))
672}
673
674impl From<&crate::llm::LLMError> for ErrorCategory {
679 fn from(err: &crate::llm::LLMError) -> Self {
680 match err {
681 crate::llm::LLMError::Authentication { .. } => ErrorCategory::Authentication,
682 crate::llm::LLMError::RateLimit { metadata } => {
683 classify_llm_metadata(metadata.as_deref(), ErrorCategory::RateLimit)
684 }
685 crate::llm::LLMError::InvalidRequest { .. } => ErrorCategory::InvalidParameters,
686 crate::llm::LLMError::Network { .. } => ErrorCategory::Network,
687 crate::llm::LLMError::Provider { message, metadata } => {
688 let metadata_category = classify_llm_metadata(metadata.as_deref(), ErrorCategory::ExecutionError);
689 if metadata_category != ErrorCategory::ExecutionError {
690 return metadata_category;
691 }
692
693 if let Some(meta) = metadata
695 && let Some(status) = meta.status
696 {
697 return match status {
698 401 => ErrorCategory::Authentication,
699 403 => ErrorCategory::PermissionDenied,
700 404 => ErrorCategory::ResourceNotFound,
701 429 => ErrorCategory::RateLimit,
702 400 => ErrorCategory::InvalidParameters,
703 500 | 502 | 503 | 504 => ErrorCategory::ServiceUnavailable,
704 408 => ErrorCategory::Timeout,
705 _ => classify_error_message(message),
706 };
707 }
708 classify_error_message(message)
710 }
711 }
712 }
713}
714
715fn classify_llm_metadata(metadata: Option<&crate::llm::LLMErrorMetadata>, fallback: ErrorCategory) -> ErrorCategory {
716 let Some(metadata) = metadata else {
717 return fallback;
718 };
719
720 let mut hint = String::new();
721 if let Some(code) = &metadata.code {
722 hint.push_str(code);
723 hint.push(' ');
724 }
725 if let Some(message) = &metadata.message {
726 hint.push_str(message);
727 hint.push(' ');
728 }
729 if let Some(status) = metadata.status {
730 let _ = write!(&mut hint, "{status}");
731 }
732
733 let classified = classify_error_message(&hint);
734 if classified == ErrorCategory::ExecutionError {
735 fallback
736 } else {
737 classified
738 }
739}
740
741#[cfg(test)]
742mod tests {
743 use super::*;
744
745 #[test]
748 fn policy_violation_takes_priority_over_permission() {
749 assert_eq!(classify_error_message("tool permission denied by policy"), ErrorCategory::PolicyViolation);
750 }
751
752 #[test]
753 fn rate_limit_classified_correctly() {
754 assert_eq!(classify_error_message("provider returned 429 Too Many Requests"), ErrorCategory::RateLimit);
755 assert_eq!(classify_error_message("rate limit exceeded"), ErrorCategory::RateLimit);
756 }
757
758 #[test]
759 fn service_unavailable_is_classified() {
760 assert_eq!(classify_error_message("503 service unavailable"), ErrorCategory::ServiceUnavailable);
761 }
762
763 #[test]
764 fn follow_up_stream_failures_are_retryable() {
765 for msg in [
768 "follow-up failed: stream disconnected mid-response",
769 "sse stream terminated before completion",
770 "incomplete stream while reading follow-up",
771 "connection closed before response completed",
772 "stream reset by peer during follow-up",
773 "h2 stream error received: internal error",
774 ] {
775 let category = classify_error_message(msg);
776 assert!(category.is_retryable(), "{msg} -> {category:?} should be retryable");
777 }
778 assert_eq!(classify_error_message("upstream stream file ready"), ErrorCategory::ExecutionError);
782 assert_eq!(classify_error_message("downstream error: 400 bad request"), ErrorCategory::ExecutionError);
783 assert_eq!(classify_error_message("something went wrong"), ErrorCategory::ExecutionError);
784 }
785
786 #[test]
787 fn authentication_errors() {
788 assert_eq!(classify_error_message("invalid api key provided"), ErrorCategory::Authentication);
789 assert_eq!(classify_error_message("401 unauthorized"), ErrorCategory::Authentication);
790 }
791
792 #[test]
793 fn billing_errors_are_resource_exhausted() {
794 assert_eq!(
795 classify_error_message("you have reached your weekly usage limit"),
796 ErrorCategory::ResourceExhausted
797 );
798 assert_eq!(classify_error_message("quota exceeded for this model"), ErrorCategory::ResourceExhausted);
799 }
800
801 #[test]
802 fn timeout_errors() {
803 assert_eq!(classify_error_message("connection timeout"), ErrorCategory::Timeout);
804 assert_eq!(classify_error_message("request timed out after 30s"), ErrorCategory::Timeout);
805 }
806
807 #[test]
808 fn network_errors() {
809 assert_eq!(classify_error_message("connection reset by peer"), ErrorCategory::Network);
810 assert_eq!(classify_error_message("dns name resolution failed"), ErrorCategory::Network);
811 }
812
813 #[test]
814 fn tool_not_found() {
815 assert_eq!(classify_error_message("unknown tool: ask_questions"), ErrorCategory::ToolNotFound);
816 }
817
818 #[test]
819 fn resource_not_found() {
820 assert_eq!(classify_error_message("no such file or directory: /tmp/missing"), ErrorCategory::ResourceNotFound);
821 assert_eq!(
822 classify_error_message("Path 'crates/codegen/vtcode-core/src/agent' does not exist"),
823 ErrorCategory::ResourceNotFound
824 );
825 }
826
827 #[test]
828 fn patch_format_errors_are_invalid_parameters() {
829 assert_eq!(
834 classify_error_message("invalid patch format: missing '*** Begin Patch' marker"),
835 ErrorCategory::InvalidParameters
836 );
837 assert_eq!(
838 classify_error_message("invalid patch format: input looks like a standard unified diff (---/+++ format)"),
839 ErrorCategory::InvalidParameters
840 );
841 assert_eq!(
842 classify_error_message("invalid patch hunk on line 5: unexpected end of input"),
843 ErrorCategory::InvalidParameters
844 );
845 assert_eq!(classify_error_message("cannot parse empty patch input"), ErrorCategory::InvalidParameters);
846 assert_eq!(classify_error_message("patch does not contain any operations"), ErrorCategory::InvalidParameters);
847 assert_eq!(
848 classify_error_message("semantic patch anchor 'fn main' for 'src/main.rs' could not be resolved"),
849 ErrorCategory::InvalidParameters
850 );
851 }
852
853 #[test]
854 fn permission_denied() {
855 assert_eq!(classify_error_message("permission denied: /etc/shadow"), ErrorCategory::PermissionDenied);
856 }
857
858 #[test]
859 fn cancelled_operations() {
860 assert_eq!(classify_error_message("operation cancelled by user"), ErrorCategory::Cancelled);
861 }
862
863 #[test]
864 fn planning_policy_violation() {
865 assert_eq!(classify_error_message("not allowed in planning workflow"), ErrorCategory::PolicyViolation);
866 }
867
868 #[test]
869 fn sandbox_failure() {
870 assert_eq!(classify_error_message("sandbox denied this operation"), ErrorCategory::SandboxFailure);
871 }
872
873 #[test]
874 fn unknown_error_is_execution_error() {
875 assert_eq!(classify_error_message("something went wrong"), ErrorCategory::ExecutionError);
876 }
877
878 #[test]
879 fn invalid_parameters() {
880 assert_eq!(classify_error_message("invalid argument: missing path field"), ErrorCategory::InvalidParameters);
881 assert_eq!(
882 classify_error_message("Failed to parse arguments for read_file handler: invalid type: boolean `false`"),
883 ErrorCategory::InvalidParameters
884 );
885 assert_eq!(
886 classify_error_message("at least one item is required for 'create'"),
887 ErrorCategory::InvalidParameters
888 );
889 assert_eq!(
890 classify_error_message("structural pattern preflight failed: pattern is not parseable as Rust syntax"),
891 ErrorCategory::InvalidParameters
892 );
893 }
894
895 #[test]
898 fn retryable_categories() {
899 assert!(ErrorCategory::Network.is_retryable());
900 assert!(ErrorCategory::Timeout.is_retryable());
901 assert!(ErrorCategory::RateLimit.is_retryable());
902 assert!(ErrorCategory::ServiceUnavailable.is_retryable());
903 assert!(ErrorCategory::CircuitOpen.is_retryable());
904 }
905
906 #[test]
907 fn non_retryable_categories() {
908 assert!(!ErrorCategory::Authentication.is_retryable());
909 assert!(!ErrorCategory::InvalidParameters.is_retryable());
910 assert!(!ErrorCategory::PolicyViolation.is_retryable());
911 assert!(!ErrorCategory::ResourceExhausted.is_retryable());
912 assert!(!ErrorCategory::Cancelled.is_retryable());
913 }
914
915 #[test]
916 fn permanent_error_detection() {
917 assert!(ErrorCategory::Authentication.is_permanent());
918 assert!(ErrorCategory::PolicyViolation.is_permanent());
919 assert!(!ErrorCategory::Network.is_permanent());
920 assert!(!ErrorCategory::Timeout.is_permanent());
921 }
922
923 #[test]
924 fn llm_mistake_detection() {
925 assert!(ErrorCategory::InvalidParameters.is_llm_mistake());
926 assert!(!ErrorCategory::Network.is_llm_mistake());
927 assert!(!ErrorCategory::Timeout.is_llm_mistake());
928 }
929
930 #[test]
933 fn llm_error_authentication_converts() {
934 let err = crate::llm::LLMError::Authentication { message: "bad key".to_string(), metadata: None };
935 assert_eq!(ErrorCategory::from(&err), ErrorCategory::Authentication);
936 }
937
938 #[test]
939 fn llm_error_rate_limit_converts() {
940 let err = crate::llm::LLMError::RateLimit { metadata: None };
941 assert_eq!(ErrorCategory::from(&err), ErrorCategory::RateLimit);
942 }
943
944 #[test]
945 fn llm_error_quota_exhaustion_converts() {
946 let err = crate::llm::LLMError::RateLimit {
947 metadata: Some(crate::llm::LLMErrorMetadata::new(
948 "openai",
949 Some(429),
950 Some("insufficient_quota".to_string()),
951 None,
952 None,
953 None,
954 Some("quota exceeded".to_string()),
955 )),
956 };
957
958 assert_eq!(ErrorCategory::from(&err), ErrorCategory::ResourceExhausted);
959 }
960
961 #[test]
962 fn llm_error_network_converts() {
963 let err = crate::llm::LLMError::Network {
964 message: "connection refused".to_string(),
965 metadata: None,
966 };
967 assert_eq!(ErrorCategory::from(&err), ErrorCategory::Network);
968 }
969
970 #[test]
971 fn llm_error_provider_with_status_code() {
972 use crate::llm::LLMErrorMetadata;
973 let err = crate::llm::LLMError::Provider {
974 message: "error".to_string(),
975 metadata: Some(LLMErrorMetadata::new("openai", Some(503), None, None, None, None, None)),
976 };
977 assert_eq!(ErrorCategory::from(&err), ErrorCategory::ServiceUnavailable);
978 }
979
980 #[test]
981 fn minimax_invalid_response_is_service_unavailable() {
982 assert_eq!(
983 classify_error_message("Invalid response from MiniMax: missing choices"),
984 ErrorCategory::ServiceUnavailable
985 );
986 assert_eq!(
987 classify_error_message("Invalid response format: missing message"),
988 ErrorCategory::ServiceUnavailable
989 );
990 }
991
992 #[test]
995 fn retryable_llm_messages() {
996 assert!(is_retryable_llm_error_message("429 too many requests"));
997 assert!(is_retryable_llm_error_message("500 internal server error"));
998 assert!(is_retryable_llm_error_message("connection timeout"));
999 assert!(is_retryable_llm_error_message("network error"));
1000 }
1001
1002 #[test]
1003 fn non_retryable_llm_messages() {
1004 assert!(!is_retryable_llm_error_message("invalid api key"));
1005 assert!(!is_retryable_llm_error_message("weekly usage limit reached"));
1006 assert!(!is_retryable_llm_error_message("permission denied"));
1007 }
1008
1009 #[test]
1010 fn context_capacity_markers_are_specific() {
1011 assert!(is_context_capacity_message("invalid request: maximum context length is 114688 tokens"));
1012 assert!(is_context_capacity_message("input token count exceeds the maximum number of tokens allowed"));
1013 assert!(!is_context_capacity_message("invalid request: context field is missing"));
1014 }
1015
1016 #[test]
1019 fn recovery_suggestions_non_empty() {
1020 for cat in [
1021 ErrorCategory::Network,
1022 ErrorCategory::Timeout,
1023 ErrorCategory::RateLimit,
1024 ErrorCategory::Authentication,
1025 ErrorCategory::InvalidParameters,
1026 ErrorCategory::ToolNotFound,
1027 ErrorCategory::ResourceNotFound,
1028 ErrorCategory::PermissionDenied,
1029 ErrorCategory::PolicyViolation,
1030 ErrorCategory::ExecutionError,
1031 ] {
1032 assert!(!cat.recovery_suggestions().is_empty(), "Missing recovery suggestions for {cat:?}");
1033 }
1034 }
1035
1036 #[test]
1039 fn user_labels_are_non_empty() {
1040 assert!(!ErrorCategory::Network.user_label().is_empty());
1041 assert!(!ErrorCategory::ExecutionError.user_label().is_empty());
1042 }
1043
1044 #[test]
1047 fn auth_recovery_guidance_no_credential_mentions_secret_add() {
1048 let guidance = ErrorCategory::Authentication.auth_recovery_guidance("StepFun", "stepfun", false, false);
1049 assert_eq!(guidance.len(), 1);
1050 assert_eq!(
1051 guidance[0],
1052 "Authentication failed for StepFun. Run /secret add stepfun to store your API key in secure storage (OS keyring or encrypted file)."
1053 );
1054 }
1055
1056 #[test]
1057 fn auth_recovery_guidance_credential_stored_mentions_overwrite() {
1058 let guidance = ErrorCategory::Authentication.auth_recovery_guidance("StepFun", "stepfun", false, true);
1059 assert_eq!(guidance.len(), 1);
1060 assert_eq!(
1061 guidance[0],
1062 "Authentication failed for StepFun. The stored API key was rejected — run /secret add stepfun to replace it with a valid key."
1063 );
1064 }
1065
1066 #[test]
1067 fn auth_recovery_guidance_managed_auth_provider_mentions_login() {
1068 let guidance = ErrorCategory::Authentication.auth_recovery_guidance("GitHub Copilot", "copilot", true, false);
1069 assert_eq!(guidance.len(), 1);
1070 assert_eq!(guidance[0], "Authentication failed for GitHub Copilot. Run /login copilot to re-authenticate.");
1071 }
1072
1073 #[test]
1074 fn auth_recovery_guidance_non_auth_category_returns_empty() {
1075 assert!(
1076 ErrorCategory::Network
1077 .auth_recovery_guidance("OpenAI", "openai", false, false)
1078 .is_empty()
1079 );
1080 assert!(
1081 ErrorCategory::Timeout
1082 .auth_recovery_guidance("OpenAI", "openai", false, false)
1083 .is_empty()
1084 );
1085 }
1086
1087 #[test]
1090 fn display_matches_user_label() {
1091 assert_eq!(format!("{}", ErrorCategory::RateLimit), ErrorCategory::RateLimit.user_label());
1092 }
1093}