Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
44 changes: 44 additions & 0 deletions agent/conversation_loop.py
Original file line number Diff line number Diff line change
Expand Up @@ -3603,6 +3603,8 @@ def _perform_api_call(next_api_kwargs):
if agent._has_pending_fallback():
if classified.reason == FailoverReason.content_policy_blocked:
agent._buffer_status("⚠️ Provider safety filter blocked this request — trying fallback...")
elif classified.reason == FailoverReason.ssl_cert_verification:
agent._buffer_status("⚠️ TLS certificate verification failed — trying fallback...")
else:
agent._buffer_status(f"⚠️ Non-retryable error (HTTP {status_code}) — trying fallback...")
if agent._try_activate_fallback():
Expand Down Expand Up @@ -3631,6 +3633,11 @@ def _perform_api_call(next_api_kwargs):
f"❌ Provider safety filter blocked this request: "
f"{_nonretryable_summary}"
)
elif classified.reason == FailoverReason.ssl_cert_verification:
agent._emit_status(
f"❌ TLS certificate verification failed: "
f"{_nonretryable_summary}"
)
else:
agent._emit_status(
f"❌ Non-retryable error (HTTP {status_code}): "
Expand Down Expand Up @@ -3704,6 +3711,43 @@ def _perform_api_call(next_api_kwargs):
f"{agent.log_prefix} hermes fallback add (interactive picker — same as `hermes model`)",
force=True,
)
# TLS certificate failures are environment problems, not
# provider/prompt problems — tell the user exactly which
# knobs fix each common cause. Inspired by Claude Code
# v2.1.199's immediate SSL fix hints.
if classified.reason == FailoverReason.ssl_cert_verification:
agent._vprint(
f"{agent.log_prefix} 💡 The TLS certificate chain could not be verified. This fails the same",
force=True,
)
agent._vprint(
f"{agent.log_prefix} way on every retry — fix the environment, then try again:",
force=True,
)
agent._vprint(
f"{agent.log_prefix} • Corporate TLS-inspecting proxy? Point Python at its CA bundle:",
force=True,
)
agent._vprint(
f"{agent.log_prefix} export SSL_CERT_FILE=/path/to/corp-ca.pem (also REQUESTS_CA_BUNDLE)",
force=True,
)
agent._vprint(
f"{agent.log_prefix} • Missing/stale system CA store? Install/refresh it:",
force=True,
)
agent._vprint(
f"{agent.log_prefix} pip install --upgrade certifi (macOS: run 'Install Certificates.command')",
force=True,
)
agent._vprint(
f"{agent.log_prefix} • Self-signed local endpoint (llama.cpp, LM Studio, vLLM)? Use http://",
force=True,
)
agent._vprint(
f"{agent.log_prefix} for localhost, or add the server's cert to your trust store.",
force=True,
)
logger.error(f"{agent.log_prefix}Non-retryable client error: {api_error}")
# Skip session persistence when the error is likely
# context-overflow related (status 400 + large session).
Expand Down
45 changes: 44 additions & 1 deletion agent/error_classifier.py
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,11 @@ class FailoverReason(enum.Enum):

# Transport
timeout = "timeout" # Connection/read timeout — rebuild client + retry
# TLS certificate verification failure — deterministic for the host
# (TLS-inspecting proxy, missing/expired CA bundle, self-signed cert).
# Retrying reproduces the identical handshake failure, so fail fast
# with actionable guidance instead of burning retries.
ssl_cert_verification = "ssl_cert_verification"

# Context / payload
context_overflow = "context_overflow" # Context too large — compress, not failover
Expand Down Expand Up @@ -438,6 +443,29 @@ def is_auth(self) -> bool:
"incomplete chunked read",
]

# SSL certificate verification failures — deterministic, NOT transient.
#
# A failed certificate chain (TLS-inspecting corporate proxy, missing
# custom CA in the trust store, expired certificate, self-signed cert)
# fails identically on every retry. Burning the retry budget before
# surfacing the error hides the actionable fix from the user for minutes.
# Inspired by Claude Code v2.1.199 (July 2026), which made SSL certificate
# errors fail immediately with a fix hint instead of retrying.
#
# Must be checked BEFORE _SSL_TRANSIENT_PATTERNS — "certificate verify
# failed" messages usually also contain "[SSL:" which would otherwise
# match the transient list and retry forever.
_SSL_CERT_VERIFY_PATTERNS = [
"certificate verify failed", # Python ssl module canonical text
"certificate_verify_failed", # OpenSSL error token
"unable to get local issuer certificate",
"self-signed certificate",
"self signed certificate",
"certificate has expired",
"hostname mismatch, certificate is not valid",
"unable to verify the first certificate", # Node/undici phrasing (MCP bridges)
]

