diff --git a/backend/apps/agents/core/error_classify.py b/backend/apps/agents/core/error_classify.py index 275a4eca..173ece10 100644 --- a/backend/apps/agents/core/error_classify.py +++ b/backend/apps/agents/core/error_classify.py @@ -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): diff --git a/backend/apps/agents/manager/run/handle_run_error.py b/backend/apps/agents/manager/run/handle_run_error.py index 3ebac036..9ab9f5b8 100644 --- a/backend/apps/agents/manager/run/handle_run_error.py +++ b/backend/apps/agents/manager/run/handle_run_error.py @@ -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 = ( diff --git a/backend/tests/test_error_classify.py b/backend/tests/test_error_classify.py index bbc5d61c..afaf8d48 100644 --- a/backend/tests/test_error_classify.py +++ b/backend/tests/test_error_classify.py @@ -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