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:
Teknium 2026-08-06 17:25:12 -07:00
parent 0957277f2f
commit daf13324ca
No known key found for this signature in database
2 changed files with 108 additions and 0 deletions

View File

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

View File

@ -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).