返回 CodeWhale
error_taxonomy.rs
根目录 / crates / tui / src / error_taxonomy.rs
1 //! Shared error taxonomy across client, tools, runtime, and UI.
2 use std::fmt;
3
4 use crate::llm_client::LlmError;
5 use crate::tools::spec::ToolError;
6
7 /// Broad category for typed error handling and policy decisions.
8 #[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
9 #[serde(rename_all = "snake_case")]
10 pub enum ErrorCategory {
11 Network,
12 Authentication,
13 Authorization,
14 RateLimit,
15 Timeout,
16 Budget,
17 InvalidInput,
18 Parse,
19 Tool,
20 State,
21 Internal,
22 }
23
24 /// Severity hint for UI and logs.
25 #[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
26 #[serde(rename_all = "snake_case")]
27 pub enum ErrorSeverity {
28 Info,
29 Warning,
30 Error,
31 Critical,
32 }
33
34 /// Unified envelope used when crossing subsystem boundaries.
35 #[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
36 pub struct ErrorEnvelope {
37 pub category: ErrorCategory,
38 pub severity: ErrorSeverity,
39 pub recoverable: bool,
40 pub code: String,
41 pub message: String,
42 }
43
44 impl fmt::Display for ErrorCategory {
45 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
46 let label = match self {
47 Self::Network => "network",
48 Self::Authentication => "authentication",
49 Self::Authorization => "authorization",
50 Self::RateLimit => "rate_limit",
51 Self::Timeout => "timeout",
52 Self::Budget => "budget",
53 Self::InvalidInput => "invalid_input",
54 Self::Parse => "parse",
55 Self::Tool => "tool",
56 Self::State => "state",
57 Self::Internal => "internal",
58 };
59 f.write_str(label)
60 }
61 }
62
63 impl fmt::Display for ErrorSeverity {
64 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
65 let label = match self {
66 Self::Info => "info",
67 Self::Warning => "warning",
68 Self::Error => "error",
69 Self::Critical => "critical",
70 };
71 f.write_str(label)
72 }
73 }
74
75 impl fmt::Display for ErrorEnvelope {
76 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
77 write!(f, "[{}] {}: {}", self.severity, self.code, self.message)
78 }
79 }
80
81 impl std::error::Error for ErrorEnvelope {}
82
83 impl ErrorEnvelope {
84 #[must_use]
85 pub fn new(
86 category: ErrorCategory,
87 severity: ErrorSeverity,
88 recoverable: bool,
89 code: impl Into<String>,
90 message: impl Into<String>,
91 ) -> Self {
92 Self {
93 category,
94 severity,
95 recoverable,
96 code: code.into(),
97 message: message.into(),
98 }
99 }
100
101 /// Recoverable internal error — stream stalls, transient retries, generic
102 /// engine errors that the user can resolve by retrying. Severity is
103 /// `Warning` so the UI surfaces it in amber rather than red.
104 #[must_use]
105 pub fn transient(message: impl Into<String>) -> Self {
106 Self::new(
107 ErrorCategory::Internal,
108 ErrorSeverity::Warning,
109 true,
110 "transient",
111 message,
112 )
113 }
114
115 /// Non-recoverable internal error — missing client, spawn failure, etc.
116 /// Flips the session into offline mode.
117 #[must_use]
118 pub fn fatal(message: impl Into<String>) -> Self {
119 Self::new(
120 ErrorCategory::Internal,
121 ErrorSeverity::Error,
122 false,
123 "fatal",
124 message,
125 )
126 }
127
128 /// Authentication failure — fatal and blocks the session.
129 #[must_use]
130 pub fn fatal_auth(message: impl Into<String>) -> Self {
131 Self::new(
132 ErrorCategory::Authentication,
133 ErrorSeverity::Critical,
134 false,
135 "auth_fatal",
136 message,
137 )
138 }
139
140 /// Context length / overflow — invalid input, recoverable via /compact.
141 #[must_use]
142 pub fn context_overflow(message: impl Into<String>) -> Self {
143 Self::new(
144 ErrorCategory::InvalidInput,
145 ErrorSeverity::Error,
146 true,
147 "context_overflow",
148 message,
149 )
150 }
151
152 /// Recoverable network / transport hiccup.
153 #[must_use]
154 pub fn network(message: impl Into<String>) -> Self {
155 Self::new(
156 ErrorCategory::Network,
157 ErrorSeverity::Warning,
158 true,
159 "network_transient",
160 message,
161 )
162 }
163
164 /// Tool execution failure.
165 #[must_use]
166 pub fn tool(message: impl Into<String>) -> Self {
167 Self::new(
168 ErrorCategory::Tool,
169 ErrorSeverity::Error,
170 true,
171 "tool_failed",
172 message,
173 )
174 }
175
176 /// Build an envelope by classifying a raw error message string. Used at
177 /// boundaries where the underlying error type was already stringified.
178 #[must_use]
179 pub fn classify(message: impl Into<String>, recoverable: bool) -> Self {
180 let message = message.into();
181 let category = classify_error_message(&message);
182 let severity = match category {
183 ErrorCategory::Authentication => ErrorSeverity::Critical,
184 ErrorCategory::RateLimit
185 | ErrorCategory::Timeout
186 | ErrorCategory::Network
187 | ErrorCategory::Budget => ErrorSeverity::Warning,
188 ErrorCategory::InvalidInput | ErrorCategory::Authorization | ErrorCategory::Parse => {
189 ErrorSeverity::Error
190 }
191 ErrorCategory::Tool | ErrorCategory::State | ErrorCategory::Internal => {
192 if recoverable {
193 ErrorSeverity::Warning
194 } else {
195 ErrorSeverity::Error
196 }
197 }
198 };
199 Self::new(
200 category,
201 severity,
202 recoverable,
203 category.to_string(),
204 message,
205 )
206 }
207 }
208
209 /// Classify a boundary error from the typed [`LlmError`] when one is
210 /// available, keeping the caller's display message.
211 ///
212 /// Boundaries that only have an `anyhow::Error` historically stringified it
213 /// and classified the *string* with `recoverable = true`. That downgraded
214 /// terminal provider rejections such as `Model error: Model not exist.`
215 /// (typed `LlmError::ModelError` → InvalidInput / `Error` severity /
216 /// not recoverable) into `Internal` + `Warning` + recoverable noise, so the
217 /// transcript showed a dismissable "Warn" row for a failure that actually
218 /// ended the turn. When the typed error survives to the boundary, preserve
219 /// its category, severity, and recovery contract verbatim; only genuinely
220 /// untyped errors fall back to string classification.
221 #[must_use]
222 pub fn envelope_for_llm_error(error: anyhow::Error, display_message: String) -> ErrorEnvelope {
223 match error.downcast::<LlmError>() {
224 Ok(llm) => {
225 let mut envelope = ErrorEnvelope::from(llm);
226 envelope.message = display_message;
227 envelope
228 }
229 Err(error) => {
230 drop(error);
231 ErrorEnvelope::classify(display_message, true)
232 }
233 }
234 }
235
236 impl From<LlmError> for ErrorEnvelope {
237 fn from(value: LlmError) -> Self {
238 match value {
239 LlmError::RateLimited { message, .. } => Self::new(
240 ErrorCategory::RateLimit,
241 ErrorSeverity::Warning,
242 true,
243 "llm_rate_limited",
244 message,
245 ),
246 // Keep the broad wire-compatible category while making the typed
247 // code and recovery contract distinct from an ordinary 429.
248 LlmError::QuotaExhausted(error) => Self::new(
249 ErrorCategory::RateLimit,
250 ErrorSeverity::Error,
251 false,
252 "llm_quota_exhausted",
253 error.into_message(),
254 ),
255 LlmError::ServerError { status, message } => Self::new(
256 ErrorCategory::Internal,
257 ErrorSeverity::Error,
258 true,
259 format!("llm_server_{status}"),
260 message,
261 ),
262 LlmError::NetworkError(message) => Self::new(
263 ErrorCategory::Network,
264 ErrorSeverity::Error,
265 true,
266 "llm_network_error",
267 message,
268 ),
269 LlmError::Timeout(duration) => Self::new(
270 ErrorCategory::Timeout,
271 ErrorSeverity::Warning,
272 true,
273 "llm_timeout",
274 format!("Request timed out after {duration:?}"),
275 ),
276 LlmError::AuthenticationError(auth) => Self::new(
277 ErrorCategory::Authentication,
278 ErrorSeverity::Critical,
279 false,
280 "llm_auth_error",
281 auth.to_user_message(),
282 ),
283 LlmError::AuthorizationError(message) => Self::new(
284 ErrorCategory::Authorization,
285 ErrorSeverity::Error,
286 false,
287 "llm_authorization_error",
288 message,
289 ),
290 LlmError::InvalidRequest { message, .. } => Self::new(
291 ErrorCategory::InvalidInput,
292 ErrorSeverity::Error,
293 false,
294 "llm_invalid_request",
295 message,
296 ),
297 LlmError::ModelError(message) => Self::new(
298 ErrorCategory::InvalidInput,
299 ErrorSeverity::Error,
300 false,
301 "llm_model_error",
302 message,
303 ),
304 LlmError::ContentPolicyError(message) => Self::new(
305 ErrorCategory::Authorization,
306 ErrorSeverity::Error,
307 false,
308 "llm_content_policy",
309 message,
310 ),
311 LlmError::ParseError(message) => Self::new(
312 ErrorCategory::Parse,
313 ErrorSeverity::Error,
314 false,
315 "llm_parse_error",
316 message,
317 ),
318 LlmError::ContextLengthError(message) => Self::new(
319 ErrorCategory::InvalidInput,
320 ErrorSeverity::Error,
321 false,
322 "llm_context_length",
323 message,
324 ),
325 LlmError::Other(message) => Self::new(
326 ErrorCategory::Internal,
327 ErrorSeverity::Error,
328 true,
329 "llm_other",
330 message,
331 ),
332 }
333 }
334 }
335
336 /// Classify an error message string into an ErrorCategory.
337 ///
338 /// Uses heuristic keyword matching on the lowercased message.
339 /// This is a replacement for ad-hoc string matching in callers.
340 #[must_use]
341 pub fn classify_error_message(message: &str) -> ErrorCategory {
342 let lower = message.to_lowercase();
343
344 if lower.contains("maximum model steps") || lower.contains("step budget exhausted") {
345 return ErrorCategory::Budget;
346 }
347 if lower.contains("model output truncated")
348 || lower.contains("model response incomplete")
349 || lower.contains("maximum context length")
350 || lower.contains("context length")
351 || lower.contains("context_length")
352 || lower.contains("prompt is too long")
353 || (lower.contains("requested") && lower.contains("tokens") && lower.contains("maximum"))
354 || lower.contains("context window")
355 || lower.contains("model not exist")
356 || lower.contains("model not found")
357 || lower.contains("no such model")
358 || lower.contains("unknown model")
359 || lower.contains("invalid model")
360 || lower.contains("model does not exist")
361 || lower.starts_with("model error:")
362 {
363 return ErrorCategory::InvalidInput;
364 }
365 if lower.contains("rate limit")
366 || lower.contains("too many requests")
367 || lower.contains("429")
368 || lower.contains("quota")
369 || lower.contains("usage limit")
370 // Prepaid gateways answer an exhausted balance with HTTP 402; that is
371 // a quota condition the operator resolves by topping up, not an input
372 // or authentication fault (Concentrate: "Insufficient funds").
373 || lower.contains("insufficient credits")
374 || lower.contains("insufficient funds")
375 || lower.contains("payment required")
376 || lower.contains("http 402")
377 {
378 return ErrorCategory::RateLimit;
379 }
380 if lower.contains("timeout") || lower.contains("timed out") {
381 return ErrorCategory::Timeout;
382 }
383 if lower.contains("authentication")
384 || lower.contains("auth failed")
385 || lower.contains("auth error")
386 || lower.contains("unauthorized")
387 || lower.contains("api key")
388 || lower.contains("invalid key")
389 || lower.contains("invalid token")
390 || lower.contains("bearer token")
391 {
392 return ErrorCategory::Authentication;
393 }
394 if lower.contains("authorization")
395 || lower.contains("permission")
396 || lower.contains("forbidden")
397 || lower.contains("denied")
398 {
399 return ErrorCategory::Authorization;
400 }
401 if lower.contains("network")
402 || lower.contains("connection")
403 || lower.contains("dns")
404 || lower.contains("stream read error")
405 || lower.contains("error decoding response body")
406 || lower.contains("chunk decode error")
407 || lower.contains("body decode")
408 || lower.contains("temporarily unavailable")
409 || lower.contains(" 502 ")
410 || lower.contains(" 503 ")
411 || lower.contains(" 504 ")
412 || lower.starts_with("502 ")
413 || lower.starts_with("503 ")
414 || lower.starts_with("504 ")
415 || lower.ends_with(" 502")
416 || lower.ends_with(" 503")
417 || lower.ends_with(" 504")
418 || lower == "502"
419 || lower == "503"
420 || lower == "504"
421 {
422 return ErrorCategory::Network;
423 }
424 if lower.contains("parse") || lower.contains("syntax") || lower.contains("malformed") {
425 return ErrorCategory::Parse;
426 }
427 if lower.contains("not found")
428 || lower.contains("unavailable")
429 || lower.contains("not available")
430 {
431 return ErrorCategory::State;
432 }
433 if lower.contains("tool") {
434 return ErrorCategory::Tool;
435 }
436
437 ErrorCategory::Internal
438 }
439
440 impl From<ToolError> for ErrorEnvelope {
441 fn from(value: ToolError) -> Self {
442 match value {
443 ToolError::InvalidInput { message } => Self::new(
444 ErrorCategory::InvalidInput,
445 ErrorSeverity::Error,
446 false,
447 "tool_invalid_input",
448 message,
449 ),
450 ToolError::MissingField { field } => Self::new(
451 ErrorCategory::InvalidInput,
452 ErrorSeverity::Error,
453 false,
454 "tool_missing_field",
455 format!("Missing required field: {field}"),
456 ),
457 ToolError::PathEscape { path } => Self::new(
458 ErrorCategory::Authorization,
459 ErrorSeverity::Error,
460 false,
461 "tool_path_escape",
462 format!("Path escapes workspace: {}", path.display()),
463 ),
464 ToolError::ExecutionFailed { message } => Self::new(
465 ErrorCategory::Tool,
466 ErrorSeverity::Error,
467 true,
468 "tool_execution_failed",
469 message,
470 ),
471 ToolError::Timeout { seconds } => Self::new(
472 ErrorCategory::Timeout,
473 ErrorSeverity::Warning,
474 true,
475 "tool_timeout",
476 format!("Tool timed out after {seconds}s"),
477 ),
478 ToolError::Cancelled { message } => Self::new(
479 ErrorCategory::Tool,
480 ErrorSeverity::Info,
481 false,
482 "tool_cancelled",
483 message,
484 ),
485 ToolError::NotAvailable { message } => Self::new(
486 ErrorCategory::State,
487 ErrorSeverity::Error,
488 false,
489 "tool_not_available",
490 message,
491 ),
492 ToolError::PermissionDenied { message } => Self::new(
493 ErrorCategory::Authorization,
494 ErrorSeverity::Error,
495 false,
496 "tool_permission_denied",
497 message,
498 ),
499 }
500 }
501 }
502
503 /// Stream‑level error discriminated by origin.
504 ///
505 /// Each variant maps to an `ErrorCategory` so the UI can render
506 /// stream‑specific icons or formatting. Wired into engine.rs at the three
507 /// stream guard sites (chunk timeout, max-bytes overflow, max-duration).
508 #[derive(Debug, Clone)]
509 pub enum StreamError {
510 /// Stream stalled — no chunk received within the idle timeout.
511 Stall { timeout_secs: u64 },
512 /// Stream exceeded content size limit.
513 Overflow { limit_bytes: usize },
514 /// Stream exceeded wall‑clock duration limit.
515 DurationLimit { limit_secs: u64 },
516 }
517
518 impl StreamError {
519 /// Convert directly into an `ErrorEnvelope` for emission on the engine
520 /// event channel. Stalls are warning-severity and recoverable; size and
521 /// duration limits are errors (the user must restart the turn).
522 #[must_use]
523 pub fn into_envelope(self) -> ErrorEnvelope {
524 match self {
525 Self::Stall { timeout_secs } => ErrorEnvelope::new(
526 ErrorCategory::Timeout,
527 ErrorSeverity::Warning,
528 true,
529 "stream_stall",
530 format!("Stream stalled: no data received for {timeout_secs}s, closing stream"),
531 ),
532 Self::Overflow { limit_bytes } => ErrorEnvelope::new(
533 ErrorCategory::Internal,
534 ErrorSeverity::Error,
535 true,
536 "stream_overflow",
537 format!("Stream exceeded maximum content size of {limit_bytes} bytes, closing"),
538 ),
539 Self::DurationLimit { limit_secs } => ErrorEnvelope::new(
540 ErrorCategory::Timeout,
541 ErrorSeverity::Error,
542 true,
543 "stream_duration_limit",
544 format!("Stream exceeded maximum duration of {limit_secs}s, closing"),
545 ),
546 }
547 }
548 }
549
550 impl fmt::Display for StreamError {
551 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
552 match self {
553 Self::Stall { timeout_secs } => {
554 write!(f, "Stream stalled after {timeout_secs}s idle")
555 }
556 Self::Overflow { limit_bytes } => {
557 write!(f, "Stream exceeded {limit_bytes} bytes limit")
558 }
559 Self::DurationLimit { limit_secs } => {
560 write!(f, "Stream exceeded {limit_secs}s duration limit")
561 }
562 }
563 }
564 }
565
566 impl std::error::Error for StreamError {}
567
568 #[cfg(test)]
569 #[path = "error_taxonomy/tests.rs"]
570 mod quota_tests;
571
572 #[cfg(test)]
573 mod tests {
574 use super::*;
575
576 fn classify(msg: &str) -> ErrorCategory {
577 classify_error_message(msg)
578 }
579
580 #[test]
581 fn invalid_input_catches_context_overflow_phrasings() {
582 // Provider phrasing varies: DeepSeek/OpenAI/Anthropic/etc each
583 // surface context-overflow as a slightly different string.
584 // The classifier needs all of them on the same branch.
585 for msg in [
586 "This model's maximum context length is 1000000 tokens",
587 "Error: context_length_exceeded",
588 "Your prompt is too long for the current model",
589 "You requested 100000 tokens but the maximum is 65536",
590 "request exceeds context window",
591 ] {
592 assert_eq!(
593 classify(msg),
594 ErrorCategory::InvalidInput,
595 "expected InvalidInput for `{msg}`",
596 );
597 }
598 }
599
600 /// A prepaid gateway's exhausted balance (HTTP 402) is a quota condition:
601 /// the request was well-formed and the key was valid.
602 #[test]
603 fn insufficient_credits_classifies_as_rate_limit_not_auth() {
604 for msg in [
605 "Responses API error (HTTP 402 Payment Required): {\"error\":\"Insufficient funds\",\"message\":\"Your account has insufficient credits. Please add credits to continue.\"}",
606 "insufficient credits",
607 ] {
608 assert_eq!(
609 classify(msg),
610 ErrorCategory::RateLimit,
611 "expected RateLimit for `{msg}`"
612 );
613 }
614 // The documented 401 body still classifies as authentication.
615 assert_eq!(
616 classify(
617 "Responses API error (HTTP 401 Unauthorized): {\"error\":\"Unauthorized\",\"message\":\"Invalid API key\"}"
618 ),
619 ErrorCategory::Authentication
620 );
621 }
622
623 #[test]
624 fn timeout_catches_both_spellings() {
625 assert_eq!(classify("connection timeout"), ErrorCategory::Timeout);
626 assert_eq!(
627 classify("request timed out after 30s"),
628 ErrorCategory::Timeout
629 );
630 }
631
632 #[test]
633 fn network_catches_stream_body_decode_failures() {
634 for msg in [
635 "Warn Stream read error: error decoding response body",
636 "Stream read error: error decoding response body",
637 "chunk decode error",
638 "provider body decode failed mid-stream",
639 ] {
640 assert_eq!(
641 classify(msg),
642 ErrorCategory::Network,
643 "expected Network for `{msg}`",
644 );
645 }
646 }
647
648 #[test]
649 fn authentication_beats_authorization_when_api_key_phrasing_is_used() {
650 // "api key" landing on Authentication (not Authorization) keeps
651 // the operator-facing message correct: the user needs to fix
652 // their key, not their permissions.
653 for msg in [
654 "Invalid API key provided",
655 "Authentication failed",
656 "401 Unauthorized",
657 ] {
658 assert_eq!(
659 classify(msg),
660 ErrorCategory::Authentication,
661 "expected Authentication for `{msg}`",
662 );
663 }
664 }
665
666 #[test]
667 fn authorization_catches_forbidden_and_denied() {
668 for msg in [
669 "403 Forbidden",
670 "Authorization failed: Arcee AI API returned Cloudflare Access Denied",
671 "Permission denied for resource",
672 "Tool 'edit_file' denied by user",
673 ] {
674 assert_eq!(
675 classify(msg),
676 ErrorCategory::Authorization,
677 "expected Authorization for `{msg}`",
678 );
679 }
680 }
681
682 #[test]
683 fn network_catches_dns_connection_5xx() {
684 for msg in [
685 "Network is unreachable",
686 "Connection reset by peer",
687 "DNS resolution failed for api.deepseek.com",
688 "503 Service Unavailable",
689 "Upstream returned 502 Bad Gateway",
690 "Service temporarily unavailable",
691 ] {
692 assert_eq!(
693 classify(msg),
694 ErrorCategory::Network,
695 "expected Network for `{msg}`",
696 );
697 }
698 // Edge-case precedence: "504 Gateway Timeout" mentions both
699 // a 504 status code AND the word "timeout". The classifier
700 // picks Timeout, which is correct — the operator-actionable
701 // category for a 504 is "wait and retry" (Timeout semantics)
702 // rather than "DNS / connection broken" (Network semantics).
703 assert_eq!(
704 classify("504 Gateway Timeout"),
705 ErrorCategory::Timeout,
706 "504 with the literal word `timeout` resolves as Timeout, not Network"
707 );
708 }
709
710 #[test]
711 fn parse_catches_syntax_and_malformed_json() {
712 for msg in [
713 "Failed to parse response JSON",
714 "Syntax error in tool arguments",
715 "Malformed event from stream",
716 ] {
717 assert_eq!(
718 classify(msg),
719 ErrorCategory::Parse,
720 "expected Parse for `{msg}`",
721 );
722 }
723 }
724
725 #[test]
726 fn state_catches_not_found_and_unavailable() {
727 for msg in [
728 "Session not found",
729 "Model is unavailable for this provider",
730 "Endpoint not available in this region",
731 ] {
732 assert_eq!(
733 classify(msg),
734 ErrorCategory::State,
735 "expected State for `{msg}`",
736 );
737 }
738 }
739
740 #[test]
741 fn tool_is_a_low_priority_catchall_for_tool_keyword() {
742 // The Tool branch is the last keyword check before falling
743 // through to Internal. Anything mentioning "tool" that didn't
744 // match an earlier category should land here.
745 assert_eq!(
746 classify("Tool returned non-zero exit status"),
747 ErrorCategory::Tool,
748 );
749 }
750
751 #[test]
752 fn unknown_messages_fall_through_to_internal() {
753 for msg in [
754 "Something exploded",
755 "panic at the disco",
756 "u-200 something happened",
757 "",
758 ] {
759 assert_eq!(
760 classify(msg),
761 ErrorCategory::Internal,
762 "expected Internal for `{msg}`",
763 );
764 }
765 }
766
767 #[test]
768 fn classifier_is_case_insensitive() {
769 // The function lowercases internally — every category must
770 // match regardless of input casing.
771 assert_eq!(classify("RATE LIMIT EXCEEDED"), ErrorCategory::RateLimit);
772 assert_eq!(classify("TimeOut"), ErrorCategory::Timeout);
773 assert_eq!(classify("UNAUTHORIZED"), ErrorCategory::Authentication);
774 }
775
776 #[test]
777 fn precedence_invalid_input_beats_tool() {
778 // A "context length" tool error should classify as
779 // InvalidInput, not Tool — InvalidInput is the more actionable
780 // category (the user needs to shorten their prompt; "tool
781 // failure" wouldn't tell them that).
782 assert_eq!(
783 classify("tool returned: maximum context length is 1000000"),
784 ErrorCategory::InvalidInput,
785 );
786 }
787
788 #[test]
789 fn precedence_timeout_beats_network() {
790 // A timeout that mentions a network call should still classify
791 // as Timeout — the retry policy for timeouts is gentler than
792 // for outright network failures.
793 assert_eq!(
794 classify("network call timed out after 30s"),
795 ErrorCategory::Timeout,
796 );
797 }
798
799 #[test]
800 fn precedence_rate_limit_beats_authentication() {
801 // 429 messages sometimes mention "api" or "auth" tokens, but
802 // RateLimit's retry semantics (back off + retry) are what the
803 // operator actually wants.
804 assert_eq!(
805 classify("Rate limit on your API quota exceeded"),
806 ErrorCategory::RateLimit,
807 );
808 }
809
810 #[test]
811 fn classifier_handles_unicode_safely() {
812 // Unicode shouldn't trip the lowercase step or the keyword
813 // scan — Chinese/Japanese error messages from
814 // OpenAI-compatible providers go through the same path.
815 assert_eq!(
816 classify("\u{8d85}\u{51fa}\u{6700}\u{5927}\u{4e0a}\u{4e0b}\u{6587} context length"),
817 ErrorCategory::InvalidInput,
818 );
819 // Pure-Chinese messages with no keyword match land on Internal.
820 assert_eq!(
821 classify("\u{4e0d}\u{77e5}\u{9053}\u{600e}\u{4e48}\u{56de}\u{4e8b}"),
822 ErrorCategory::Internal,
823 );
824 }
825
826 #[test]
827 fn error_envelope_display_includes_severity_code_message() {
828 let env = ErrorEnvelope::new(
829 ErrorCategory::Network,
830 ErrorSeverity::Warning,
831 true,
832 "net_transient",
833 "DNS resolution failed",
834 );
835 assert_eq!(
836 format!("{env}"),
837 "[warning] net_transient: DNS resolution failed"
838 );
839 }
840
841 #[test]
842 fn error_category_display_round_trips_via_snake_case() {
843 // The snake_case labels are what crosses the wire / hits logs;
844 // pin them so a future rename doesn't silently shift consumer
845 // contracts.
846 assert_eq!(format!("{}", ErrorCategory::Network), "network");
847 assert_eq!(format!("{}", ErrorCategory::RateLimit), "rate_limit");
848 assert_eq!(format!("{}", ErrorCategory::InvalidInput), "invalid_input");
849 assert_eq!(format!("{}", ErrorSeverity::Critical), "critical");
850 }
851 }
852
852 lines RUST