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