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