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 PlanModeViolation,
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::PlanModeViolation
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::PlanModeViolation => vec![
234 Cow::Borrowed("This operation is not allowed in plan/read-only mode"),
235 Cow::Borrowed("Exit plan mode to perform mutating operations"),
236 ],
237 ErrorCategory::SandboxFailure => vec![
238 Cow::Borrowed("The sandbox denied this operation"),
239 Cow::Borrowed("Check sandbox configuration and permissions"),
240 ],
241 ErrorCategory::ResourceExhausted => vec![
242 Cow::Borrowed("Check your account usage limits and billing status"),
243 Cow::Borrowed("Review resource consumption and optimize if possible"),
244 ],
245 ErrorCategory::Cancelled => vec![Cow::Borrowed("The operation was cancelled")],
246 ErrorCategory::ExecutionError => vec![
247 Cow::Borrowed("Review error details for specific issues"),
248 Cow::Borrowed("Check tool documentation for known limitations"),
249 ],
250 }
251 }
252
253 #[must_use]
255 pub const fn user_label(&self) -> &'static str {
256 match self {
257 ErrorCategory::Network => "Network error",
258 ErrorCategory::Timeout => "Request timed out",
259 ErrorCategory::RateLimit => "Rate limit exceeded",
260 ErrorCategory::ServiceUnavailable => "Service temporarily unavailable",
261 ErrorCategory::CircuitOpen => "Tool temporarily disabled",
262 ErrorCategory::Authentication => "Authentication failed",
263 ErrorCategory::InvalidParameters => "Invalid parameters",
264 ErrorCategory::ToolNotFound => "Tool not found",
265 ErrorCategory::ResourceNotFound => "Resource not found",
266 ErrorCategory::PermissionDenied => "Permission denied",
267 ErrorCategory::PolicyViolation => "Blocked by policy",
268 ErrorCategory::PlanModeViolation => "Not allowed in plan mode",
269 ErrorCategory::SandboxFailure => "Sandbox denied",
270 ErrorCategory::ResourceExhausted => "Resource limit reached",
271 ErrorCategory::Cancelled => "Operation cancelled",
272 ErrorCategory::ExecutionError => "Execution failed",
273 }
274 }
275}
276
277impl fmt::Display for ErrorCategory {
278 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
279 f.write_str(self.user_label())
280 }
281}
282
283#[must_use]
293pub fn classify_anyhow_error(err: &anyhow::Error) -> ErrorCategory {
294 let msg = err.to_string().to_ascii_lowercase();
295 classify_error_message(&msg)
296}
297
298#[inline]
303#[must_use]
304pub fn classify_error_message(msg: &str) -> ErrorCategory {
305 let msg = if msg.as_bytes().iter().any(|b| b.is_ascii_uppercase()) {
306 Cow::Owned(msg.to_ascii_lowercase())
307 } else {
308 Cow::Borrowed(msg)
309 };
310
311 if contains_any(
313 &msg,
314 &[
315 "policy violation",
316 "denied by policy",
317 "tool permission denied",
318 "safety validation failed",
319 "not allowed in plan mode",
320 "only available when plan mode is active",
321 "workspace boundary",
322 "blocked by policy",
323 ],
324 ) {
325 return ErrorCategory::PolicyViolation;
326 }
327
328 if contains_any(
330 &msg,
331 &["plan mode", "read-only mode", "plan_mode_violation"],
332 ) {
333 return ErrorCategory::PlanModeViolation;
334 }
335
336 if contains_any(
338 &msg,
339 &[
340 "invalid api key",
341 "authentication failed",
342 "unauthorized",
343 "401",
344 "invalid credentials",
345 ],
346 ) {
347 return ErrorCategory::Authentication;
348 }
349
350 if contains_any(
352 &msg,
353 &[
354 "weekly usage limit",
355 "daily usage limit",
356 "monthly spending limit",
357 "insufficient credits",
358 "quota exceeded",
359 "billing",
360 "payment required",
361 ],
362 ) {
363 return ErrorCategory::ResourceExhausted;
364 }
365
366 if contains_any(
368 &msg,
369 &[
370 "invalid argument",
371 "invalid parameters",
372 "invalid type",
373 "malformed",
374 "failed to parse arguments",
375 "failed to parse argument",
376 "missing required",
377 "at least one item is required",
378 "is required for",
379 "schema validation",
380 "argument validation failed",
381 "unknown field",
382 "unknown variant",
383 "expected struct",
384 "expected enum",
385 "type mismatch",
386 "must be an absolute path",
387 "not parseable",
388 "parseable as",
389 ],
390 ) {
391 return ErrorCategory::InvalidParameters;
392 }
393
394 if contains_any(
396 &msg,
397 &[
398 "tool not found",
399 "unknown tool",
400 "unsupported tool",
401 "no such tool",
402 ],
403 ) {
404 return ErrorCategory::ToolNotFound;
405 }
406
407 if contains_any(
409 &msg,
410 &[
411 "no such file",
412 "no such directory",
413 "file not found",
414 "directory not found",
415 "resource not found",
416 "path not found",
417 "does not exist",
418 "enoent",
419 ],
420 ) {
421 return ErrorCategory::ResourceNotFound;
422 }
423
424 if contains_any(
426 &msg,
427 &[
428 "permission denied",
429 "access denied",
430 "operation not permitted",
431 "eacces",
432 "eperm",
433 "forbidden",
434 "403",
435 ],
436 ) {
437 return ErrorCategory::PermissionDenied;
438 }
439
440 if contains_any(&msg, &["cancelled", "interrupted", "canceled"]) {
442 return ErrorCategory::Cancelled;
443 }
444
445 if contains_any(&msg, &["circuit breaker", "circuit open"]) {
447 return ErrorCategory::CircuitOpen;
448 }
449
450 if contains_any(&msg, &["sandbox denied", "sandbox failure"]) {
452 return ErrorCategory::SandboxFailure;
453 }
454
455 if contains_any(&msg, &["rate limit", "too many requests", "429", "throttl"]) {
457 return ErrorCategory::RateLimit;
458 }
459
460 if contains_any(&msg, &["timeout", "timed out", "deadline exceeded"]) {
462 return ErrorCategory::Timeout;
463 }
464
465 if contains_any(
467 &msg,
468 &[
469 "invalid response format: missing choices",
470 "invalid response format: missing message",
471 "missing choices in response",
472 "missing message in choice",
473 "no choices in response",
474 "invalid response from ",
475 "empty response body",
476 "response did not contain",
477 "unexpected response format",
478 "failed to parse response",
479 ],
480 ) {
481 return ErrorCategory::ServiceUnavailable;
482 }
483
484 if contains_any(
486 &msg,
487 &[
488 "service unavailable",
489 "temporarily unavailable",
490 "internal server error",
491 "bad gateway",
492 "gateway timeout",
493 "overloaded",
494 "500",
495 "502",
496 "503",
497 "504",
498 ],
499 ) {
500 return ErrorCategory::ServiceUnavailable;
501 }
502
503 if contains_any(
505 &msg,
506 &[
507 "network",
508 "connection reset",
509 "connection refused",
510 "broken pipe",
511 "dns",
512 "name resolution",
513 "try again",
514 "retry later",
515 "upstream connect error",
516 "tls handshake",
517 "socket hang up",
518 "econnreset",
519 "etimedout",
520 ],
521 ) {
522 return ErrorCategory::Network;
523 }
524
525 if contains_any(&msg, &["out of memory", "disk full", "no space left"]) {
527 return ErrorCategory::ResourceExhausted;
528 }
529
530 ErrorCategory::ExecutionError
532}
533
534#[inline]
539#[must_use]
540pub fn is_retryable_llm_error_message(msg: &str) -> bool {
541 let category = classify_error_message(msg);
542 category.is_retryable()
543}
544
545#[inline]
546fn contains_any(message: &str, markers: &[&str]) -> bool {
547 markers.iter().any(|marker| message.contains(marker))
548}
549
550impl From<&crate::llm::LLMError> for ErrorCategory {
555 fn from(err: &crate::llm::LLMError) -> Self {
556 match err {
557 crate::llm::LLMError::Authentication { .. } => ErrorCategory::Authentication,
558 crate::llm::LLMError::RateLimit { metadata } => {
559 classify_llm_metadata(metadata.as_deref(), ErrorCategory::RateLimit)
560 }
561 crate::llm::LLMError::InvalidRequest { .. } => ErrorCategory::InvalidParameters,
562 crate::llm::LLMError::Network { .. } => ErrorCategory::Network,
563 crate::llm::LLMError::Provider { message, metadata } => {
564 let metadata_category =
565 classify_llm_metadata(metadata.as_deref(), ErrorCategory::ExecutionError);
566 if metadata_category != ErrorCategory::ExecutionError {
567 return metadata_category;
568 }
569
570 if let Some(meta) = metadata
572 && let Some(status) = meta.status
573 {
574 return match status {
575 401 => ErrorCategory::Authentication,
576 403 => ErrorCategory::PermissionDenied,
577 404 => ErrorCategory::ResourceNotFound,
578 429 => ErrorCategory::RateLimit,
579 400 => ErrorCategory::InvalidParameters,
580 500 | 502 | 503 | 504 => ErrorCategory::ServiceUnavailable,
581 408 => ErrorCategory::Timeout,
582 _ => classify_error_message(message),
583 };
584 }
585 classify_error_message(message)
587 }
588 }
589 }
590}
591
592fn classify_llm_metadata(
593 metadata: Option<&crate::llm::LLMErrorMetadata>,
594 fallback: ErrorCategory,
595) -> ErrorCategory {
596 let Some(metadata) = metadata else {
597 return fallback;
598 };
599
600 let mut hint = String::new();
601 if let Some(code) = &metadata.code {
602 hint.push_str(code);
603 hint.push(' ');
604 }
605 if let Some(message) = &metadata.message {
606 hint.push_str(message);
607 hint.push(' ');
608 }
609 if let Some(status) = metadata.status {
610 let _ = write!(&mut hint, "{status}");
611 }
612
613 let classified = classify_error_message(&hint);
614 if classified == ErrorCategory::ExecutionError {
615 fallback
616 } else {
617 classified
618 }
619}
620
621#[cfg(test)]
622mod tests {
623 use super::*;
624
625 #[test]
628 fn policy_violation_takes_priority_over_permission() {
629 assert_eq!(
630 classify_error_message("tool permission denied by policy"),
631 ErrorCategory::PolicyViolation
632 );
633 }
634
635 #[test]
636 fn rate_limit_classified_correctly() {
637 assert_eq!(
638 classify_error_message("provider returned 429 Too Many Requests"),
639 ErrorCategory::RateLimit
640 );
641 assert_eq!(
642 classify_error_message("rate limit exceeded"),
643 ErrorCategory::RateLimit
644 );
645 }
646
647 #[test]
648 fn service_unavailable_is_classified() {
649 assert_eq!(
650 classify_error_message("503 service unavailable"),
651 ErrorCategory::ServiceUnavailable
652 );
653 }
654
655 #[test]
656 fn authentication_errors() {
657 assert_eq!(
658 classify_error_message("invalid api key provided"),
659 ErrorCategory::Authentication
660 );
661 assert_eq!(
662 classify_error_message("401 unauthorized"),
663 ErrorCategory::Authentication
664 );
665 }
666
667 #[test]
668 fn billing_errors_are_resource_exhausted() {
669 assert_eq!(
670 classify_error_message("you have reached your weekly usage limit"),
671 ErrorCategory::ResourceExhausted
672 );
673 assert_eq!(
674 classify_error_message("quota exceeded for this model"),
675 ErrorCategory::ResourceExhausted
676 );
677 }
678
679 #[test]
680 fn timeout_errors() {
681 assert_eq!(
682 classify_error_message("connection timeout"),
683 ErrorCategory::Timeout
684 );
685 assert_eq!(
686 classify_error_message("request timed out after 30s"),
687 ErrorCategory::Timeout
688 );
689 }
690
691 #[test]
692 fn network_errors() {
693 assert_eq!(
694 classify_error_message("connection reset by peer"),
695 ErrorCategory::Network
696 );
697 assert_eq!(
698 classify_error_message("dns name resolution failed"),
699 ErrorCategory::Network
700 );
701 }
702
703 #[test]
704 fn tool_not_found() {
705 assert_eq!(
706 classify_error_message("unknown tool: ask_questions"),
707 ErrorCategory::ToolNotFound
708 );
709 }
710
711 #[test]
712 fn resource_not_found() {
713 assert_eq!(
714 classify_error_message("no such file or directory: /tmp/missing"),
715 ErrorCategory::ResourceNotFound
716 );
717 assert_eq!(
718 classify_error_message("Path 'vtcode-core/src/agent' does not exist"),
719 ErrorCategory::ResourceNotFound
720 );
721 }
722
723 #[test]
724 fn permission_denied() {
725 assert_eq!(
726 classify_error_message("permission denied: /etc/shadow"),
727 ErrorCategory::PermissionDenied
728 );
729 }
730
731 #[test]
732 fn cancelled_operations() {
733 assert_eq!(
734 classify_error_message("operation cancelled by user"),
735 ErrorCategory::Cancelled
736 );
737 }
738
739 #[test]
740 fn plan_mode_violation() {
741 assert_eq!(
742 classify_error_message("not allowed in plan mode"),
743 ErrorCategory::PolicyViolation
744 );
745 }
746
747 #[test]
748 fn sandbox_failure() {
749 assert_eq!(
750 classify_error_message("sandbox denied this operation"),
751 ErrorCategory::SandboxFailure
752 );
753 }
754
755 #[test]
756 fn unknown_error_is_execution_error() {
757 assert_eq!(
758 classify_error_message("something went wrong"),
759 ErrorCategory::ExecutionError
760 );
761 }
762
763 #[test]
764 fn invalid_parameters() {
765 assert_eq!(
766 classify_error_message("invalid argument: missing path field"),
767 ErrorCategory::InvalidParameters
768 );
769 assert_eq!(
770 classify_error_message(
771 "Failed to parse arguments for read_file handler: invalid type: boolean `false`"
772 ),
773 ErrorCategory::InvalidParameters
774 );
775 assert_eq!(
776 classify_error_message("at least one item is required for 'create'"),
777 ErrorCategory::InvalidParameters
778 );
779 assert_eq!(
780 classify_error_message(
781 "structural pattern preflight failed: pattern is not parseable as Rust syntax"
782 ),
783 ErrorCategory::InvalidParameters
784 );
785 }
786
787 #[test]
790 fn retryable_categories() {
791 assert!(ErrorCategory::Network.is_retryable());
792 assert!(ErrorCategory::Timeout.is_retryable());
793 assert!(ErrorCategory::RateLimit.is_retryable());
794 assert!(ErrorCategory::ServiceUnavailable.is_retryable());
795 assert!(ErrorCategory::CircuitOpen.is_retryable());
796 }
797
798 #[test]
799 fn non_retryable_categories() {
800 assert!(!ErrorCategory::Authentication.is_retryable());
801 assert!(!ErrorCategory::InvalidParameters.is_retryable());
802 assert!(!ErrorCategory::PolicyViolation.is_retryable());
803 assert!(!ErrorCategory::ResourceExhausted.is_retryable());
804 assert!(!ErrorCategory::Cancelled.is_retryable());
805 }
806
807 #[test]
808 fn permanent_error_detection() {
809 assert!(ErrorCategory::Authentication.is_permanent());
810 assert!(ErrorCategory::PolicyViolation.is_permanent());
811 assert!(!ErrorCategory::Network.is_permanent());
812 assert!(!ErrorCategory::Timeout.is_permanent());
813 }
814
815 #[test]
816 fn llm_mistake_detection() {
817 assert!(ErrorCategory::InvalidParameters.is_llm_mistake());
818 assert!(!ErrorCategory::Network.is_llm_mistake());
819 assert!(!ErrorCategory::Timeout.is_llm_mistake());
820 }
821
822 #[test]
825 fn llm_error_authentication_converts() {
826 let err = crate::llm::LLMError::Authentication {
827 message: "bad key".to_string(),
828 metadata: None,
829 };
830 assert_eq!(ErrorCategory::from(&err), ErrorCategory::Authentication);
831 }
832
833 #[test]
834 fn llm_error_rate_limit_converts() {
835 let err = crate::llm::LLMError::RateLimit { metadata: None };
836 assert_eq!(ErrorCategory::from(&err), ErrorCategory::RateLimit);
837 }
838
839 #[test]
840 fn llm_error_quota_exhaustion_converts() {
841 let err = crate::llm::LLMError::RateLimit {
842 metadata: Some(crate::llm::LLMErrorMetadata::new(
843 "openai",
844 Some(429),
845 Some("insufficient_quota".to_string()),
846 None,
847 None,
848 None,
849 Some("quota exceeded".to_string()),
850 )),
851 };
852
853 assert_eq!(ErrorCategory::from(&err), ErrorCategory::ResourceExhausted);
854 }
855
856 #[test]
857 fn llm_error_network_converts() {
858 let err = crate::llm::LLMError::Network {
859 message: "connection refused".to_string(),
860 metadata: None,
861 };
862 assert_eq!(ErrorCategory::from(&err), ErrorCategory::Network);
863 }
864
865 #[test]
866 fn llm_error_provider_with_status_code() {
867 use crate::llm::LLMErrorMetadata;
868 let err = crate::llm::LLMError::Provider {
869 message: "error".to_string(),
870 metadata: Some(LLMErrorMetadata::new(
871 "openai",
872 Some(503),
873 None,
874 None,
875 None,
876 None,
877 None,
878 )),
879 };
880 assert_eq!(ErrorCategory::from(&err), ErrorCategory::ServiceUnavailable);
881 }
882
883 #[test]
884 fn minimax_invalid_response_is_service_unavailable() {
885 assert_eq!(
886 classify_error_message("Invalid response from MiniMax: missing choices"),
887 ErrorCategory::ServiceUnavailable
888 );
889 assert_eq!(
890 classify_error_message("Invalid response format: missing message"),
891 ErrorCategory::ServiceUnavailable
892 );
893 }
894
895 #[test]
898 fn retryable_llm_messages() {
899 assert!(is_retryable_llm_error_message("429 too many requests"));
900 assert!(is_retryable_llm_error_message("500 internal server error"));
901 assert!(is_retryable_llm_error_message("connection timeout"));
902 assert!(is_retryable_llm_error_message("network error"));
903 }
904
905 #[test]
906 fn non_retryable_llm_messages() {
907 assert!(!is_retryable_llm_error_message("invalid api key"));
908 assert!(!is_retryable_llm_error_message(
909 "weekly usage limit reached"
910 ));
911 assert!(!is_retryable_llm_error_message("permission denied"));
912 }
913
914 #[test]
917 fn recovery_suggestions_non_empty() {
918 for cat in [
919 ErrorCategory::Network,
920 ErrorCategory::Timeout,
921 ErrorCategory::RateLimit,
922 ErrorCategory::Authentication,
923 ErrorCategory::InvalidParameters,
924 ErrorCategory::ToolNotFound,
925 ErrorCategory::ResourceNotFound,
926 ErrorCategory::PermissionDenied,
927 ErrorCategory::PolicyViolation,
928 ErrorCategory::ExecutionError,
929 ] {
930 assert!(
931 !cat.recovery_suggestions().is_empty(),
932 "Missing recovery suggestions for {:?}",
933 cat
934 );
935 }
936 }
937
938 #[test]
941 fn user_labels_are_non_empty() {
942 assert!(!ErrorCategory::Network.user_label().is_empty());
943 assert!(!ErrorCategory::ExecutionError.user_label().is_empty());
944 }
945
946 #[test]
949 fn display_matches_user_label() {
950 assert_eq!(
951 format!("{}", ErrorCategory::RateLimit),
952 ErrorCategory::RateLimit.user_label()
953 );
954 }
955}