feat(a2a-client): surface 410 Gone as 'removed' error so callers can re-onboard (#2429)

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) <noreply@anthropic.com>
This commit is contained in:
Hongming Wang 2026-04-30 22:08:08 -07:00
parent 6dbc36d820
commit 645c1862c4
2 changed files with 71 additions and 1 deletions

View File

@ -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)}

View File

@ -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': '<exception message>'}."""
import a2a_client