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