1use std::borrow::Cow;
22use std::fmt;
23use std::fmt::Write;
24use std::time::Duration;
25
26#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize)]
29pub enum ErrorCategory {
30 Network,
33 Timeout,
35 RateLimit,
37 ServiceUnavailable,
39 CircuitOpen,
41
42 Authentication,
45 InvalidParameters,
47 ToolNotFound,
49 ResourceNotFound,
51 PermissionDenied,
53 PolicyViolation,
55 PlanningPolicyViolation,
57 SandboxFailure,
59 ResourceExhausted,
61 Cancelled,
63 ExecutionError,
65}
66
67#[derive(Debug, Clone, PartialEq, Eq)]
69pub enum Retryability {
70 Retryable {
72 max_attempts: u32,
74 backoff: BackoffStrategy,
76 },
77 NonRetryable,
79 RequiresIntervention,
81}
82
83#[derive(Debug, Clone, PartialEq, Eq)]
85pub enum BackoffStrategy {
86 Exponential { base: Duration, max: Duration },
88 Fixed(Duration),
90}
91
92impl ErrorCategory {
93 #[inline]
95 #[must_use]
96 pub const fn is_retryable(&self) -> bool {
97 matches!(
98 self,
99 ErrorCategory::Network
100 | ErrorCategory::Timeout
101 | ErrorCategory::RateLimit
102 | ErrorCategory::ServiceUnavailable
103 | ErrorCategory::CircuitOpen
104 )
105 }
106
107 #[inline]
109 #[must_use]
110 pub const fn should_trip_circuit_breaker(&self) -> bool {
111 matches!(
112 self,
113 ErrorCategory::Network
114 | ErrorCategory::Timeout
115 | ErrorCategory::RateLimit
116 | ErrorCategory::ServiceUnavailable
117 | ErrorCategory::ExecutionError
118 )
119 }
120
121 #[inline]
124 #[must_use]
125 pub const fn is_llm_mistake(&self) -> bool {
126 matches!(self, ErrorCategory::InvalidParameters)
127 }
128
129 #[inline]
131 #[must_use]
132 pub const fn is_permanent(&self) -> bool {
133 matches!(
134 self,
135 ErrorCategory::Authentication
136 | ErrorCategory::PolicyViolation
137 | ErrorCategory::PlanningPolicyViolation
138 | ErrorCategory::ResourceExhausted
139 )
140 }
141
142 #[must_use]
144 pub fn retryability(&self) -> Retryability {
145 match self {
146 ErrorCategory::Network | ErrorCategory::ServiceUnavailable => Retryability::Retryable {
147 max_attempts: 3,
148 backoff: BackoffStrategy::Exponential {
149 base: Duration::from_millis(500),
150 max: Duration::from_secs(10),
151 },
152 },
153 ErrorCategory::Timeout => Retryability::Retryable {
154 max_attempts: 2,
155 backoff: BackoffStrategy::Exponential {
156 base: Duration::from_millis(1000),
157 max: Duration::from_secs(15),
158 },
159 },
160 ErrorCategory::RateLimit => Retryability::Retryable {
161 max_attempts: 3,
162 backoff: BackoffStrategy::Exponential {
163 base: Duration::from_secs(1),
164 max: Duration::from_secs(30),
165 },
166 },
167 ErrorCategory::CircuitOpen => Retryability::Retryable {
168 max_attempts: 1,
169 backoff: BackoffStrategy::Fixed(Duration::from_secs(10)),
170 },
171 ErrorCategory::PermissionDenied => Retryability::RequiresIntervention,
172 _ => Retryability::NonRetryable,
173 }
174 }
175
176 #[must_use]
179 pub fn recovery_suggestions(&self) -> Vec<Cow<'static, str>> {
180 match self {
181 ErrorCategory::Network => vec![
182 Cow::Borrowed("Check network connectivity"),
183 Cow::Borrowed("Retry the operation after a brief delay"),
184 Cow::Borrowed("Verify external service availability"),
185 ],
186 ErrorCategory::Timeout => vec![
187 Cow::Borrowed("Increase timeout values if appropriate"),
188 Cow::Borrowed("Break large operations into smaller chunks"),
189 Cow::Borrowed("Check system resources and performance"),
190 ],
191 ErrorCategory::RateLimit => vec![
192 Cow::Borrowed("Wait before retrying the request"),
193 Cow::Borrowed("Reduce request frequency"),
194 Cow::Borrowed("Check provider rate limit documentation"),
195 ],
196 ErrorCategory::ServiceUnavailable => vec![
197 Cow::Borrowed("The service is temporarily unavailable"),
198 Cow::Borrowed("Retry after a brief delay"),
199 Cow::Borrowed("Check service status page if available"),
200 ],
201 ErrorCategory::CircuitOpen => vec![
202 Cow::Borrowed("This tool has been temporarily disabled due to repeated failures"),
203 Cow::Borrowed("Wait for the circuit breaker cooldown period"),
204 Cow::Borrowed("Try an alternative approach"),
205 ],
206 ErrorCategory::Authentication => vec![
207 Cow::Borrowed("Verify your API key or credentials"),
208 Cow::Borrowed("Check that your account is active and has sufficient permissions"),
209 Cow::Borrowed("Ensure environment variables for API keys are set correctly"),
210 ],
211 ErrorCategory::InvalidParameters => vec![
212 Cow::Borrowed("Check parameter names and types against the tool schema"),
213 Cow::Borrowed("Ensure required parameters are provided"),
214 Cow::Borrowed("Verify parameter values are within acceptable ranges"),
215 ],
216 ErrorCategory::ToolNotFound => vec![
217 Cow::Borrowed("Verify the tool name is spelled correctly"),
218 Cow::Borrowed("Check if the tool is available in the current context"),
219 ],
220 ErrorCategory::ResourceNotFound => vec![
221 Cow::Borrowed("Verify file paths and resource locations"),
222 Cow::Borrowed("Check if files exist and are accessible"),
223 Cow::Borrowed("Use list_dir to explore available resources"),
224 ],
225 ErrorCategory::PermissionDenied => vec![
226 Cow::Borrowed("Check file permissions and access rights"),
227 Cow::Borrowed("Ensure workspace boundaries are respected"),
228 ],
229 ErrorCategory::PolicyViolation => vec![
230 Cow::Borrowed("Review workspace policies and restrictions"),
231 Cow::Borrowed("Use alternative tools that comply with policies"),
232 ],
233 ErrorCategory::PlanningPolicyViolation => vec![
234 Cow::Borrowed(
235 "This operation is not allowed in the Planning workflow with read-only permissions",
236 ),
237 Cow::Borrowed("Exit the Planning workflow to perform mutating operations"),
238 ],
239 ErrorCategory::SandboxFailure => vec![
240 Cow::Borrowed("The sandbox denied this operation"),
241 Cow::Borrowed("Check sandbox configuration and permissions"),
242 ],
243 ErrorCategory::ResourceExhausted => vec![
244 Cow::Borrowed("Check your account usage limits and billing status"),
245 Cow::Borrowed("Review resource consumption and optimize if possible"),
246 ],
247 ErrorCategory::Cancelled => vec![Cow::Borrowed("The operation was cancelled")],
248 ErrorCategory::ExecutionError => vec![
249 Cow::Borrowed("Review error details for specific issues"),
250 Cow::Borrowed("Check tool documentation for known limitations"),
251 ],
252 }
253 }
254
255 #[must_use]
257 pub const fn user_label(&self) -> &'static str {
258 match self {
259 ErrorCategory::Network => "Network error",
260 ErrorCategory::Timeout => "Request timed out",
261 ErrorCategory::RateLimit => "Rate limit exceeded",
262 ErrorCategory::ServiceUnavailable => "Service temporarily unavailable",
263 ErrorCategory::CircuitOpen => "Tool temporarily disabled",
264 ErrorCategory::Authentication => "Authentication failed",
265 ErrorCategory::InvalidParameters => "Invalid parameters",
266 ErrorCategory::ToolNotFound => "Tool not found",
267 ErrorCategory::ResourceNotFound => "Resource not found",
268 ErrorCategory::PermissionDenied => "Permission denied",
269 ErrorCategory::PolicyViolation => "Blocked by policy",
270 ErrorCategory::PlanningPolicyViolation => "Not allowed in planning workflow",
271 ErrorCategory::SandboxFailure => "Sandbox denied",
272 ErrorCategory::ResourceExhausted => "Resource limit reached",
273 ErrorCategory::Cancelled => "Operation cancelled",
274 ErrorCategory::ExecutionError => "Execution failed",
275 }
276 }
277}
278
279impl fmt::Display for ErrorCategory {
280 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
281 f.write_str(self.user_label())
282 }
283}
284
285#[must_use]
295pub fn classify_anyhow_error(err: &anyhow::Error) -> ErrorCategory {
296 let msg = err.to_string().to_ascii_lowercase();
297 classify_error_message(&msg)
298}
299
300#[inline]
305#[must_use]
306pub fn classify_error_message(msg: &str) -> ErrorCategory {
307 let msg = if msg.as_bytes().iter().any(|b| b.is_ascii_uppercase()) {
308 Cow::Owned(msg.to_ascii_lowercase())
309 } else {
310 Cow::Borrowed(msg)
311 };
312
313 if contains_any(
315 &msg,
316 &[
317 "policy violation",
318 "denied by policy",
319 "tool permission denied",
320 "safety validation failed",
321 "not allowed in planning workflow",
322 "only available when planning workflow is active",
323 "workspace boundary",
324 "blocked by policy",
325 ],
326 ) {
327 return ErrorCategory::PolicyViolation;
328 }
329
330 if contains_any(
332 &msg,
333 &[
334 "planning workflow",
335 "read-only permissions",
336 concat!("read-only ", "mode"),
337 "planning_policy_violation",
338 ],
339 ) {
340 return ErrorCategory::PlanningPolicyViolation;
341 }
342
343 if contains_any(
345 &msg,
346 &[
347 "invalid api key",
348 "authentication failed",
349 "unauthorized",
350 "401",
351 "invalid credentials",
352 ],
353 ) {
354 return ErrorCategory::Authentication;
355 }
356
357 if contains_any(
359 &msg,
360 &[
361 "weekly usage limit",
362 "daily usage limit",
363 "monthly spending limit",
364 "insufficient credits",
365 "quota exceeded",
366 "billing",
367 "payment required",
368 ],
369 ) {
370 return ErrorCategory::ResourceExhausted;
371 }
372
373 if contains_any(
375 &msg,
376 &[
377 "invalid argument",
378 "invalid parameters",
379 "invalid type",
380 "malformed",
381 "failed to parse arguments",
382 "failed to parse argument",
383 "missing required",
384 "at least one item is required",
385 "is required for",
386 "schema validation",
387 "argument validation failed",
388 "unknown field",
389 "unknown variant",
390 "expected struct",
391 "expected enum",
392 "type mismatch",
393 "must be an absolute path",
394 "not parseable",
395 "parseable as",
396 ],
397 ) {
398 return ErrorCategory::InvalidParameters;
399 }
400
401 if contains_any(
403 &msg,
404 &[
405 "tool not found",
406 "unknown tool",
407 "unsupported tool",
408 "no such tool",
409 ],
410 ) {
411 return ErrorCategory::ToolNotFound;
412 }
413
414 if contains_any(
416 &msg,
417 &[
418 "no such file",
419 "no such directory",
420 "file not found",
421 "directory not found",
422 "resource not found",
423 "path not found",
424 "does not exist",
425 "enoent",
426 ],
427 ) {
428 return ErrorCategory::ResourceNotFound;
429 }
430
431 if contains_any(
433 &msg,
434 &[
435 "permission denied",
436 "access denied",
437 "operation not permitted",
438 "eacces",
439 "eperm",
440 "forbidden",
441 "403",
442 ],
443 ) {
444 return ErrorCategory::PermissionDenied;
445 }
446
447 if contains_any(&msg, &["cancelled", "interrupted", "canceled"]) {
449 return ErrorCategory::Cancelled;
450 }
451
452 if contains_any(&msg, &["circuit breaker", "circuit open"]) {
454 return ErrorCategory::CircuitOpen;
455 }
456
457 if contains_any(&msg, &["sandbox denied", "sandbox failure"]) {
459 return ErrorCategory::SandboxFailure;
460 }
461
462 if contains_any(&msg, &["rate limit", "too many requests", "429", "throttl"]) {
464 return ErrorCategory::RateLimit;
465 }
466
467 if contains_any(&msg, &["timeout", "timed out", "deadline exceeded"]) {
469 return ErrorCategory::Timeout;
470 }
471
472 if contains_any(
474 &msg,
475 &[
476 "invalid response format: missing choices",
477 "invalid response format: missing message",
478 "missing choices in response",
479 "missing message in choice",
480 "no choices in response",
481 "invalid response from ",
482 "empty response body",
483 "response did not contain",
484 "unexpected response format",
485 "failed to parse response",
486 ],
487 ) {
488 return ErrorCategory::ServiceUnavailable;
489 }
490
491 if contains_any(
493 &msg,
494 &[
495 "service unavailable",
496 "temporarily unavailable",
497 "internal server error",
498 "bad gateway",
499 "gateway timeout",
500 "overloaded",
501 "500",
502 "502",
503 "503",
504 "504",
505 ],
506 ) {
507 return ErrorCategory::ServiceUnavailable;
508 }
509
510 if contains_any(
512 &msg,
513 &[
514 "network",
515 "connection reset",
516 "connection refused",
517 "broken pipe",
518 "dns",
519 "name resolution",
520 "try again",
521 "retry later",
522 "upstream connect error",
523 "tls handshake",
524 "socket hang up",
525 "econnreset",
526 "etimedout",
527 ],
528 ) {
529 return ErrorCategory::Network;
530 }
531
532 if contains_any(&msg, &["out of memory", "disk full", "no space left"]) {
534 return ErrorCategory::ResourceExhausted;
535 }
536
537 ErrorCategory::ExecutionError
539}
540
541#[inline]
546#[must_use]
547pub fn is_retryable_llm_error_message(msg: &str) -> bool {
548 let category = classify_error_message(msg);
549 category.is_retryable()
550}
551
552#[inline]
553fn contains_any(message: &str, markers: &[&str]) -> bool {
554 markers.iter().any(|marker| message.contains(marker))
555}
556
557impl From<&crate::llm::LLMError> for ErrorCategory {
562 fn from(err: &crate::llm::LLMError) -> Self {
563 match err {
564 crate::llm::LLMError::Authentication { .. } => ErrorCategory::Authentication,
565 crate::llm::LLMError::RateLimit { metadata } => {
566 classify_llm_metadata(metadata.as_deref(), ErrorCategory::RateLimit)
567 }
568 crate::llm::LLMError::InvalidRequest { .. } => ErrorCategory::InvalidParameters,
569 crate::llm::LLMError::Network { .. } => ErrorCategory::Network,
570 crate::llm::LLMError::Provider { message, metadata } => {
571 let metadata_category =
572 classify_llm_metadata(metadata.as_deref(), ErrorCategory::ExecutionError);
573 if metadata_category != ErrorCategory::ExecutionError {
574 return metadata_category;
575 }
576
577 if let Some(meta) = metadata
579 && let Some(status) = meta.status
580 {
581 return match status {
582 401 => ErrorCategory::Authentication,
583 403 => ErrorCategory::PermissionDenied,
584 404 => ErrorCategory::ResourceNotFound,
585 429 => ErrorCategory::RateLimit,
586 400 => ErrorCategory::InvalidParameters,
587 500 | 502 | 503 | 504 => ErrorCategory::ServiceUnavailable,
588 408 => ErrorCategory::Timeout,
589 _ => classify_error_message(message),
590 };
591 }
592 classify_error_message(message)
594 }
595 }
596 }
597}
598
599fn classify_llm_metadata(
600 metadata: Option<&crate::llm::LLMErrorMetadata>,
601 fallback: ErrorCategory,
602) -> ErrorCategory {
603 let Some(metadata) = metadata else {
604 return fallback;
605 };
606
607 let mut hint = String::new();
608 if let Some(code) = &metadata.code {
609 hint.push_str(code);
610 hint.push(' ');
611 }
612 if let Some(message) = &metadata.message {
613 hint.push_str(message);
614 hint.push(' ');
615 }
616 if let Some(status) = metadata.status {
617 let _ = write!(&mut hint, "{status}");
618 }
619
620 let classified = classify_error_message(&hint);
621 if classified == ErrorCategory::ExecutionError {
622 fallback
623 } else {
624 classified
625 }
626}
627
628#[cfg(test)]
629mod tests {
630 use super::*;
631
632 #[test]
635 fn policy_violation_takes_priority_over_permission() {
636 assert_eq!(
637 classify_error_message("tool permission denied by policy"),
638 ErrorCategory::PolicyViolation
639 );
640 }
641
642 #[test]
643 fn rate_limit_classified_correctly() {
644 assert_eq!(
645 classify_error_message("provider returned 429 Too Many Requests"),
646 ErrorCategory::RateLimit
647 );
648 assert_eq!(
649 classify_error_message("rate limit exceeded"),
650 ErrorCategory::RateLimit
651 );
652 }
653
654 #[test]
655 fn service_unavailable_is_classified() {
656 assert_eq!(
657 classify_error_message("503 service unavailable"),
658 ErrorCategory::ServiceUnavailable
659 );
660 }
661
662 #[test]
663 fn authentication_errors() {
664 assert_eq!(
665 classify_error_message("invalid api key provided"),
666 ErrorCategory::Authentication
667 );
668 assert_eq!(
669 classify_error_message("401 unauthorized"),
670 ErrorCategory::Authentication
671 );
672 }
673
674 #[test]
675 fn billing_errors_are_resource_exhausted() {
676 assert_eq!(
677 classify_error_message("you have reached your weekly usage limit"),
678 ErrorCategory::ResourceExhausted
679 );
680 assert_eq!(
681 classify_error_message("quota exceeded for this model"),
682 ErrorCategory::ResourceExhausted
683 );
684 }
685
686 #[test]
687 fn timeout_errors() {
688 assert_eq!(
689 classify_error_message("connection timeout"),
690 ErrorCategory::Timeout
691 );
692 assert_eq!(
693 classify_error_message("request timed out after 30s"),
694 ErrorCategory::Timeout
695 );
696 }
697
698 #[test]
699 fn network_errors() {
700 assert_eq!(
701 classify_error_message("connection reset by peer"),
702 ErrorCategory::Network
703 );
704 assert_eq!(
705 classify_error_message("dns name resolution failed"),
706 ErrorCategory::Network
707 );
708 }
709
710 #[test]
711 fn tool_not_found() {
712 assert_eq!(
713 classify_error_message("unknown tool: ask_questions"),
714 ErrorCategory::ToolNotFound
715 );
716 }
717
718 #[test]
719 fn resource_not_found() {
720 assert_eq!(
721 classify_error_message("no such file or directory: /tmp/missing"),
722 ErrorCategory::ResourceNotFound
723 );
724 assert_eq!(
725 classify_error_message("Path 'vtcode-core/src/agent' does not exist"),
726 ErrorCategory::ResourceNotFound
727 );
728 }
729
730 #[test]
731 fn permission_denied() {
732 assert_eq!(
733 classify_error_message("permission denied: /etc/shadow"),
734 ErrorCategory::PermissionDenied
735 );
736 }
737
738 #[test]
739 fn cancelled_operations() {
740 assert_eq!(
741 classify_error_message("operation cancelled by user"),
742 ErrorCategory::Cancelled
743 );
744 }
745
746 #[test]
747 fn planning_policy_violation() {
748 assert_eq!(
749 classify_error_message("not allowed in planning workflow"),
750 ErrorCategory::PolicyViolation
751 );
752 }
753
754 #[test]
755 fn sandbox_failure() {
756 assert_eq!(
757 classify_error_message("sandbox denied this operation"),
758 ErrorCategory::SandboxFailure
759 );
760 }
761
762 #[test]
763 fn unknown_error_is_execution_error() {
764 assert_eq!(
765 classify_error_message("something went wrong"),
766 ErrorCategory::ExecutionError
767 );
768 }
769
770 #[test]
771 fn invalid_parameters() {
772 assert_eq!(
773 classify_error_message("invalid argument: missing path field"),
774 ErrorCategory::InvalidParameters
775 );
776 assert_eq!(
777 classify_error_message(
778 "Failed to parse arguments for read_file handler: invalid type: boolean `false`"
779 ),
780 ErrorCategory::InvalidParameters
781 );
782 assert_eq!(
783 classify_error_message("at least one item is required for 'create'"),
784 ErrorCategory::InvalidParameters
785 );
786 assert_eq!(
787 classify_error_message(
788 "structural pattern preflight failed: pattern is not parseable as Rust syntax"
789 ),
790 ErrorCategory::InvalidParameters
791 );
792 }
793
794 #[test]
797 fn retryable_categories() {
798 assert!(ErrorCategory::Network.is_retryable());
799 assert!(ErrorCategory::Timeout.is_retryable());
800 assert!(ErrorCategory::RateLimit.is_retryable());
801 assert!(ErrorCategory::ServiceUnavailable.is_retryable());
802 assert!(ErrorCategory::CircuitOpen.is_retryable());
803 }
804
805 #[test]
806 fn non_retryable_categories() {
807 assert!(!ErrorCategory::Authentication.is_retryable());
808 assert!(!ErrorCategory::InvalidParameters.is_retryable());
809 assert!(!ErrorCategory::PolicyViolation.is_retryable());
810 assert!(!ErrorCategory::ResourceExhausted.is_retryable());
811 assert!(!ErrorCategory::Cancelled.is_retryable());
812 }
813
814 #[test]
815 fn permanent_error_detection() {
816 assert!(ErrorCategory::Authentication.is_permanent());
817 assert!(ErrorCategory::PolicyViolation.is_permanent());
818 assert!(!ErrorCategory::Network.is_permanent());
819 assert!(!ErrorCategory::Timeout.is_permanent());
820 }
821
822 #[test]
823 fn llm_mistake_detection() {
824 assert!(ErrorCategory::InvalidParameters.is_llm_mistake());
825 assert!(!ErrorCategory::Network.is_llm_mistake());
826 assert!(!ErrorCategory::Timeout.is_llm_mistake());
827 }
828
829 #[test]
832 fn llm_error_authentication_converts() {
833 let err = crate::llm::LLMError::Authentication {
834 message: "bad key".to_string(),
835 metadata: None,
836 };
837 assert_eq!(ErrorCategory::from(&err), ErrorCategory::Authentication);
838 }
839
840 #[test]
841 fn llm_error_rate_limit_converts() {
842 let err = crate::llm::LLMError::RateLimit { metadata: None };
843 assert_eq!(ErrorCategory::from(&err), ErrorCategory::RateLimit);
844 }
845
846 #[test]
847 fn llm_error_quota_exhaustion_converts() {
848 let err = crate::llm::LLMError::RateLimit {
849 metadata: Some(crate::llm::LLMErrorMetadata::new(
850 "openai",
851 Some(429),
852 Some("insufficient_quota".to_string()),
853 None,
854 None,
855 None,
856 Some("quota exceeded".to_string()),
857 )),
858 };
859
860 assert_eq!(ErrorCategory::from(&err), ErrorCategory::ResourceExhausted);
861 }
862
863 #[test]
864 fn llm_error_network_converts() {
865 let err = crate::llm::LLMError::Network {
866 message: "connection refused".to_string(),
867 metadata: None,
868 };
869 assert_eq!(ErrorCategory::from(&err), ErrorCategory::Network);
870 }
871
872 #[test]
873 fn llm_error_provider_with_status_code() {
874 use crate::llm::LLMErrorMetadata;
875 let err = crate::llm::LLMError::Provider {
876 message: "error".to_string(),
877 metadata: Some(LLMErrorMetadata::new(
878 "openai",
879 Some(503),
880 None,
881 None,
882 None,
883 None,
884 None,
885 )),
886 };
887 assert_eq!(ErrorCategory::from(&err), ErrorCategory::ServiceUnavailable);
888 }
889
890 #[test]
891 fn minimax_invalid_response_is_service_unavailable() {
892 assert_eq!(
893 classify_error_message("Invalid response from MiniMax: missing choices"),
894 ErrorCategory::ServiceUnavailable
895 );
896 assert_eq!(
897 classify_error_message("Invalid response format: missing message"),
898 ErrorCategory::ServiceUnavailable
899 );
900 }
901
902 #[test]
905 fn retryable_llm_messages() {
906 assert!(is_retryable_llm_error_message("429 too many requests"));
907 assert!(is_retryable_llm_error_message("500 internal server error"));
908 assert!(is_retryable_llm_error_message("connection timeout"));
909 assert!(is_retryable_llm_error_message("network error"));
910 }
911
912 #[test]
913 fn non_retryable_llm_messages() {
914 assert!(!is_retryable_llm_error_message("invalid api key"));
915 assert!(!is_retryable_llm_error_message(
916 "weekly usage limit reached"
917 ));
918 assert!(!is_retryable_llm_error_message("permission denied"));
919 }
920
921 #[test]
924 fn recovery_suggestions_non_empty() {
925 for cat in [
926 ErrorCategory::Network,
927 ErrorCategory::Timeout,
928 ErrorCategory::RateLimit,
929 ErrorCategory::Authentication,
930 ErrorCategory::InvalidParameters,
931 ErrorCategory::ToolNotFound,
932 ErrorCategory::ResourceNotFound,
933 ErrorCategory::PermissionDenied,
934 ErrorCategory::PolicyViolation,
935 ErrorCategory::ExecutionError,
936 ] {
937 assert!(
938 !cat.recovery_suggestions().is_empty(),
939 "Missing recovery suggestions for {cat:?}"
940 );
941 }
942 }
943
944 #[test]
947 fn user_labels_are_non_empty() {
948 assert!(!ErrorCategory::Network.user_label().is_empty());
949 assert!(!ErrorCategory::ExecutionError.user_label().is_empty());
950 }
951
952 #[test]
955 fn display_matches_user_label() {
956 assert_eq!(
957 format!("{}", ErrorCategory::RateLimit),
958 ErrorCategory::RateLimit.user_label()
959 );
960 }
961}