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:
parent
6dbc36d820
commit
645c1862c4
@ -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)}
|
||||
|
||||
@ -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
|
||||
|
||||
Loading…
Reference in New Issue
Block a user