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 "context limit exceeded",
390 "context size exceeded",
391 "exceeds context",
392 "input tokens exceed",
393 "prompt tokens exceed",
394 "prompt token count exceeds",
395 "token limit exceeded",
396 "input too large",
397 "prompt too large",
398 ],
399 )
400}
401
402#[inline]
407#[must_use]
408pub fn classify_error_message(msg: &str) -> ErrorCategory {
409 let msg = if msg.as_bytes().iter().any(|b| b.is_ascii_uppercase()) {
410 Cow::Owned(msg.to_ascii_lowercase())
411 } else {
412 Cow::Borrowed(msg)
413 };
414
415 if contains_any(
417 &msg,
418 &[
419 "policy violation",
420 "denied by policy",
421 "tool permission denied",
422 "safety validation failed",
423 "not allowed in planning workflow",
424 "only available when planning workflow is active",
425 "workspace boundary",
426 "blocked by policy",
427 ],
428 ) {
429 return ErrorCategory::PolicyViolation;
430 }
431
432 if contains_any(
434 &msg,
435 &[
436 "planning workflow",
437 "read-only permissions",
438 concat!("read-only ", "mode"),
439 "planning_policy_violation",
440 ],
441 ) {
442 return ErrorCategory::PlanningPolicyViolation;
443 }
444
445 if contains_any(
447 &msg,
448 &[
449 "invalid api key",
450 "authentication failed",
451 "unauthorized",
452 "401",
453 "invalid credentials",
454 ],
455 ) {
456 return ErrorCategory::Authentication;
457 }
458
459 if contains_any(
461 &msg,
462 &[
463 "weekly usage limit",
464 "daily usage limit",
465 "monthly spending limit",
466 "insufficient credits",
467 "quota exceeded",
468 "billing",
469 "payment required",
470 ],
471 ) {
472 return ErrorCategory::ResourceExhausted;
473 }
474
475 if contains_any(
477 &msg,
478 &[
479 "invalid argument",
480 "invalid parameters",
481 "invalid type",
482 "malformed",
483 "failed to parse arguments",
484 "failed to parse argument",
485 "missing required",
486 "at least one item is required",
487 "is required for",
488 "schema validation",
489 "argument validation failed",
490 "unknown field",
491 "unknown variant",
492 "expected struct",
493 "expected enum",
494 "type mismatch",
495 "must be an absolute path",
496 "not parseable",
497 "parseable as",
498 "invalid patch format",
506 "invalid patch hunk",
507 "invalid patch operation",
508 "cannot parse empty patch",
509 "patch does not contain",
510 "semantic patch anchor",
511 ],
512 ) {
513 return ErrorCategory::InvalidParameters;
514 }
515
516 if contains_any(&msg, &["tool not found", "unknown tool", "unsupported tool", "no such tool"]) {
518 return ErrorCategory::ToolNotFound;
519 }
520
521 if contains_any(
523 &msg,
524 &[
525 "no such file",
526 "no such directory",
527 "file not found",
528 "directory not found",
529 "resource not found",
530 "path not found",
531 "does not exist",
532 "enoent",
533 ],
534 ) {
535 return ErrorCategory::ResourceNotFound;
536 }
537
538 if contains_any(
540 &msg,
541 &[
542 "permission denied",
543 "access denied",
544 "operation not permitted",
545 "eacces",
546 "eperm",
547 "forbidden",
548 "403",
549 ],
550 ) {
551 return ErrorCategory::PermissionDenied;
552 }
553
554 if contains_any(&msg, &["cancelled", "interrupted", "canceled"]) {
556 return ErrorCategory::Cancelled;
557 }
558
559 if contains_any(&msg, &["circuit breaker", "circuit open"]) {
561 return ErrorCategory::CircuitOpen;
562 }
563
564 if contains_any(&msg, &["sandbox denied", "sandbox failure"]) {
566 return ErrorCategory::SandboxFailure;
567 }
568
569 if contains_any(&msg, &["rate limit", "too many requests", "429", "throttl"]) {
571 return ErrorCategory::RateLimit;
572 }
573
574 if contains_any(&msg, &["timeout", "timed out", "deadline exceeded"]) {
576 return ErrorCategory::Timeout;
577 }
578
579 if contains_any(
581 &msg,
582 &[
583 "invalid response format: missing choices",
584 "invalid response format: missing message",
585 "missing choices in response",
586 "missing message in choice",
587 "no choices in response",
588 "invalid response from ",
589 "empty response body",
590 "response did not contain",
591 "unexpected response format",
592 "failed to parse response",
593 "stream disconnected",
599 "stream closed unexpectedly",
600 "incomplete stream",
601 "unexpected end of stream",
602 "truncated response",
603 "failed to decode stream",
604 "stream terminated",
605 ],
606 ) {
607 return ErrorCategory::ServiceUnavailable;
608 }
609
610 if contains_any(
612 &msg,
613 &[
614 "service unavailable",
615 "temporarily unavailable",
616 "internal server error",
617 "bad gateway",
618 "gateway timeout",
619 "overloaded",
620 "500",
621 "502",
622 "503",
623 "504",
624 ],
625 ) {
626 return ErrorCategory::ServiceUnavailable;
627 }
628
629 if contains_any(
631 &msg,
632 &[
633 "network",
634 "connection reset",
635 "connection refused",
636 "broken pipe",
637 "dns",
638 "name resolution",
639 "try again",
640 "retry later",
641 "upstream connect error",
642 "tls handshake",
643 "socket hang up",
644 "econnreset",
645 "etimedout",
646 "error decoding response body",
650 "connection closed before",
655 "stream reset",
656 "stream error received",
657 ],
658 ) {
659 return ErrorCategory::Network;
660 }
661
662 if contains_any(&msg, &["out of memory", "disk full", "no space left"]) {
664 return ErrorCategory::ResourceExhausted;
665 }
666
667 ErrorCategory::ExecutionError
669}
670
671#[inline]
676#[must_use]
677pub fn is_retryable_llm_error_message(msg: &str) -> bool {
678 let category = classify_error_message(msg);
679 category.is_retryable()
680}
681
682#[inline]
683fn contains_any(message: &str, markers: &[&str]) -> bool {
684 markers.iter().any(|marker| message.contains(marker))
685}
686
687impl From<&crate::llm::LLMError> for ErrorCategory {
692 fn from(err: &crate::llm::LLMError) -> Self {
693 match err {
694 crate::llm::LLMError::Authentication { .. } => ErrorCategory::Authentication,
695 crate::llm::LLMError::RateLimit { metadata } => {
696 classify_llm_metadata(metadata.as_deref(), ErrorCategory::RateLimit)
697 }
698 crate::llm::LLMError::InvalidRequest { .. } => ErrorCategory::InvalidParameters,
699 crate::llm::LLMError::Network { .. } => ErrorCategory::Network,
700 crate::llm::LLMError::Provider { message, metadata } => {
701 let metadata_category = classify_llm_metadata(metadata.as_deref(), ErrorCategory::ExecutionError);
702 if metadata_category != ErrorCategory::ExecutionError {
703 return metadata_category;
704 }
705
706 if let Some(meta) = metadata
708 && let Some(status) = meta.status
709 {
710 return match status {
711 401 => ErrorCategory::Authentication,
712 403 => ErrorCategory::PermissionDenied,
713 404 => ErrorCategory::ResourceNotFound,
714 429 => ErrorCategory::RateLimit,
715 400 => ErrorCategory::InvalidParameters,
716 500 | 502 | 503 | 504 => ErrorCategory::ServiceUnavailable,
717 408 => ErrorCategory::Timeout,
718 _ => classify_error_message(message),
719 };
720 }
721 classify_error_message(message)
723 }
724 }
725 }
726}
727
728fn classify_llm_metadata(metadata: Option<&crate::llm::LLMErrorMetadata>, fallback: ErrorCategory) -> ErrorCategory {
729 let Some(metadata) = metadata else {
730 return fallback;
731 };
732
733 let mut hint = String::new();
734 if let Some(code) = &metadata.code {
735 hint.push_str(code);
736 hint.push(' ');
737 }
738 if let Some(message) = &metadata.message {
739 hint.push_str(message);
740 hint.push(' ');
741 }
742 if let Some(status) = metadata.status {
743 let _ = write!(&mut hint, "{status}");
744 }
745
746 let classified = classify_error_message(&hint);
747 if classified == ErrorCategory::ExecutionError {
748 fallback
749 } else {
750 classified
751 }
752}
753
754#[cfg(test)]
755mod tests {
756 use super::*;
757
758 #[test]
761 fn policy_violation_takes_priority_over_permission() {
762 assert_eq!(classify_error_message("tool permission denied by policy"), ErrorCategory::PolicyViolation);
763 }
764
765 #[test]
766 fn rate_limit_classified_correctly() {
767 assert_eq!(classify_error_message("provider returned 429 Too Many Requests"), ErrorCategory::RateLimit);
768 assert_eq!(classify_error_message("rate limit exceeded"), ErrorCategory::RateLimit);
769 }
770
771 #[test]
772 fn service_unavailable_is_classified() {
773 assert_eq!(classify_error_message("503 service unavailable"), ErrorCategory::ServiceUnavailable);
774 }
775
776 #[test]
777 fn follow_up_stream_failures_are_retryable() {
778 for msg in [
781 "follow-up failed: stream disconnected mid-response",
782 "sse stream terminated before completion",
783 "incomplete stream while reading follow-up",
784 "connection closed before response completed",
785 "stream reset by peer during follow-up",
786 "h2 stream error received: internal error",
787 ] {
788 let category = classify_error_message(msg);
789 assert!(category.is_retryable(), "{msg} -> {category:?} should be retryable");
790 }
791 assert_eq!(classify_error_message("upstream stream file ready"), ErrorCategory::ExecutionError);
795 assert_eq!(classify_error_message("downstream error: 400 bad request"), ErrorCategory::ExecutionError);
796 assert_eq!(classify_error_message("something went wrong"), ErrorCategory::ExecutionError);
797 }
798
799 #[test]
800 fn authentication_errors() {
801 assert_eq!(classify_error_message("invalid api key provided"), ErrorCategory::Authentication);
802 assert_eq!(classify_error_message("401 unauthorized"), ErrorCategory::Authentication);
803 }
804
805 #[test]
806 fn billing_errors_are_resource_exhausted() {
807 assert_eq!(
808 classify_error_message("you have reached your weekly usage limit"),
809 ErrorCategory::ResourceExhausted
810 );
811 assert_eq!(classify_error_message("quota exceeded for this model"), ErrorCategory::ResourceExhausted);
812 }
813
814 #[test]
815 fn timeout_errors() {
816 assert_eq!(classify_error_message("connection timeout"), ErrorCategory::Timeout);
817 assert_eq!(classify_error_message("request timed out after 30s"), ErrorCategory::Timeout);
818 }
819
820 #[test]
821 fn network_errors() {
822 assert_eq!(classify_error_message("connection reset by peer"), ErrorCategory::Network);
823 assert_eq!(classify_error_message("dns name resolution failed"), ErrorCategory::Network);
824 }
825
826 #[test]
827 fn tool_not_found() {
828 assert_eq!(classify_error_message("unknown tool: ask_questions"), ErrorCategory::ToolNotFound);
829 }
830
831 #[test]
832 fn resource_not_found() {
833 assert_eq!(classify_error_message("no such file or directory: /tmp/missing"), ErrorCategory::ResourceNotFound);
834 assert_eq!(
835 classify_error_message("Path 'crates/codegen/vtcode-core/src/agent' does not exist"),
836 ErrorCategory::ResourceNotFound
837 );
838 }
839
840 #[test]
841 fn patch_format_errors_are_invalid_parameters() {
842 assert_eq!(
847 classify_error_message("invalid patch format: missing '*** Begin Patch' marker"),
848 ErrorCategory::InvalidParameters
849 );
850 assert_eq!(
851 classify_error_message("invalid patch format: input looks like a standard unified diff (---/+++ format)"),
852 ErrorCategory::InvalidParameters
853 );
854 assert_eq!(
855 classify_error_message("invalid patch hunk on line 5: unexpected end of input"),
856 ErrorCategory::InvalidParameters
857 );
858 assert_eq!(classify_error_message("cannot parse empty patch input"), ErrorCategory::InvalidParameters);
859 assert_eq!(classify_error_message("patch does not contain any operations"), ErrorCategory::InvalidParameters);
860 assert_eq!(
861 classify_error_message("semantic patch anchor 'fn main' for 'src/main.rs' could not be resolved"),
862 ErrorCategory::InvalidParameters
863 );
864 }
865
866 #[test]
867 fn permission_denied() {
868 assert_eq!(classify_error_message("permission denied: /etc/shadow"), ErrorCategory::PermissionDenied);
869 }
870
871 #[test]
872 fn cancelled_operations() {
873 assert_eq!(classify_error_message("operation cancelled by user"), ErrorCategory::Cancelled);
874 }
875
876 #[test]
877 fn planning_policy_violation() {
878 assert_eq!(classify_error_message("not allowed in planning workflow"), ErrorCategory::PolicyViolation);
879 }
880
881 #[test]
882 fn sandbox_failure() {
883 assert_eq!(classify_error_message("sandbox denied this operation"), ErrorCategory::SandboxFailure);
884 }
885
886 #[test]
887 fn unknown_error_is_execution_error() {
888 assert_eq!(classify_error_message("something went wrong"), ErrorCategory::ExecutionError);
889 }
890
891 #[test]
892 fn invalid_parameters() {
893 assert_eq!(classify_error_message("invalid argument: missing path field"), ErrorCategory::InvalidParameters);
894 assert_eq!(
895 classify_error_message("Failed to parse arguments for read_file handler: invalid type: boolean `false`"),
896 ErrorCategory::InvalidParameters
897 );
898 assert_eq!(
899 classify_error_message("at least one item is required for 'create'"),
900 ErrorCategory::InvalidParameters
901 );
902 assert_eq!(
903 classify_error_message("structural pattern preflight failed: pattern is not parseable as Rust syntax"),
904 ErrorCategory::InvalidParameters
905 );
906 }
907
908 #[test]
911 fn retryable_categories() {
912 assert!(ErrorCategory::Network.is_retryable());
913 assert!(ErrorCategory::Timeout.is_retryable());
914 assert!(ErrorCategory::RateLimit.is_retryable());
915 assert!(ErrorCategory::ServiceUnavailable.is_retryable());
916 assert!(ErrorCategory::CircuitOpen.is_retryable());
917 }
918
919 #[test]
920 fn non_retryable_categories() {
921 assert!(!ErrorCategory::Authentication.is_retryable());
922 assert!(!ErrorCategory::InvalidParameters.is_retryable());
923 assert!(!ErrorCategory::PolicyViolation.is_retryable());
924 assert!(!ErrorCategory::ResourceExhausted.is_retryable());
925 assert!(!ErrorCategory::Cancelled.is_retryable());
926 }
927
928 #[test]
929 fn permanent_error_detection() {
930 assert!(ErrorCategory::Authentication.is_permanent());
931 assert!(ErrorCategory::PolicyViolation.is_permanent());
932 assert!(!ErrorCategory::Network.is_permanent());
933 assert!(!ErrorCategory::Timeout.is_permanent());
934 }
935
936 #[test]
937 fn llm_mistake_detection() {
938 assert!(ErrorCategory::InvalidParameters.is_llm_mistake());
939 assert!(!ErrorCategory::Network.is_llm_mistake());
940 assert!(!ErrorCategory::Timeout.is_llm_mistake());
941 }
942
943 #[test]
946 fn llm_error_authentication_converts() {
947 let err = crate::llm::LLMError::Authentication { message: "bad key".to_string(), metadata: None };
948 assert_eq!(ErrorCategory::from(&err), ErrorCategory::Authentication);
949 }
950
951 #[test]
952 fn llm_error_rate_limit_converts() {
953 let err = crate::llm::LLMError::RateLimit { metadata: None };
954 assert_eq!(ErrorCategory::from(&err), ErrorCategory::RateLimit);
955 }
956
957 #[test]
958 fn llm_error_quota_exhaustion_converts() {
959 let err = crate::llm::LLMError::RateLimit {
960 metadata: Some(crate::llm::LLMErrorMetadata::new(
961 "openai",
962 Some(429),
963 Some("insufficient_quota".to_string()),
964 None,
965 None,
966 None,
967 Some("quota exceeded".to_string()),
968 )),
969 };
970
971 assert_eq!(ErrorCategory::from(&err), ErrorCategory::ResourceExhausted);
972 }
973
974 #[test]
975 fn llm_error_network_converts() {
976 let err = crate::llm::LLMError::Network {
977 message: "connection refused".to_string(),
978 metadata: None,
979 };
980 assert_eq!(ErrorCategory::from(&err), ErrorCategory::Network);
981 }
982
983 #[test]
984 fn llm_error_provider_with_status_code() {
985 use crate::llm::LLMErrorMetadata;
986 let err = crate::llm::LLMError::Provider {
987 message: "error".to_string(),
988 metadata: Some(LLMErrorMetadata::new("openai", Some(503), None, None, None, None, None)),
989 };
990 assert_eq!(ErrorCategory::from(&err), ErrorCategory::ServiceUnavailable);
991 }
992
993 #[test]
994 fn minimax_invalid_response_is_service_unavailable() {
995 assert_eq!(
996 classify_error_message("Invalid response from MiniMax: missing choices"),
997 ErrorCategory::ServiceUnavailable
998 );
999 assert_eq!(
1000 classify_error_message("Invalid response format: missing message"),
1001 ErrorCategory::ServiceUnavailable
1002 );
1003 }
1004
1005 #[test]
1008 fn retryable_llm_messages() {
1009 assert!(is_retryable_llm_error_message("429 too many requests"));
1010 assert!(is_retryable_llm_error_message("500 internal server error"));
1011 assert!(is_retryable_llm_error_message("connection timeout"));
1012 assert!(is_retryable_llm_error_message("network error"));
1013 }
1014
1015 #[test]
1016 fn non_retryable_llm_messages() {
1017 assert!(!is_retryable_llm_error_message("invalid api key"));
1018 assert!(!is_retryable_llm_error_message("weekly usage limit reached"));
1019 assert!(!is_retryable_llm_error_message("permission denied"));
1020 }
1021
1022 #[test]
1023 fn context_capacity_markers_are_specific() {
1024 assert!(is_context_capacity_message("invalid request: maximum context length is 114688 tokens"));
1025 assert!(is_context_capacity_message("input token count exceeds the maximum number of tokens allowed"));
1026 assert!(!is_context_capacity_message("invalid request: context field is missing"));
1027 }
1028
1029 #[test]
1030 fn context_capacity_markers_cover_gateway_variants() {
1031 for msg in [
1035 "input tokens exceed context limit (956758 > 1000000)",
1036 "prompt tokens exceed the model context window",
1037 "context limit exceeded: too many input tokens",
1038 "request exceeds context size for anthropic/claude-sonnet-5",
1039 "token limit exceeded for prompt",
1040 "prompt too large for context window",
1041 ] {
1042 assert!(is_context_capacity_message(msg), "{msg} should be a capacity signal");
1043 }
1044 assert!(!is_context_capacity_message("upstream stream file ready"));
1047 assert!(!is_context_capacity_message("context limited to 5 tools"));
1050 }
1051
1052 #[test]
1055 fn recovery_suggestions_non_empty() {
1056 for cat in [
1057 ErrorCategory::Network,
1058 ErrorCategory::Timeout,
1059 ErrorCategory::RateLimit,
1060 ErrorCategory::Authentication,
1061 ErrorCategory::InvalidParameters,
1062 ErrorCategory::ToolNotFound,
1063 ErrorCategory::ResourceNotFound,
1064 ErrorCategory::PermissionDenied,
1065 ErrorCategory::PolicyViolation,
1066 ErrorCategory::ExecutionError,
1067 ] {
1068 assert!(!cat.recovery_suggestions().is_empty(), "Missing recovery suggestions for {cat:?}");
1069 }
1070 }
1071
1072 #[test]
1075 fn user_labels_are_non_empty() {
1076 assert!(!ErrorCategory::Network.user_label().is_empty());
1077 assert!(!ErrorCategory::ExecutionError.user_label().is_empty());
1078 }
1079
1080 #[test]
1083 fn auth_recovery_guidance_no_credential_mentions_secret_add() {
1084 let guidance = ErrorCategory::Authentication.auth_recovery_guidance("StepFun", "stepfun", false, false);
1085 assert_eq!(guidance.len(), 1);
1086 assert_eq!(
1087 guidance[0],
1088 "Authentication failed for StepFun. Run /secret add stepfun to store your API key in secure storage (OS keyring or encrypted file)."
1089 );
1090 }
1091
1092 #[test]
1093 fn auth_recovery_guidance_credential_stored_mentions_overwrite() {
1094 let guidance = ErrorCategory::Authentication.auth_recovery_guidance("StepFun", "stepfun", false, true);
1095 assert_eq!(guidance.len(), 1);
1096 assert_eq!(
1097 guidance[0],
1098 "Authentication failed for StepFun. The stored API key was rejected — run /secret add stepfun to replace it with a valid key."
1099 );
1100 }
1101
1102 #[test]
1103 fn auth_recovery_guidance_managed_auth_provider_mentions_login() {
1104 let guidance = ErrorCategory::Authentication.auth_recovery_guidance("GitHub Copilot", "copilot", true, false);
1105 assert_eq!(guidance.len(), 1);
1106 assert_eq!(guidance[0], "Authentication failed for GitHub Copilot. Run /login copilot to re-authenticate.");
1107 }
1108
1109 #[test]
1110 fn auth_recovery_guidance_non_auth_category_returns_empty() {
1111 assert!(
1112 ErrorCategory::Network
1113 .auth_recovery_guidance("OpenAI", "openai", false, false)
1114 .is_empty()
1115 );
1116 assert!(
1117 ErrorCategory::Timeout
1118 .auth_recovery_guidance("OpenAI", "openai", false, false)
1119 .is_empty()
1120 );
1121 }
1122
1123 #[test]
1126 fn display_matches_user_label() {
1127 assert_eq!(format!("{}", ErrorCategory::RateLimit), ErrorCategory::RateLimit.user_label());
1128 }
1129}