返回 DeepSeek-TUI-2026
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 LlmError::ServerError { status, message } => Self::new(
217 ErrorCategory::Internal,
218 ErrorSeverity::Error,
219 true,
220 format!("llm_server_{status}"),
221 message,
222 ),
223 LlmError::NetworkError(message) => Self::new(
224 ErrorCategory::Network,
225 ErrorSeverity::Error,
226 true,
227 "llm_network_error",
228 message,
229 ),
230 LlmError::Timeout(duration) => Self::new(
231 ErrorCategory::Timeout,
232 ErrorSeverity::Warning,
233 true,
234 "llm_timeout",
235 format!("Request timed out after {duration:?}"),
236 ),
237 LlmError::AuthenticationError(message) => Self::new(
238 ErrorCategory::Authentication,
239 ErrorSeverity::Critical,
240 false,
241 "llm_auth_error",
242 message,
243 ),
244 LlmError::InvalidRequest { message, .. } => Self::new(
245 ErrorCategory::InvalidInput,
246 ErrorSeverity::Error,
247 false,
248 "llm_invalid_request",
249 message,
250 ),
251 LlmError::ModelError(message) => Self::new(
252 ErrorCategory::InvalidInput,
253 ErrorSeverity::Error,
254 false,
255 "llm_model_error",
256 message,
257 ),
258 LlmError::ContentPolicyError(message) => Self::new(
259 ErrorCategory::Authorization,
260 ErrorSeverity::Error,
261 false,
262 "llm_content_policy",
263 message,
264 ),
265 LlmError::ParseError(message) => Self::new(
266 ErrorCategory::Parse,
267 ErrorSeverity::Error,
268 false,
269 "llm_parse_error",
270 message,
271 ),
272 LlmError::ContextLengthError(message) => Self::new(
273 ErrorCategory::InvalidInput,
274 ErrorSeverity::Error,
275 false,
276 "llm_context_length",
277 message,
278 ),
279 LlmError::Other(message) => Self::new(
280 ErrorCategory::Internal,
281 ErrorSeverity::Error,
282 true,
283 "llm_other",
284 message,
285 ),
286 }
287 }
288 }
289
290 /// Classify an error message string into an ErrorCategory.
291 ///
292 /// Uses heuristic keyword matching on the lowercased message.
293 /// This is a replacement for ad-hoc string matching in callers.
294 #[must_use]
295 pub fn classify_error_message(message: &str) -> ErrorCategory {
296 let lower = message.to_lowercase();
297
298 if lower.contains("maximum context length")
299 || lower.contains("context length")
300 || lower.contains("context_length")
301 || lower.contains("prompt is too long")
302 || (lower.contains("requested") && lower.contains("tokens") && lower.contains("maximum"))
303 || lower.contains("context window")
304 {
305 return ErrorCategory::InvalidInput;
306 }
307 if lower.contains("rate limit")
308 || lower.contains("too many requests")
309 || lower.contains("429")
310 || lower.contains("quota")
311 {
312 return ErrorCategory::RateLimit;
313 }
314 if lower.contains("timeout") || lower.contains("timed out") {
315 return ErrorCategory::Timeout;
316 }
317 if lower.contains("auth") || lower.contains("unauthorized") || lower.contains("api key") {
318 return ErrorCategory::Authentication;
319 }
320 if lower.contains("permission") || lower.contains("forbidden") || lower.contains("denied") {
321 return ErrorCategory::Authorization;
322 }
323 if lower.contains("network")
324 || lower.contains("connection")
325 || lower.contains("dns")
326 || lower.contains("temporarily unavailable")
327 || lower.contains(" 502 ")
328 || lower.contains(" 503 ")
329 || lower.contains(" 504 ")
330 || lower.starts_with("502 ")
331 || lower.starts_with("503 ")
332 || lower.starts_with("504 ")
333 || lower.ends_with(" 502")
334 || lower.ends_with(" 503")
335 || lower.ends_with(" 504")
336 || lower == "502"
337 || lower == "503"
338 || lower == "504"
339 {
340 return ErrorCategory::Network;
341 }
342 if lower.contains("parse") || lower.contains("syntax") || lower.contains("malformed") {
343 return ErrorCategory::Parse;
344 }
345 if lower.contains("not found")
346 || lower.contains("unavailable")
347 || lower.contains("not available")
348 {
349 return ErrorCategory::State;
350 }
351 if lower.contains("tool") {
352 return ErrorCategory::Tool;
353 }
354
355 ErrorCategory::Internal
356 }
357
358 impl From<ToolError> for ErrorEnvelope {
359 fn from(value: ToolError) -> Self {
360 match value {
361 ToolError::InvalidInput { message } => Self::new(
362 ErrorCategory::InvalidInput,
363 ErrorSeverity::Error,
364 false,
365 "tool_invalid_input",
366 message,
367 ),
368 ToolError::MissingField { field } => Self::new(
369 ErrorCategory::InvalidInput,
370 ErrorSeverity::Error,
371 false,
372 "tool_missing_field",
373 format!("Missing required field: {field}"),
374 ),
375 ToolError::PathEscape { path } => Self::new(
376 ErrorCategory::Authorization,
377 ErrorSeverity::Error,
378 false,
379 "tool_path_escape",
380 format!("Path escapes workspace: {}", path.display()),
381 ),
382 ToolError::ExecutionFailed { message } => Self::new(
383 ErrorCategory::Tool,
384 ErrorSeverity::Error,
385 true,
386 "tool_execution_failed",
387 message,
388 ),
389 ToolError::Timeout { seconds } => Self::new(
390 ErrorCategory::Timeout,
391 ErrorSeverity::Warning,
392 true,
393 "tool_timeout",
394 format!("Tool timed out after {seconds}s"),
395 ),
396 ToolError::NotAvailable { message } => Self::new(
397 ErrorCategory::State,
398 ErrorSeverity::Error,
399 false,
400 "tool_not_available",
401 message,
402 ),
403 ToolError::PermissionDenied { message } => Self::new(
404 ErrorCategory::Authorization,
405 ErrorSeverity::Error,
406 false,
407 "tool_permission_denied",
408 message,
409 ),
410 }
411 }
412 }
413
414 /// Stream‑level error discriminated by origin.
415 ///
416 /// Each variant maps to an `ErrorCategory` so the UI can render
417 /// stream‑specific icons or formatting. Wired into engine.rs at the three
418 /// stream guard sites (chunk timeout, max-bytes overflow, max-duration).
419 #[derive(Debug, Clone)]
420 pub enum StreamError {
421 /// Stream stalled — no chunk received within the idle timeout.
422 Stall { timeout_secs: u64 },
423 /// Stream exceeded content size limit.
424 Overflow { limit_bytes: usize },
425 /// Stream exceeded wall‑clock duration limit.
426 DurationLimit { limit_secs: u64 },
427 }
428
429 impl StreamError {
430 /// Convert directly into an `ErrorEnvelope` for emission on the engine
431 /// event channel. Stalls are warning-severity and recoverable; size and
432 /// duration limits are errors (the user must restart the turn).
433 #[must_use]
434 pub fn into_envelope(self) -> ErrorEnvelope {
435 match self {
436 Self::Stall { timeout_secs } => ErrorEnvelope::new(
437 ErrorCategory::Timeout,
438 ErrorSeverity::Warning,
439 true,
440 "stream_stall",
441 format!("Stream stalled: no data received for {timeout_secs}s, closing stream"),
442 ),
443 Self::Overflow { limit_bytes } => ErrorEnvelope::new(
444 ErrorCategory::Internal,
445 ErrorSeverity::Error,
446 true,
447 "stream_overflow",
448 format!("Stream exceeded maximum content size of {limit_bytes} bytes, closing"),
449 ),
450 Self::DurationLimit { limit_secs } => ErrorEnvelope::new(
451 ErrorCategory::Timeout,
452 ErrorSeverity::Error,
453 true,
454 "stream_duration_limit",
455 format!("Stream exceeded maximum duration of {limit_secs}s, closing"),
456 ),
457 }
458 }
459 }
460
461 impl fmt::Display for StreamError {
462 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
463 match self {
464 Self::Stall { timeout_secs } => {
465 write!(f, "Stream stalled after {timeout_secs}s idle")
466 }
467 Self::Overflow { limit_bytes } => {
468 write!(f, "Stream exceeded {limit_bytes} bytes limit")
469 }
470 Self::DurationLimit { limit_secs } => {
471 write!(f, "Stream exceeded {limit_secs}s duration limit")
472 }
473 }
474 }
475 }
476
477 impl std::error::Error for StreamError {}
478
478 lines RUST