mirror of
https://github.com/openswarm-ai/openswarm.git
synced 2026-08-17 18:25:42 +02:00
[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:
@@ -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 = (
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user