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