diff --git a/agent/error_classifier.py b/agent/error_classifier.py index 92d9fd43efba0..94a4fe2646caf 100644 --- a/agent/error_classifier.py +++ b/agent/error_classifier.py @@ -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 diff --git a/tests/agent/test_error_classifier.py b/tests/agent/test_error_classifier.py index 37b498b251c17..05374d02b5c65 100644 --- a/tests/agent/test_error_classifier.py +++ b/tests/agent/test_error_classifier.py @@ -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).