From 9e321c7c05f5fc5d7673eac603a5751ab479c51d Mon Sep 17 00:00:00 2001 From: Eric Mey Date: Thu, 7 May 2026 22:58:22 -0400 Subject: [PATCH 1/2] fix(sdk): handle empty/non-JSON gateway responses in post_agent_hook MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The hook client called `response.json(content_type=None)` before checking status, so any empty 200 (or 5xx with no body) raised an uncaught `JSONDecodeError`. Observed in production as a LiveKit tool exception on `openclaw_delegate` calls when the gateway returned an empty 200. Read the body as text, check status first, then parse JSON safely: - empty body → `OpenClawHookError("...empty response body")` - non-2xx → `OpenClawHookError("...status N: ...")` with body snippet - invalid JSON → `OpenClawHookError("...invalid JSON: ...")` Adds three test cases covering the new branches; existing behavior preserved for happy path and 4xx-with-error-body rejection. Closes #27 Co-Authored-By: Claude Opus 4.7 (1M context) --- sdk/src/sdk/openclaw_hooks.py | 19 ++++++++-- sdk/tests/test_openclaw_hooks.py | 59 ++++++++++++++++++++++++++++++++ 2 files changed, 75 insertions(+), 3 deletions(-) diff --git a/sdk/src/sdk/openclaw_hooks.py b/sdk/src/sdk/openclaw_hooks.py index 22e4826..8a96852 100644 --- a/sdk/src/sdk/openclaw_hooks.py +++ b/sdk/src/sdk/openclaw_hooks.py @@ -7,6 +7,7 @@ from __future__ import annotations +import json import logging import os from dataclasses import dataclass @@ -110,16 +111,28 @@ async def post_agent_hook( try: async with aiohttp.ClientSession(timeout=timeout) as session: async with session.post(cfg.agent_url, json=payload, headers=headers) as response: - body = await response.json(content_type=None) + status = response.status + reason = response.reason + text = await response.text() except TimeoutError as err: raise OpenClawHookError("OpenClaw hook request timed out") from err except aiohttp.ClientError as err: raise OpenClawHookError(f"OpenClaw hook request failed: {err}") from err + if not 200 <= status < 300: + snippet = text.strip()[:200] + detail = snippet or reason or "no body" + raise OpenClawHookError(f"OpenClaw hook returned status {status}: {detail}") + if not text.strip(): + raise OpenClawHookError("OpenClaw hook returned empty response body") + try: + body = json.loads(text) + except ValueError as err: + raise OpenClawHookError(f"OpenClaw hook returned invalid JSON: {err}") from err if not isinstance(body, dict): raise OpenClawHookError("OpenClaw hook returned a non-object response") - if response.status < 200 or response.status >= 300 or body.get("ok") is not True: - error = body.get("error") if isinstance(body.get("error"), str) else response.reason + if body.get("ok") is not True: + error = body.get("error") if isinstance(body.get("error"), str) else reason raise OpenClawHookError(f"OpenClaw hook rejected the request: {error}") run_id = body.get("runId") diff --git a/sdk/tests/test_openclaw_hooks.py b/sdk/tests/test_openclaw_hooks.py index 0edaff7..faebc4c 100644 --- a/sdk/tests/test_openclaw_hooks.py +++ b/sdk/tests/test_openclaw_hooks.py @@ -105,3 +105,62 @@ async def handler(request: web.Request) -> web.Response: await post_agent_hook(agent_id="yumi", message="Do it", name="Voice") finally: await runner.cleanup() + + +async def _serve(handler): + app = web.Application() + app.router.add_post("/hooks/agent", handler) + runner = web.AppRunner(app) + await runner.setup() + port = _free_port() + site = web.TCPSite(runner, "127.0.0.1", port) + await site.start() + return runner, port + + +@pytest.mark.asyncio +async def test_post_agent_hook_rejects_empty_success_body(monkeypatch): + async def handler(_request: web.Request) -> web.Response: + return web.Response(status=200, body=b"") + + runner, port = await _serve(handler) + monkeypatch.setenv("OPENCLAW_HOOK_TOKEN", "secret") + monkeypatch.setenv("OPENCLAW_GATEWAY_HTTP_URL", f"http://127.0.0.1:{port}") + + try: + with pytest.raises(OpenClawHookError, match="empty response body"): + await post_agent_hook(agent_id="yumi", message="Do it", name="Voice") + finally: + await runner.cleanup() + + +@pytest.mark.asyncio +async def test_post_agent_hook_rejects_invalid_json_success_body(monkeypatch): + async def handler(_request: web.Request) -> web.Response: + return web.Response(status=200, body=b"not json", content_type="application/json") + + runner, port = await _serve(handler) + monkeypatch.setenv("OPENCLAW_HOOK_TOKEN", "secret") + monkeypatch.setenv("OPENCLAW_GATEWAY_HTTP_URL", f"http://127.0.0.1:{port}") + + try: + with pytest.raises(OpenClawHookError, match="invalid JSON"): + await post_agent_hook(agent_id="yumi", message="Do it", name="Voice") + finally: + await runner.cleanup() + + +@pytest.mark.asyncio +async def test_post_agent_hook_surfaces_5xx_with_empty_body(monkeypatch): + async def handler(_request: web.Request) -> web.Response: + return web.Response(status=503, body=b"") + + runner, port = await _serve(handler) + monkeypatch.setenv("OPENCLAW_HOOK_TOKEN", "secret") + monkeypatch.setenv("OPENCLAW_GATEWAY_HTTP_URL", f"http://127.0.0.1:{port}") + + try: + with pytest.raises(OpenClawHookError, match="status 503"): + await post_agent_hook(agent_id="yumi", message="Do it", name="Voice") + finally: + await runner.cleanup() From 14b8b00aaa9a56455e8614209ee7881e56559a7f Mon Sep 17 00:00:00 2001 From: Eric Mey Date: Thu, 7 May 2026 23:12:40 -0400 Subject: [PATCH 2/2] fix(sdk): tolerate undecodable response bytes; harden test port binding MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Codex review on #28: P2 — `await response.text()` used strict UTF-8 decoding, so a 5xx with malformed bytes (proxy garbage, partial gzip frame, etc.) raised `UnicodeDecodeError` past our handlers — same crash class we fixed for JSONDecodeError. Switched to `response.text(errors="replace")` so undecodable bytes surface as a normal `OpenClawHookError` with the status code. Adds a regression test using deliberately invalid UTF-8. P3 — `_free_port()` opened, closed, then handed the port to TCPSite, opening a race where another process could grab the freed port between calls. Replaced with TCPSite-binds-port-0 + read-back-from-socket via a single `_serve(handler)` helper; migrated the two pre-existing tests to use it. Drops the `socket` import. Co-Authored-By: Claude Opus 4.7 (1M context) --- sdk/src/sdk/openclaw_hooks.py | 2 +- sdk/tests/test_openclaw_hooks.py | 65 +++++++++++++++----------------- 2 files changed, 32 insertions(+), 35 deletions(-) diff --git a/sdk/src/sdk/openclaw_hooks.py b/sdk/src/sdk/openclaw_hooks.py index 8a96852..b6b48b7 100644 --- a/sdk/src/sdk/openclaw_hooks.py +++ b/sdk/src/sdk/openclaw_hooks.py @@ -113,7 +113,7 @@ async def post_agent_hook( async with session.post(cfg.agent_url, json=payload, headers=headers) as response: status = response.status reason = response.reason - text = await response.text() + text = await response.text(errors="replace") except TimeoutError as err: raise OpenClawHookError("OpenClaw hook request timed out") from err except aiohttp.ClientError as err: diff --git a/sdk/tests/test_openclaw_hooks.py b/sdk/tests/test_openclaw_hooks.py index faebc4c..4f2d7a1 100644 --- a/sdk/tests/test_openclaw_hooks.py +++ b/sdk/tests/test_openclaw_hooks.py @@ -2,8 +2,6 @@ from __future__ import annotations -import socket - import pytest from aiohttp import web from sdk.openclaw_hooks import ( @@ -14,10 +12,16 @@ ) -def _free_port() -> int: - with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock: - sock.bind(("127.0.0.1", 0)) - return int(sock.getsockname()[1]) +async def _serve(handler): + """Start an aiohttp test server on an OS-assigned port and return (runner, port).""" + app = web.Application() + app.router.add_post("/hooks/agent", handler) + runner = web.AppRunner(app) + await runner.setup() + site = web.TCPSite(runner, "127.0.0.1", 0) + await site.start() + port = site._server.sockets[0].getsockname()[1] + return runner, port def test_hook_config_requires_dedicated_token(monkeypatch): @@ -48,14 +52,7 @@ async def handler(request: web.Request) -> web.Response: seen["body"] = await request.json() return web.json_response({"ok": True, "runId": "run-123"}) - app = web.Application() - app.router.add_post("/hooks/agent", handler) - runner = web.AppRunner(app) - await runner.setup() - port = _free_port() - site = web.TCPSite(runner, "127.0.0.1", port) - await site.start() - + runner, port = await _serve(handler) monkeypatch.setenv("OPENCLAW_HOOK_TOKEN", "secret") monkeypatch.setenv("OPENCLAW_GATEWAY_HTTP_URL", f"http://127.0.0.1:{port}") @@ -86,17 +83,10 @@ async def handler(request: web.Request) -> web.Response: @pytest.mark.asyncio async def test_post_agent_hook_surfaces_rejection(monkeypatch): - async def handler(request: web.Request) -> web.Response: + async def handler(_request: web.Request) -> web.Response: return web.json_response({"ok": False, "error": "denied"}, status=400) - app = web.Application() - app.router.add_post("/hooks/agent", handler) - runner = web.AppRunner(app) - await runner.setup() - port = _free_port() - site = web.TCPSite(runner, "127.0.0.1", port) - await site.start() - + runner, port = await _serve(handler) monkeypatch.setenv("OPENCLAW_HOOK_TOKEN", "secret") monkeypatch.setenv("OPENCLAW_GATEWAY_HTTP_URL", f"http://127.0.0.1:{port}") @@ -107,17 +97,6 @@ async def handler(request: web.Request) -> web.Response: await runner.cleanup() -async def _serve(handler): - app = web.Application() - app.router.add_post("/hooks/agent", handler) - runner = web.AppRunner(app) - await runner.setup() - port = _free_port() - site = web.TCPSite(runner, "127.0.0.1", port) - await site.start() - return runner, port - - @pytest.mark.asyncio async def test_post_agent_hook_rejects_empty_success_body(monkeypatch): async def handler(_request: web.Request) -> web.Response: @@ -164,3 +143,21 @@ async def handler(_request: web.Request) -> web.Response: await post_agent_hook(agent_id="yumi", message="Do it", name="Voice") finally: await runner.cleanup() + + +@pytest.mark.asyncio +async def test_post_agent_hook_handles_undecodable_body(monkeypatch): + """A 5xx with bytes that aren't valid UTF-8 must surface as OpenClawHookError, not UnicodeDecodeError.""" + + async def handler(_request: web.Request) -> web.Response: + return web.Response(status=500, body=b"\xff\xfe\xfd garbage \x80\x81") + + runner, port = await _serve(handler) + monkeypatch.setenv("OPENCLAW_HOOK_TOKEN", "secret") + monkeypatch.setenv("OPENCLAW_GATEWAY_HTTP_URL", f"http://127.0.0.1:{port}") + + try: + with pytest.raises(OpenClawHookError, match="status 500"): + await post_agent_hook(agent_id="yumi", message="Do it", name="Voice") + finally: + await runner.cleanup()