| 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 |