fix(error_classifier): classify connect/DNS failure messages on generic exception types
Port from anomalyco/opencode#40707: connection-establishment and DNS failure messages wrapped in generic exceptions (RuntimeError from local shims, MCP bridges, SDKs re-raising without chaining) fell through to FailoverReason.unknown, which misses the retry loop's eager transport fallback — the full retry budget burned against a dead endpoint before provider fallback. New _CONNECTION_MESSAGE_PATTERNS (connect refused, no route, network unreachable, DNS phrasings across Python/glibc/macOS/Node, fetch failed, Envoy upstream connect error) classify as retryable timeout via _classify_by_message, mirroring _TIMEOUT_MESSAGE_PATTERNS. Mid-stream disconnect strings are deliberately excluded — they keep their _SERVER_DISCONNECT_PATTERNS routing (large-session compression).
This commit is contained in:
parent
0957277f2f
commit
daf13324ca
|
|
@ -523,6 +523,43 @@ _TIMEOUT_MESSAGE_PATTERNS = [
|
|||
"upstream timed out",
|
||||
]
|
||||
|
||||
# Connection-establishment / DNS failure message patterns. These surface
|
||||
# when the exception TYPE is generic (RuntimeError/Exception from a local
|
||||
# shim, MCP bridge, subprocess wrapper, or an SDK that re-raises without
|
||||
# chaining) so the _TRANSPORT_ERROR_TYPES check never fires, and the error
|
||||
# carries no HTTP status. Without message-level matching they fall through
|
||||
# to FailoverReason.unknown, which misses the transport eager-fallback path
|
||||
# in the retry loop (unknown retries the same dead endpoint for the full
|
||||
# budget before fallback). Ported from anomalyco/opencode#40707, which hit
|
||||
# the same bug shape: serialized midstream errors matched by type only.
|
||||
#
|
||||
# Deliberately EXCLUDES mid-stream disconnect strings ("connection reset by
|
||||
# peer", "peer closed connection", "unexpected eof", "socket hang up") —
|
||||
# those belong to _SERVER_DISCONNECT_PATTERNS, whose classification step
|
||||
# runs later and routes large sessions to context-overflow compression.
|
||||
# A connection that was never established cannot be a server-side overflow
|
||||
# rejection, so these are safe to classify as plain retryable transport.
|
||||
_CONNECTION_MESSAGE_PATTERNS = [
|
||||
# TCP connect failures
|
||||
"connection refused",
|
||||
"econnrefused",
|
||||
"no route to host",
|
||||
"network is unreachable",
|
||||
"network unreachable",
|
||||
# DNS resolution failures (Python, glibc, macOS, Node bridge phrasings)
|
||||
"name or service not known",
|
||||
"temporary failure in name resolution",
|
||||
"nodename nor servname provided",
|
||||
"getaddrinfo failed",
|
||||
"getaddrinfo enotfound",
|
||||
"eai_again",
|
||||
# Node/undici bridge generic network failure (MCP servers, local shims)
|
||||
"fetch failed",
|
||||
"failed to fetch",
|
||||
# Envoy/proxy upstream connect failure (cloud gateways)
|
||||
"upstream connect error",
|
||||
]
|
||||
|
||||
# Transport error type names
|
||||
_TRANSPORT_ERROR_TYPES = frozenset({
|
||||
"ReadTimeout", "ConnectTimeout", "PoolTimeout",
|
||||
|
|
@ -1672,6 +1709,16 @@ def _classify_by_message(
|
|||
if any(p in error_msg for p in _TIMEOUT_MESSAGE_PATTERNS):
|
||||
return result_fn(FailoverReason.timeout, retryable=True)
|
||||
|
||||
# Connection-establishment / DNS failure message patterns — same shim
|
||||
# problem as the timeout patterns above: the wrapping exception type is
|
||||
# generic, so _TRANSPORT_ERROR_TYPES never matches and the error would
|
||||
# fall through to FailoverReason.unknown. Classified as timeout (the
|
||||
# transport bucket) so the retry loop's eager transport fallback and
|
||||
# client rebuild apply. Never routes to compression: a connection that
|
||||
# was never established is not a context-overflow signal.
|
||||
if any(p in error_msg for p in _CONNECTION_MESSAGE_PATTERNS):
|
||||
return result_fn(FailoverReason.timeout, retryable=True)
|
||||
|
||||
return None
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -1019,6 +1019,67 @@ class Test408RequestTimeout:
|
|||
assert result.should_compress is False
|
||||
|
||||
|
||||
# ── Test: connection/DNS failure message patterns on generic exception types ──
|
||||
# Port of anomalyco/opencode#40707 (expand retryable error patterns): errors
|
||||
# whose TYPE is generic (RuntimeError/Exception from local shims, MCP bridges,
|
||||
# re-raising SDKs) but whose MESSAGE carries a connection-establishment or DNS
|
||||
# failure must classify as retryable transport, not FailoverReason.unknown.
|
||||
|
||||
class TestConnectionMessagePatterns:
|
||||
"""Generic-typed connect/DNS failures route to the transport bucket."""
|
||||
|
||||
@pytest.mark.parametrize("message", [
|
||||
"connect ECONNREFUSED 127.0.0.1:11434",
|
||||
"Connection refused by proxy",
|
||||
"getaddrinfo failed",
|
||||
"getaddrinfo ENOTFOUND api.example.com",
|
||||
"[Errno -3] Temporary failure in name resolution",
|
||||
"[Errno 8] nodename nor servname provided, or not known",
|
||||
"getaddrinfo EAI_AGAIN openrouter.ai",
|
||||
"Name or service not known",
|
||||
"No route to host",
|
||||
"[Errno 101] Network is unreachable",
|
||||
"fetch failed",
|
||||
"TypeError: Failed to fetch",
|
||||
"upstream connect error or disconnect/reset before headers",
|
||||
])
|
||||
def test_generic_exception_with_connect_failure_message_is_timeout(self, message):
|
||||
# RuntimeError — NOT in _TRANSPORT_ERROR_TYPES, not a ConnectionError
|
||||
# subclass, no status code. Without message matching this falls to
|
||||
# FailoverReason.unknown and misses the eager transport fallback.
|
||||
result = classify_api_error(RuntimeError(message))
|
||||
assert result.reason == FailoverReason.timeout, message
|
||||
assert result.retryable is True
|
||||
assert result.should_compress is False
|
||||
|
||||
def test_connect_failure_never_routes_to_compression_on_large_session(self):
|
||||
# A connection that was never established is not an overflow signal,
|
||||
# even when the session is huge (the disconnect+large-session
|
||||
# heuristic must not apply to connect-phase failures).
|
||||
result = classify_api_error(
|
||||
RuntimeError("connect ECONNREFUSED 10.0.0.5:443"),
|
||||
approx_tokens=180000, context_length=200000, num_messages=400,
|
||||
)
|
||||
assert result.reason == FailoverReason.timeout
|
||||
assert result.should_compress is False
|
||||
|
||||
def test_midstream_disconnect_patterns_still_use_disconnect_path(self):
|
||||
# "connection reset by peer" is deliberately NOT in the connect-phase
|
||||
# list — it stays on the _SERVER_DISCONNECT_PATTERNS path, which
|
||||
# routes large sessions to context-overflow compression.
|
||||
result = classify_api_error(
|
||||
RuntimeError("Connection reset by peer"),
|
||||
approx_tokens=180000, context_length=200000, num_messages=400,
|
||||
)
|
||||
assert result.reason == FailoverReason.context_overflow
|
||||
assert result.should_compress is True
|
||||
|
||||
def test_plain_unknown_error_still_unknown(self):
|
||||
# Guard against over-matching: an unrelated message stays unknown.
|
||||
result = classify_api_error(RuntimeError("something exploded"))
|
||||
assert result.reason == FailoverReason.unknown
|
||||
|
||||
|
||||
# ── Test: throttle vs overflow disambiguation + new overflow shapes ─────
|
||||
# Port of anomalyco/opencode#37848 (expand context overflow patterns +
|
||||
# rate-limit exclusion guard).
|
||||
|
|
|
|||
Loading…
Reference in New Issue