[eric] agents: a TLS certificate failure is non-transient and says so, instead of burning 335s of retries on a broken cert chain

This commit is contained in:
ciregenz
2026-08-09 12:51:02 -07:00
parent c8c3e7be0e
commit 14d4a71e15
3 changed files with 48 additions and 0 deletions
@@ -245,6 +245,21 @@ def p_get_transient_exc_types() -> Tuple[type, ...]:
return p_transient_exc_types
# A broken cert chain is deterministic per host (corporate TLS-inspection proxy, clock skew, stale
# CA bundle): it never heals inside a retry schedule, so it must be checked BEFORE the exception-type
# tuple, because the raising httpx.ConnectError subclasses the transient httpx.TransportError.
CERT_FAILURE_PATTERNS = re.compile(
r"certificate\s+verify\s+failed|CERTIFICATE_VERIFY_FAILED|unable\s+to\s+get\s+local\s+issuer"
r"|self.signed\s+certificate|certificate\s+has\s+expired|hostname\s+mismatch",
re.IGNORECASE,
)
@typechecked
def is_cert_failure(exc: BaseException, extra_text: str = "") -> bool:
return bool(CERT_FAILURE_PATTERNS.search(f"{exc!s}\n{extra_text}"))
@typechecked
def is_transient_capacity_error(exc: BaseException, extra_text: str = "") -> bool:
# The Claude CLI's underlying ProcessError stringifies to a generic "Command failed with exit code 1 / Check stderr output for details"; the real cause (rate_limit_error / No pool capacity available / 429 / overloaded) only surfaces in the subprocess's stderr stream, which we capture via the SDK's `stderr` callback and pass in as extra_text. Classify against both so we catch capacity errors regardless of which channel carried the message.
@@ -252,6 +267,9 @@ def is_transient_capacity_error(exc: BaseException, extra_text: str = "") -> boo
# An overflow can arrive dressed as a 429 ("request too large"); retrying the identical oversized request is guaranteed futile, the valve owns it.
if is_context_overflow_error(exc, extra_text):
return False
# 335s of retries cannot fix a certificate; the user has to (ENG-218, reproduced against badssl).
if is_cert_failure(exc, extra_text):
return False
# A failure that names its own recovery window ("reset after 1m 57s") heals itself, even when
# it's dressed as a 401; the reset hint outranks the auth-shaped non-transient veto (caught live).
if combined and re.search(r"reset\s+after\s+\d", combined, re.IGNORECASE):
@@ -18,6 +18,7 @@ from backend.apps.agents.core.error_classify import (
is_free_trial_exhausted,
is_out_of_tokens,
is_auth_error,
is_cert_failure,
is_cli_binary_missing,
is_unknown_model_error,
parse_retry_after,
@@ -111,6 +112,22 @@ async def handle_run_error(e: Exception, session: AgentSession, session_id: str,
})
except Exception:
logger.debug("submit_diagnostic for context_overflow failed", exc_info=True)
elif is_cert_failure(e, extra_text=p_stderr_tail):
# Deterministic per host: corporate TLS-inspection proxy, clock skew, or a stale CA bundle. Waiting can't fix any of them, so name the real remedies instead of a rate-limit-shaped pill (ENG-218).
friendly_msg = (
"OpenSwarm couldn't verify the AI provider's security certificate, so the "
"connection was refused. This usually means a corporate proxy or security "
"tool (Zscaler, Netskope) is inspecting your traffic, or your system clock "
"is wrong. Check the clock, try a different network, or ask IT to allow "
"api.anthropic.com; retrying won't help until one of those changes."
)
error_msg = Message(role="system", content=friendly_msg, branch_id=session.active_branch_id)
session.messages.append(error_msg)
await ws_manager.send_to_session(session_id, "agent:message", {
"session_id": session_id,
"message": error_msg.model_dump(mode="json"),
})
p_report_model_error("cert_failure", session_id, session, turn, e, p_stderr_tail)
elif is_cli_binary_missing(e, extra_text=p_stderr_tail):
# The bundled CLI vanished from an installed app (Windows AV quarantine class; 22 of 25 field installs never recovered). The raw "not found at: C:\..." card is unactionable; name the likely cause and the two real fixes.
friendly_msg = (
+13
View File
@@ -131,3 +131,16 @@ def test_first_real_exception_all_cancelled_is_none():
def test_first_real_exception_plain_passthrough():
boom = RuntimeError("x")
assert first_real_exception(boom) is boom
def test_cert_failure_is_never_transient():
import httpx
from backend.apps.agents.core.error_classify import is_cert_failure, is_transient_capacity_error
exc = httpx.ConnectError("[SSL: CERTIFICATE_VERIFY_FAILED] certificate verify failed: self-signed certificate")
assert is_cert_failure(exc) is True
# httpx.ConnectError subclasses the transient TransportError; the cert check must win (ENG-218).
assert is_transient_capacity_error(exc) is False
for msg in ("unable to get local issuer certificate", "certificate has expired", "Hostname mismatch"):
assert is_transient_capacity_error(httpx.ConnectError(msg)) is False
# A cert-free transport hiccup keeps its transient classification.
assert is_transient_capacity_error(httpx.ConnectError("Connection refused")) is True