From d1d6e0e0e56219524aa6d4b19be40e8c5fbe7eca Mon Sep 17 00:00:00 2001 From: ciregenz Date: Thu, 6 Aug 2026 20:58:42 -0700 Subject: [PATCH] [eric] health: one banner line per provider, a 401 naming its own reset window is mid-refresh not dead --- .../apps/nine_router/subscription_health.py | 19 ++++++-- .../tests/test_subscription_health_dedupe.py | 43 +++++++++++++++++++ .../overlays/ProviderHealthToast.tsx | 3 +- 3 files changed, 60 insertions(+), 5 deletions(-) create mode 100644 backend/tests/test_subscription_health_dedupe.py diff --git a/backend/apps/nine_router/subscription_health.py b/backend/apps/nine_router/subscription_health.py index ac01ef86..62648c77 100644 --- a/backend/apps/nine_router/subscription_health.py +++ b/backend/apps/nine_router/subscription_health.py @@ -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: diff --git a/backend/tests/test_subscription_health_dedupe.py b/backend/tests/test_subscription_health_dedupe.py new file mode 100644 index 00000000..c2a83639 --- /dev/null +++ b/backend/tests/test_subscription_health_dedupe.py @@ -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") diff --git a/frontend/src/app/components/overlays/ProviderHealthToast.tsx b/frontend/src/app/components/overlays/ProviderHealthToast.tsx index a71bdeb9..e6ce37cd 100644 --- a/frontend/src/app/components/overlays/ProviderHealthToast.tsx +++ b/frontend/src/app/components/overlays/ProviderHealthToast.tsx @@ -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 (