Skip to content

Commit 551cb98

Browse files
fix: validate cluster-info and health response shape with ServerError
assert isinstance(...) silently disappeared under python -O and produced a bare AssertionError otherwise, so a malformed cluster-info manifest would either crash without context or let the worker proceed with an unchecked non-dict response. Both get_cluster_info() and health() now raise ServerError with a stable reason (invalid_cluster_info / invalid_health_response) when the wire shape isn't a JSON object. Closes TD-P010. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent ce0f2c4 commit 551cb98

2 files changed

Lines changed: 63 additions & 2 deletions

File tree

src/durable_workflow/client.py

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010

1111
from . import serializer
1212
from .errors import (
13+
ServerError,
1314
WorkflowCancelled,
1415
WorkflowFailed,
1516
WorkflowTerminated,
@@ -314,7 +315,11 @@ async def _do_request() -> httpx.Response:
314315
async def get_cluster_info(self) -> dict[str, Any]:
315316
"""Fetch server version and capabilities from /api/cluster/info."""
316317
result = await self._request("GET", "/cluster/info", worker=False, context="get_cluster_info")
317-
assert isinstance(result, dict)
318+
if not isinstance(result, dict):
319+
raise ServerError(
320+
200,
321+
{"reason": "invalid_cluster_info", "message": f"expected JSON object, got {type(result).__name__}"},
322+
)
318323
return result
319324

320325
def get_workflow_handle(
@@ -325,7 +330,11 @@ def get_workflow_handle(
325330
# ── Health ─────────────────────────────────────────────────────────
326331
async def health(self) -> dict[str, Any]:
327332
result = await self._request("GET", "/health")
328-
assert isinstance(result, dict)
333+
if not isinstance(result, dict):
334+
raise ServerError(
335+
200,
336+
{"reason": "invalid_health_response", "message": f"expected JSON object, got {type(result).__name__}"},
337+
)
329338
return result
330339

331340
# ── Workflows ──────────────────────────────────────────────────────

tests/test_client.py

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -373,3 +373,55 @@ async def test_register_honors_explicit_sdk_version_override(self, client: Clien
373373
)
374374
body = mock.call_args.kwargs.get("json") or mock.call_args[1].get("json")
375375
assert body["sdk_version"] == "custom-runtime/9.9.9"
376+
377+
378+
class TestGetClusterInfo:
379+
@pytest.mark.asyncio
380+
async def test_returns_dict_payload(self, client: Client) -> None:
381+
payload = {"version": "2.0.0", "capabilities": {"workflow": True}}
382+
resp = _mock_response(200, payload)
383+
with patch.object(client._http, "request", new_callable=AsyncMock, return_value=resp):
384+
info = await client.get_cluster_info()
385+
assert info == payload
386+
387+
@pytest.mark.asyncio
388+
async def test_accepts_empty_dict(self, client: Client) -> None:
389+
resp = _mock_response(200, {})
390+
with patch.object(client._http, "request", new_callable=AsyncMock, return_value=resp):
391+
info = await client.get_cluster_info()
392+
assert info == {}
393+
394+
@pytest.mark.asyncio
395+
async def test_rejects_list_payload(self, client: Client) -> None:
396+
resp = httpx.Response(200, content=b"[]", headers={"content-type": "application/json"},
397+
request=httpx.Request("GET", "http://test"))
398+
with patch.object(client._http, "request", new_callable=AsyncMock, return_value=resp):
399+
with pytest.raises(ServerError) as exc:
400+
await client.get_cluster_info()
401+
assert exc.value.reason() == "invalid_cluster_info"
402+
403+
@pytest.mark.asyncio
404+
async def test_rejects_string_payload(self, client: Client) -> None:
405+
resp = httpx.Response(200, content=b'"oops"', headers={"content-type": "application/json"},
406+
request=httpx.Request("GET", "http://test"))
407+
with patch.object(client._http, "request", new_callable=AsyncMock, return_value=resp):
408+
with pytest.raises(ServerError) as exc:
409+
await client.get_cluster_info()
410+
assert exc.value.reason() == "invalid_cluster_info"
411+
412+
413+
class TestHealth:
414+
@pytest.mark.asyncio
415+
async def test_returns_dict_payload(self, client: Client) -> None:
416+
resp = _mock_response(200, {"status": "ok"})
417+
with patch.object(client._http, "request", new_callable=AsyncMock, return_value=resp):
418+
assert await client.health() == {"status": "ok"}
419+
420+
@pytest.mark.asyncio
421+
async def test_rejects_non_dict(self, client: Client) -> None:
422+
resp = httpx.Response(200, content=b"true", headers={"content-type": "application/json"},
423+
request=httpx.Request("GET", "http://test"))
424+
with patch.object(client._http, "request", new_callable=AsyncMock, return_value=resp):
425+
with pytest.raises(ServerError) as exc:
426+
await client.health()
427+
assert exc.value.reason() == "invalid_health_response"

0 commit comments

Comments
 (0)