From 94b07588dda8e1306a9bd82af67e1e311ea1d66a Mon Sep 17 00:00:00 2001 From: carlotestor Date: Wed, 23 Sep 2026 17:08:31 +0000 Subject: [PATCH] fix(gateway): resolve reattach URL and key as one pair; fail fast on 401/403 Drop the persisted base_url: pairing a saved URL with the profile's current key could poll the old gateway with a new credential. Both now come from the session profile's current config, and an auth rejection settles the turn at once with an explicit error instead of 150 retries. --- api/gateway_chat.py | 14 +++++------ ...test_gateway_run_reattach_after_restart.py | 24 +++++++++++-------- 2 files changed, 20 insertions(+), 18 deletions(-) diff --git a/api/gateway_chat.py b/api/gateway_chat.py index f18ee584..0ae1d5c1 100644 --- a/api/gateway_chat.py +++ b/api/gateway_chat.py @@ -895,6 +895,8 @@ def _await_gateway_run_result( except urllib.error.HTTPError as exc: if exc.code == 404: raise RuntimeError("Gateway no longer has this run; its result could not be recovered after the WebUI restart.") from exc + if exc.code in (401, 403): + raise RuntimeError(f"Gateway rejected the WebUI credentials (HTTP {exc.code}) while reattaching to a run after the restart.") from exc failures += 1 status = None except (urllib.error.URLError, OSError, ValueError): @@ -982,18 +984,15 @@ def _sidecars_with_active_stream(session_dir) -> list[str]: return ids -def _gateway_endpoint_for_reattach(session, run: dict) -> tuple[str, str]: - """Resolve the gateway that owns ``run`` using the session's profile, not the process default.""" +def _gateway_endpoint_for_reattach(session) -> tuple[str, str]: + """Resolve gateway URL and key together from the session's profile, never the process default.""" from api import profiles as _profiles from api.config import get_config with _profiles.profile_scope_for_detached_worker( getattr(session, "profile", None), "gateway reattach", logger_override=logger, ): - base_url = _gateway_base_url(get_config()) - api_key = _gateway_api_key() - # The run lives on the gateway that accepted it, even if config changed since. - return str(run.get("base_url") or base_url).rstrip("/"), api_key + return _gateway_base_url(get_config()), _gateway_api_key() def _resume_gateway_run_for_session(session) -> bool: @@ -1005,7 +1004,7 @@ def _resume_gateway_run_for_session(session) -> bool: if not stream_id or not run_id or run.get("stream_id") != stream_id: return False sid = session.session_id - endpoint = _gateway_endpoint_for_reattach(session, run) + endpoint = _gateway_endpoint_for_reattach(session) with STREAMS_LOCK: if stream_id in STREAMS: return False @@ -1318,7 +1317,6 @@ def _run_gateway_chat_streaming( active_provider=(model_provider or ""), on_run_id=lambda run_id: _record_gateway_run( session_id, stream_id, run_id, - base_url=base_url, regeneration=bool(regeneration), goal_related=bool(goal_related), ), diff --git a/tests/test_gateway_run_reattach_after_restart.py b/tests/test_gateway_run_reattach_after_restart.py index 0ddf872a..9597ce46 100644 --- a/tests/test_gateway_run_reattach_after_restart.py +++ b/tests/test_gateway_run_reattach_after_restart.py @@ -113,7 +113,7 @@ def test_runs_api_start_sends_idempotency_key_and_persists_run_id(isolated_sessi assert captured["post_headers"]["Idempotency-key"] == f"webui-{stream_id}" assert captured["persisted_at_events"]["run_id"] == "run_live" assert captured["persisted_at_events"]["stream_id"] == stream_id - assert captured["persisted_at_events"]["base_url"] == "http://gateway.local" + assert "api_key" not in captured["persisted_at_events"] saved = json.loads((isolated_sessions / f"{s.session_id}.json").read_text()) assert saved["gateway_run"] is None assert saved["active_stream_id"] is None @@ -348,16 +348,20 @@ def test_reattach_resolves_gateway_from_the_session_profile(isolated_sessions, m assert saved["messages"][-1]["content"] == "answer" -def test_reattach_polls_the_gateway_that_accepted_the_run(isolated_sessions, monkeypatch): - sid, stream_id = _orphaned_gateway_turn() - s = models.Session.load(sid) - s.gateway_run = {**s.gateway_run, "base_url": "http://accepted-gateway:8642/"} - s.save(touch_updated_at=False) - models.SESSIONS.clear() - seen = [] - _poll_until_completed(monkeypatch, seen) +@pytest.mark.parametrize("code", [401, 403]) +def test_reattach_auth_rejection_fails_fast_with_an_auth_error(isolated_sessions, monkeypatch, code): + sid, _stream_id = _orphaned_gateway_turn() + calls = [] + def rejected(base_url, api_key, run_id): + calls.append(run_id) + raise urllib.error.HTTPError("http://gateway.local/v1/runs/x", code, "denied", Message(), io.BytesIO(b"")) + + monkeypatch.setattr(gateway_chat, "_get_gateway_run_status", rejected) gateway_chat.resume_gateway_runs_after_restart() _wait_for_reattach_threads() - assert {b for b, _k, _r in seen} == {"http://accepted-gateway:8642"} + assert len(calls) == 1 + saved = json.loads((isolated_sessions / f"{sid}.json").read_text()) + assert saved["active_stream_id"] is None and saved["gateway_run"] is None + assert f"HTTP {code}" in json.dumps(saved["messages"][-1])