From 645c1862c489030a711eb011ac26a2e8a4391e0a Mon Sep 17 00:00:00 2001 From: Hongming Wang Date: Thu, 30 Apr 2026 22:08:08 -0700 Subject: [PATCH] feat(a2a-client): surface 410 Gone as 'removed' error so callers can re-onboard (#2429) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Follow-up A to PR #2449 — that PR taught the platform to return 410 Gone for status='removed' workspaces; this PR teaches get_workspace_info to consume that signal. Before: every non-200 collapsed into {"error": "not found"}, which made the 2026-04-30 incident impossible to diagnose — the operator KNEW the workspace_id existed (they'd just registered it), but the runtime kept reporting "not found" for a deleted-but-not-purged row. After: 410 produces a distinct {"error": "removed", "id", "removed_at", "hint"} dict so callers (heartbeat-loop, channel bridge, dashboard tools) can surface "your workspace was deleted, re-onboard" instead of "not found". Falls back to a default hint if the platform body isn't parseable so the actionable signal doesn't depend on body shape parity. Two new tests: - TestGetWorkspaceInfo.test_410_returns_removed_with_hint - TestGetWorkspaceInfo.test_410_with_unparseable_body_falls_back_to_default_hint Co-Authored-By: Claude Opus 4.7 (1M context) --- workspace/a2a_client.py | 30 ++++++++++++++++++++- workspace/tests/test_a2a_client.py | 42 ++++++++++++++++++++++++++++++ 2 files changed, 71 insertions(+), 1 deletion(-) diff --git a/workspace/a2a_client.py b/workspace/a2a_client.py index 83ad0c89..4a9be69e 100644 --- a/workspace/a2a_client.py +++ b/workspace/a2a_client.py @@ -340,7 +340,14 @@ async def get_peers() -> list[dict]: async def get_workspace_info() -> dict: - """Get this workspace's info from the platform.""" + """Get this workspace's info from the platform. + + Distinguishes three failure shapes so callers can handle them + distinctly (#2429): + - 410 Gone → workspace was deleted; re-onboard required + - 404 / other → workspace never existed (or transient) + - exception → network / auth failure + """ async with httpx.AsyncClient(timeout=10.0) as client: try: resp = await client.get( @@ -349,6 +356,27 @@ async def get_workspace_info() -> dict: ) if resp.status_code == 200: return resp.json() + if resp.status_code == 410: + # #2429: platform returns 410 when status='removed'. + # Surface "removed" + the actionable hint so callers + # can prompt re-onboard instead of falling through to + # "not found" — which made the 2026-04-30 incident + # impossible to diagnose ("workspace not found" with + # a workspace_id we KNEW we'd just registered). + try: + body = resp.json() + except Exception: + body = {} + return { + "error": "removed", + "id": body.get("id", WORKSPACE_ID), + "removed_at": body.get("removed_at"), + "hint": body.get( + "hint", + "Workspace was deleted on the platform. " + "Regenerate workspace + token from the canvas → Tokens tab.", + ), + } return {"error": "not found"} except Exception as e: return {"error": str(e)} diff --git a/workspace/tests/test_a2a_client.py b/workspace/tests/test_a2a_client.py index 446945f9..f667ed95 100644 --- a/workspace/tests/test_a2a_client.py +++ b/workspace/tests/test_a2a_client.py @@ -819,6 +819,48 @@ class TestGetWorkspaceInfo: assert result == {"error": "not found"} + async def test_410_returns_removed_with_hint(self): + """410 Gone (#2429) → distinct error 'removed' so callers can + prompt re-onboard instead of falling through to 'not found'. + Body shape passes through removed_at + the platform hint.""" + import a2a_client + + body = { + "error": "workspace removed", + "id": "ws-deleted-uuid", + "removed_at": "2026-04-30T12:00:00Z", + "hint": "Regenerate workspace + token from the canvas → Tokens tab", + } + resp = _make_response(410, body) + mock_client = _make_mock_client(get_resp=resp) + + with patch("a2a_client.httpx.AsyncClient", return_value=mock_client): + result = await a2a_client.get_workspace_info() + + assert result["error"] == "removed" + assert result["id"] == "ws-deleted-uuid" + assert result["removed_at"] == "2026-04-30T12:00:00Z" + assert "Regenerate" in result["hint"] + + async def test_410_with_unparseable_body_falls_back_to_default_hint(self): + """If the platform's 410 body isn't JSON for some reason, the + default hint still surfaces — the actionable signal must not + depend on body shape parity with the platform.""" + import a2a_client + + resp = MagicMock() + resp.status_code = 410 + resp.json = MagicMock(side_effect=ValueError("not json")) + mock_client = _make_mock_client(get_resp=resp) + + with patch("a2a_client.httpx.AsyncClient", return_value=mock_client): + result = await a2a_client.get_workspace_info() + + assert result["error"] == "removed" + assert result["id"] == a2a_client.WORKSPACE_ID + assert result["removed_at"] is None + assert "Regenerate" in result["hint"] + async def test_exception_returns_error_dict_with_message(self): """Network exception → returns {'error': ''}.""" import a2a_client