[eric] agents: a self-healing reset-window 401 retries silently instead of flashing the reconnect card

This commit is contained in:
ciregenz
2026-08-06 20:58:42 -07:00
parent d1d6e0e0e5
commit 7c3999d779
2 changed files with 27 additions and 2 deletions
+11 -2
View File
@@ -14,6 +14,7 @@ TRANSIENT_CAPACITY_PATTERNS = re.compile(
r"|internal\s+server\s+error"
r"|rate[_\s-]?limit(?:_error)?"
r"|ECONNRESET|ETIMEDOUT|ENETUNREACH|fetch\s+failed"
r"|reset\s+after\s+\d"
r"|resource[_\s-]?exhausted"
r"|upstream\s+connect\s+error)",
re.IGNORECASE,
@@ -154,6 +155,10 @@ def is_auth_error(exc: BaseException, extra_text: str = "") -> bool:
# A tool-schema translation 400 can carry provider/connection wording that trips the auth regex below; it isn't auth, so don't claim it is.
if is_translation_error(exc, extra_text):
return False
# A 401 that names its own recovery window ("reset after 1m 57s") is a token mid-refresh; it
# heals itself, so the reconnect card would lie. The transient classifier retries it instead.
if re.search(r"reset\s+after|try\s+again\s+in", combined, re.IGNORECASE):
return False
return bool(re.search(
r"\b(401|403)\b"
r"|invalid\s+authentication\s+credentials"
@@ -244,11 +249,15 @@ def p_get_transient_exc_types() -> Tuple[type, ...]:
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.
combined = f"{exc!s}\n{extra_text}".strip()
if combined and NON_TRANSIENT_PATTERNS.search(combined):
return False
# 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
# 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):
return True
if combined and NON_TRANSIENT_PATTERNS.search(combined):
return False
# Ahead of the empty-string bail on purpose: what the exception IS doesn't depend on whether it bothered to say anything.
if isinstance(exc, p_get_transient_exc_types()):
return True
+16
View File
@@ -108,3 +108,19 @@ def test_ordinary_turn_failures_are_not_router_unreachable():
"",
):
assert not is_router_unreachable_error(text), text
# --- self-healing 401s: mid-refresh tokens must retry, never flash the reconnect card ------------
from backend.apps.agents.core.error_classify import is_auth_error
def test_reset_window_401_is_transient_not_auth():
# Verbatim live codex body (2026-08-06): healed itself two minutes later, chats were fine.
body = '[codex/gpt-5.2] [401]: Provided authentication token is expired. Please try signing in again. (reset after 1m 57s)'
assert not is_auth_error(Exception(body)), "a self-healing 401 must not show the reconnect card"
assert capacity_retry_wait(Exception(body), 0) == 5, "and the turn silently retries through the window"
def test_genuine_auth_death_still_cards():
assert is_auth_error(Exception("401 unauthorized: invalid authentication credentials"))
assert capacity_retry_wait(Exception("401 unauthorized: invalid api key"), 0) is None