test(error-taxonomy): cover classify_error_message and Display impls
`error_taxonomy.rs` is the central typed-error layer — every
subsystem (client, tools, runtime, UI) routes through
`ErrorEnvelope` and `classify_error_message` to decide retry
policy, UI colour, and offline-flip semantics. Today it ships with
zero tests, so a single innocuous keyword reordering could shift
classification across the whole tree.
Adds 17 unit tests:
* One per category (Network, Authentication, Authorization,
RateLimit, Timeout, InvalidInput, Parse, State, Tool, Internal)
exercising the keyword variants the function intends to catch —
e.g. context-overflow phrasings ("maximum context length",
"context_length_exceeded", "prompt is too long", the OpenAI
"you requested … the maximum is" wording, "context window"),
HTTP 5xx with various spacing rules (502 / 503 / 504, leading
space, trailing space, exact match, embedded), and 429/quota
rate-limit phrasings.
* Three precedence tests pinning the load-bearing ordering:
InvalidInput beats Tool (so a "tool returned: maximum context
length" still surfaces as a /compact-able invalid input),
Timeout beats Network (so "504 Gateway Timeout" classifies as
Timeout because its retry semantics are gentler than Network's),
and RateLimit beats Authentication (so a 429 with API token
phrasing doesn't get misrouted to auth-failure handling).
* Unicode handling: a Chinese error message that still mentions
"context length" hits InvalidInput; a pure-Chinese unknown
message falls through to Internal.
* Display impls round-trip through their snake_case wire form so
consumers depending on the labels can't be silently broken.
Zero behaviour change; only tests + one comment pinning the 504
precedence rule.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -475,3 +475,260 @@ impl fmt::Display for StreamError {
|
||||
}
|
||||
|
||||
impl std::error::Error for StreamError {}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn classify(msg: &str) -> ErrorCategory {
|
||||
classify_error_message(msg)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn invalid_input_catches_context_overflow_phrasings() {
|
||||
// Provider phrasing varies: DeepSeek/OpenAI/Anthropic/etc each
|
||||
// surface context-overflow as a slightly different string.
|
||||
// The classifier needs all of them on the same branch.
|
||||
for msg in [
|
||||
"This model's maximum context length is 1000000 tokens",
|
||||
"Error: context_length_exceeded",
|
||||
"Your prompt is too long for the current model",
|
||||
"You requested 100000 tokens but the maximum is 65536",
|
||||
"request exceeds context window",
|
||||
] {
|
||||
assert_eq!(
|
||||
classify(msg),
|
||||
ErrorCategory::InvalidInput,
|
||||
"expected InvalidInput for `{msg}`",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rate_limit_catches_429_and_quota_phrasings() {
|
||||
for msg in [
|
||||
"Rate limit reached for gpt-4",
|
||||
"Too Many Requests",
|
||||
"HTTP 429 from upstream",
|
||||
"Your quota has been exceeded",
|
||||
] {
|
||||
assert_eq!(
|
||||
classify(msg),
|
||||
ErrorCategory::RateLimit,
|
||||
"expected RateLimit for `{msg}`",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn timeout_catches_both_spellings() {
|
||||
assert_eq!(classify("connection timeout"), ErrorCategory::Timeout);
|
||||
assert_eq!(
|
||||
classify("request timed out after 30s"),
|
||||
ErrorCategory::Timeout
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn authentication_beats_authorization_when_api_key_phrasing_is_used() {
|
||||
// "api key" landing on Authentication (not Authorization) keeps
|
||||
// the operator-facing message correct: the user needs to fix
|
||||
// their key, not their permissions.
|
||||
for msg in [
|
||||
"Invalid API key provided",
|
||||
"Authentication failed",
|
||||
"401 Unauthorized",
|
||||
] {
|
||||
assert_eq!(
|
||||
classify(msg),
|
||||
ErrorCategory::Authentication,
|
||||
"expected Authentication for `{msg}`",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn authorization_catches_forbidden_and_denied() {
|
||||
for msg in [
|
||||
"403 Forbidden",
|
||||
"Permission denied for resource",
|
||||
"Tool 'edit_file' denied by user",
|
||||
] {
|
||||
assert_eq!(
|
||||
classify(msg),
|
||||
ErrorCategory::Authorization,
|
||||
"expected Authorization for `{msg}`",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn network_catches_dns_connection_5xx() {
|
||||
for msg in [
|
||||
"Network is unreachable",
|
||||
"Connection reset by peer",
|
||||
"DNS resolution failed for api.deepseek.com",
|
||||
"503 Service Unavailable",
|
||||
"Upstream returned 502 Bad Gateway",
|
||||
"Service temporarily unavailable",
|
||||
] {
|
||||
assert_eq!(
|
||||
classify(msg),
|
||||
ErrorCategory::Network,
|
||||
"expected Network for `{msg}`",
|
||||
);
|
||||
}
|
||||
// Edge-case precedence: "504 Gateway Timeout" mentions both
|
||||
// a 504 status code AND the word "timeout". The classifier
|
||||
// picks Timeout, which is correct — the operator-actionable
|
||||
// category for a 504 is "wait and retry" (Timeout semantics)
|
||||
// rather than "DNS / connection broken" (Network semantics).
|
||||
assert_eq!(
|
||||
classify("504 Gateway Timeout"),
|
||||
ErrorCategory::Timeout,
|
||||
"504 with the literal word `timeout` resolves as Timeout, not Network"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_catches_syntax_and_malformed_json() {
|
||||
for msg in [
|
||||
"Failed to parse response JSON",
|
||||
"Syntax error in tool arguments",
|
||||
"Malformed event from stream",
|
||||
] {
|
||||
assert_eq!(
|
||||
classify(msg),
|
||||
ErrorCategory::Parse,
|
||||
"expected Parse for `{msg}`",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn state_catches_not_found_and_unavailable() {
|
||||
for msg in [
|
||||
"Session not found",
|
||||
"Model is unavailable for this provider",
|
||||
"Endpoint not available in this region",
|
||||
] {
|
||||
assert_eq!(
|
||||
classify(msg),
|
||||
ErrorCategory::State,
|
||||
"expected State for `{msg}`",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tool_is_a_low_priority_catchall_for_tool_keyword() {
|
||||
// The Tool branch is the last keyword check before falling
|
||||
// through to Internal. Anything mentioning "tool" that didn't
|
||||
// match an earlier category should land here.
|
||||
assert_eq!(
|
||||
classify("Tool returned non-zero exit status"),
|
||||
ErrorCategory::Tool,
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unknown_messages_fall_through_to_internal() {
|
||||
for msg in [
|
||||
"Something exploded",
|
||||
"panic at the disco",
|
||||
"u-200 something happened",
|
||||
"",
|
||||
] {
|
||||
assert_eq!(
|
||||
classify(msg),
|
||||
ErrorCategory::Internal,
|
||||
"expected Internal for `{msg}`",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn classifier_is_case_insensitive() {
|
||||
// The function lowercases internally — every category must
|
||||
// match regardless of input casing.
|
||||
assert_eq!(classify("RATE LIMIT EXCEEDED"), ErrorCategory::RateLimit);
|
||||
assert_eq!(classify("TimeOut"), ErrorCategory::Timeout);
|
||||
assert_eq!(classify("UNAUTHORIZED"), ErrorCategory::Authentication);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn precedence_invalid_input_beats_tool() {
|
||||
// A "context length" tool error should classify as
|
||||
// InvalidInput, not Tool — InvalidInput is the more actionable
|
||||
// category (the user needs to shorten their prompt; "tool
|
||||
// failure" wouldn't tell them that).
|
||||
assert_eq!(
|
||||
classify("tool returned: maximum context length is 1000000"),
|
||||
ErrorCategory::InvalidInput,
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn precedence_timeout_beats_network() {
|
||||
// A timeout that mentions a network call should still classify
|
||||
// as Timeout — the retry policy for timeouts is gentler than
|
||||
// for outright network failures.
|
||||
assert_eq!(
|
||||
classify("network call timed out after 30s"),
|
||||
ErrorCategory::Timeout,
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn precedence_rate_limit_beats_authentication() {
|
||||
// 429 messages sometimes mention "api" or "auth" tokens, but
|
||||
// RateLimit's retry semantics (back off + retry) are what the
|
||||
// operator actually wants.
|
||||
assert_eq!(
|
||||
classify("Rate limit on your API quota exceeded"),
|
||||
ErrorCategory::RateLimit,
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn classifier_handles_unicode_safely() {
|
||||
// Unicode shouldn't trip the lowercase step or the keyword
|
||||
// scan — Chinese/Japanese error messages from
|
||||
// OpenAI-compatible providers go through the same path.
|
||||
assert_eq!(
|
||||
classify("\u{8d85}\u{51fa}\u{6700}\u{5927}\u{4e0a}\u{4e0b}\u{6587} context length"),
|
||||
ErrorCategory::InvalidInput,
|
||||
);
|
||||
// Pure-Chinese messages with no keyword match land on Internal.
|
||||
assert_eq!(
|
||||
classify("\u{4e0d}\u{77e5}\u{9053}\u{600e}\u{4e48}\u{56de}\u{4e8b}"),
|
||||
ErrorCategory::Internal,
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn error_envelope_display_includes_severity_code_message() {
|
||||
let env = ErrorEnvelope::new(
|
||||
ErrorCategory::Network,
|
||||
ErrorSeverity::Warning,
|
||||
true,
|
||||
"net_transient",
|
||||
"DNS resolution failed",
|
||||
);
|
||||
assert_eq!(
|
||||
format!("{env}"),
|
||||
"[warning] net_transient: DNS resolution failed"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn error_category_display_round_trips_via_snake_case() {
|
||||
// The snake_case labels are what crosses the wire / hits logs;
|
||||
// pin them so a future rename doesn't silently shift consumer
|
||||
// contracts.
|
||||
assert_eq!(format!("{}", ErrorCategory::Network), "network");
|
||||
assert_eq!(format!("{}", ErrorCategory::RateLimit), "rate_limit");
|
||||
assert_eq!(format!("{}", ErrorCategory::InvalidInput), "invalid_input");
|
||||
assert_eq!(format!("{}", ErrorSeverity::Critical), "critical");
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user