[eric] health: one banner line per provider, a 401 naming its own reset window is mid-refresh not dead

This commit is contained in:
ciregenz
2026-08-06 20:58:42 -07:00
parent d2ff1bdae7
commit d1d6e0e0e5
3 changed files with 60 additions and 5 deletions
@@ -57,6 +57,10 @@ def classify_auth_dead(status_code: int, body_text: str) -> bool:
if status_code not in (401, 403):
return False
low = body_text.lower()
# A 401 that names its own recovery window ("reset after 1m 57s") is a token mid-refresh, not a
# dead login; it heals itself and the banner would cry wolf while real chats work (caught live).
if "reset after" in low or "try again in" in low:
return False
return any(m in low for m in P_AUTH_DEAD_MARKERS)
@@ -101,10 +105,17 @@ async def probe_subscription_health(connections: List[Dict]) -> List[Dict[str, s
async with p_probe_lock:
if p_cached_result is not None and time.monotonic() - p_cached_at < P_CACHE_TTL_S:
return p_cached_result
subs = [
c for c in connections
if isinstance(c, dict) and c.get("provider") in PREFIX_BY_PROVIDER and c.get("isActive")
]
# One probe per PROVIDER: db.json can hold several active rows for one provider (a stale +
# a fresh connect), and probing per row reported "ChatGPT and ChatGPT" in the banner.
p_seen: set = set()
subs = []
for c in connections:
if not (isinstance(c, dict) and c.get("provider") in PREFIX_BY_PROVIDER and c.get("isActive")):
continue
if c.get("provider") in p_seen:
continue
p_seen.add(c.get("provider"))
subs.append(c)
dead: List[Dict[str, str]] = []
if subs:
async with httpx.AsyncClient(timeout=P_PROBE_TIMEOUT_S) as client:
@@ -0,0 +1,43 @@
"""One banner line per provider: two active db.json rows for one provider (stale + fresh connect)
used to probe twice and render "Your ChatGPT and ChatGPT logins have expired"."""
import pytest
from backend.apps.nine_router import subscription_health as sh
@pytest.mark.asyncio
async def test_duplicate_provider_rows_probe_and_report_once(monkeypatch):
sh.invalidate_health_cache()
monkeypatch.setattr(sh, "is_running", lambda: True)
probed = []
async def fake_pick(client, prefix):
return prefix + "model"
async def fake_probe(client, model):
probed.append(model)
return True
monkeypatch.setattr(sh, "p_pick_probe_model", fake_pick)
monkeypatch.setattr(sh, "p_probe_one", fake_probe)
conns = [
{"provider": "codex", "isActive": True, "id": "stale"},
{"provider": "codex", "isActive": True, "id": "fresh"},
{"provider": "claude", "isActive": True, "id": "c1"},
]
dead = await sh.probe_subscription_health(conns)
assert probed == ["cx/model", "cc/model"], "one probe per provider, not per row"
assert [d["label"] for d in dead] == ["ChatGPT", "Claude"], "labels never repeat"
sh.invalidate_health_cache()
def test_self_healing_401_with_reset_window_is_not_dead():
# Verbatim live body (2026-08-06): the codex lane 401s while its token is mid-refresh, then heals.
body = '{"error":{"message":"[codex/gpt-5.2] [401]: Provided authentication token is expired. Please try signing in again. (reset after 1m 57s)"}}'
assert not sh.classify_auth_dead(401, body)
def test_genuine_rotation_death_still_reports():
assert sh.classify_auth_dead(401, "invalid_grant: refresh token rotated")
assert sh.classify_auth_dead(403, "Unauthorized: expired credentials, please sign in")
@@ -23,7 +23,8 @@ export default function ProviderHealthToast() {
dispatch(hideProviderHealthToast());
}, [dispatch]);
const labels = dead.map((d) => d.label).join(' and ');
// Defensive dedupe: duplicate provider rows upstream once rendered "ChatGPT and ChatGPT".
const labels = Array.from(new Set(dead.map((d) => d.label))).join(' and ');
return (
<Snackbar