# SSL/TLS transient failure patterns — intentionally distinct from
# _SERVER_DISCONNECT_PATTERNS above.
#
Expand Down Expand Up @@ -735,7 +763,22 @@ def _result(reason: FailoverReason, **overrides) -> ClassifiedError:
if classified is not None:
return classified

# ── 5. SSL/TLS transient errors → retry as timeout (not compression) ──
# ── 5. SSL certificate verification failures → fail fast ────────
# A broken certificate chain (TLS-inspecting proxy, missing custom CA,
# expired/self-signed cert) is deterministic for the host — every retry
# reproduces the identical handshake failure. Fail immediately with
# actionable guidance instead of burning the retry budget first.
# Checked BEFORE the transient-SSL patterns: cert-verify messages also
# contain "[ssl:" which would otherwise match the transient list.
# Inspired by Claude Code v2.1.199 (July 2026).
if any(p in error_msg for p in _SSL_CERT_VERIFY_PATTERNS):
return _result(
FailoverReason.ssl_cert_verification,
retryable=False,
should_fallback=False,
)

# ── 5b. SSL/TLS transient errors → retry as timeout (not compression) ──
# SSL alerts mid-stream are transport hiccups, not server-side context
# overflow signals. Classify before the disconnect check so a large
# session doesn't incorrectly trigger context compression when the real
Expand Down
72 changes: 72 additions & 0 deletions tests/agent/test_error_classifier.py
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,7 @@ def test_enum_members_exist(self):
"auth", "auth_permanent", "billing", "rate_limit",
"upstream_rate_limit",
"overloaded", "server_error", "timeout",
"ssl_cert_verification",
"context_overflow", "payload_too_large", "image_too_large",
"model_not_found", "format_error",
"invalid_encrypted_content",
Expand Down Expand Up @@ -1679,6 +1680,77 @@ def test_real_ssl_error_type_classifies_as_timeout(self):
assert result.reason == FailoverReason.timeout
assert result.retryable is True


# ── Test: SSL certificate verification failures (fail fast) ────────────

class TestSSLCertVerificationFailFast:
"""Certificate verification failures are deterministic for the host —
a TLS-inspecting proxy, missing custom CA, expired or self-signed cert
fails identically on every retry. They must classify as non-retryable
``ssl_cert_verification`` so the user sees the fix hint immediately,
instead of matching the transient "[ssl:" pattern and retrying forever.

Inspired by Claude Code v2.1.199 (July 2026).
"""

def test_python_cert_verify_failed_is_non_retryable(self):
import ssl
e = ssl.SSLCertVerificationError(
1,
"[SSL: CERTIFICATE_VERIFY_FAILED] certificate verify failed: "
"unable to get local issuer certificate (_ssl.c:1006)",
)
result = classify_api_error(e)
assert result.reason == FailoverReason.ssl_cert_verification
assert result.retryable is False
assert result.should_compress is False

def test_wrapped_cert_verify_message_is_non_retryable(self):
"""SDKs often re-raise without chaining — match on message alone."""
e = Exception(
"Connection error: [SSL: CERTIFICATE_VERIFY_FAILED] certificate "
"verify failed: self-signed certificate in certificate chain"
)
result = classify_api_error(e)
assert result.reason == FailoverReason.ssl_cert_verification
assert result.retryable is False

def test_expired_certificate_is_non_retryable(self):
e = Exception("certificate verify failed: certificate has expired")
result = classify_api_error(e)
assert result.reason == FailoverReason.ssl_cert_verification
assert result.retryable is False

def test_node_undici_phrasing_is_non_retryable(self):
"""MCP bridges surface Node's phrasing."""
e = Exception("fetch failed: unable to verify the first certificate")
result = classify_api_error(e)
assert result.reason == FailoverReason.ssl_cert_verification
assert result.retryable is False

def test_cert_verify_wins_over_transient_ssl_prefix(self):
"""The '[SSL:' prefix also appears in cert-verify messages; the
cert check must run first so this doesn't retry as timeout."""
e = Exception("[SSL: CERTIFICATE_VERIFY_FAILED] certificate verify failed")
result = classify_api_error(e)
assert result.reason == FailoverReason.ssl_cert_verification
assert result.retryable is False

def test_transient_ssl_alert_still_retries(self):
"""Regression guard: genuine transient alerts keep retrying."""
e = Exception("[SSL: BAD_RECORD_MAC] sslv3 alert bad record mac")
result = classify_api_error(e)
assert result.reason == FailoverReason.timeout
assert result.retryable is True

def test_cert_verify_on_large_session_does_not_compress(self):
e = Exception("certificate verify failed: unable to get local issuer certificate")
result = classify_api_error(
e, approx_tokens=180000, context_length=200000, num_messages=300,
)
assert result.reason == FailoverReason.ssl_cert_verification
assert result.should_compress is False

# ── Test: RateLimitError without status_code (Copilot/GitHub Models) ──────────

class TestRateLimitErrorWithoutStatusCode:
Expand Down
Loading