[eric] error-classify: schema-translation 400s aren't auth; gemini RESOURCE_EXHAUSTED is transient

This commit is contained in:
ciregenz
2026-06-15 14:05:21 -07:00
parent 2bb5ee05f6
commit ad00fd19ae
2 changed files with 62 additions and 0 deletions
@@ -12,10 +12,30 @@ _TRANSIENT_CAPACITY_PATTERNS = re.compile(
r"|internal\s+server\s+error"
r"|rate[_\s-]?limit(?:_error)?"
r"|ECONNRESET|ETIMEDOUT|ENETUNREACH|fetch\s+failed"
r"|resource[_\s-]?exhausted"
r"|upstream\s+connect\s+error)",
re.IGNORECASE,
)
# A first message ships the full tool schema; 9Router rewrites Anthropic
# tools[].input_schema into Gemini function_declarations / OpenAI params, and a
# construct it can't translate makes the provider 400 (INVALID_ARGUMENT) with
# zero tokens. That is NOT auth, reconnecting won't help, the request shape is
# wrong, so we classify it apart and stop the catch-all from showing a
# "reconnect your subscription" card for a tool-schema 400.
_TRANSLATION_ERROR_PATTERNS = re.compile(
r"(?:function_declarations"
r"|invalid_argument"
r"|invalid\s+json\s+payload"
r"|unknown\s+name\b"
r"|cannot\s+find\s+field"
r"|proto\s+field"
r"|input_schema"
r"|\btools\[\d+\]"
r")",
re.IGNORECASE,
)
# Patterns that look rate-limit-ish but are actually non-transient (user quota,
# auth, context-window tier gate). Must NOT retry, upgrading, reauthing, or
# trimming context is required. The long-context-required variant is what
@@ -69,6 +89,17 @@ def _is_free_trial_exhausted(exc: BaseException, extra_text: str = "") -> bool:
))
def _is_translation_error(exc: BaseException, extra_text: str = "") -> bool:
"""True when the upstream 400 is a tool-schema / protocol translation
failure (9Router rewriting Anthropic tools into Gemini function_declarations
or OpenAI params), not auth or capacity. Kept distinct so the catch-all
stops mislabeling a schema 400 as an expired-subscription reconnect card."""
combined = f"{exc!s}\n{extra_text}".strip()
if not combined:
return False
return bool(_TRANSLATION_ERROR_PATTERNS.search(combined))
def _is_auth_error(exc: BaseException, extra_text: str = "") -> bool:
"""True when the upstream error is a 401/403 auth failure.
@@ -80,6 +111,10 @@ def _is_auth_error(exc: BaseException, extra_text: str = "") -> bool:
combined = f"{exc!s}\n{extra_text}".strip()
if not combined:
return False
# 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
return bool(re.search(
r"\b(401|403)\b"
r"|invalid\s+authentication\s+credentials"
+27
View File
@@ -523,6 +523,33 @@ def test_resolve_sdk_gemini_prefers_antigravity_over_api_key():
assert registry.resolve_model_id_for_sdk("gemini-3-flash", s2) == "gc/gemini-3-flash-preview"
def test_error_classify_schema_translation_400_is_not_auth():
"""A 9Router tool-schema translation 400 can carry provider/connection
wording that trips the auth regex, so it used to surface a misleading
'reconnect your subscription' card for what is really a schema bug. The
translation guard must win: schema 400 -> not auth; a real auth failure
with no translation signature still reads as auth."""
from backend.apps.agents.core.error_classify import _is_auth_error, _is_translation_error
both = Exception("provider not connected: 400 INVALID_ARGUMENT at "
"tools[0].function_declarations[0].parameters")
assert _is_translation_error(both)
assert not _is_auth_error(both), "schema-400 must not be classified as auth"
# Pure auth failures (no translation signature) still classify as auth.
assert _is_auth_error(Exception("provider not connected: gemini"))
assert _is_auth_error(Exception("401 invalid authentication credentials"))
assert not _is_translation_error(Exception("401 invalid authentication credentials"))
def test_error_classify_gemini_resource_exhausted_is_transient():
"""gemini-cli's free-tier 429 surfaces as RESOURCE_EXHAUSTED; it must count
as transient so the existing backoff/retry catches it instead of dying as a
hard first-message error. A 403 (hard auth/quota) must still NOT retry."""
from backend.apps.agents.core.error_classify import _is_transient_capacity_error
assert _is_transient_capacity_error(Exception("429 RESOURCE_EXHAUSTED: Quota exceeded"))
assert _is_transient_capacity_error(Exception("RESOURCE_EXHAUSTED"))
assert not _is_transient_capacity_error(Exception("403 permission denied"))
def test_banned_models_not_offered():
"""Claude Fable (banned) and Gemini 3.1 Pro (no working lane: AG can't serve
it, AI Studio key 429s pro-preview) were pulled from the picker. Guard so a