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