From 6ead3b433ee00fc6ade886f5c0863ed031d7f8ab Mon Sep 17 00:00:00 2001 From: rabbitblood Date: Fri, 24 Apr 2026 11:22:57 -0700 Subject: [PATCH 1/2] fix(a2a): include exception class + error code in [A2A_ERROR] (#51) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When an exception's str() is empty (bare TimeoutError(), BrokenPipeError(), some httpx transport errors) `f"{_A2A_ERROR_PREFIX}{e}"` produced `"[A2A_ERROR] "` with a trailing space and zero diagnostic context, masking the real cause of peer-delegation failures in activity_logs. Observed on main monorepo: 22+ occurrences in 75 min across 7 leads during the MiniMax M2.7 trial rate-limit episode — zero breadcrumbs to route the debug from. Fix: - Exception branch: fall back to `type(e).__name__` when str(e) is empty - Error branch: include JSON-RPC `error.code` alongside message when present Tests: test_a2a_error_observability.py covers both the bare-exception path (must surface class name) and the message-passthrough path (must preserve existing useful messages). Co-Authored-By: Claude Opus 4.7 (1M context) --- molecule_runtime/a2a_client.py | 14 ++++- tests/test_a2a_error_observability.py | 78 +++++++++++++++++++++++++++ 2 files changed, 90 insertions(+), 2 deletions(-) create mode 100644 tests/test_a2a_error_observability.py diff --git a/molecule_runtime/a2a_client.py b/molecule_runtime/a2a_client.py index 3b7882c..76fb205 100644 --- a/molecule_runtime/a2a_client.py +++ b/molecule_runtime/a2a_client.py @@ -76,10 +76,20 @@ async def send_a2a_message(target_url: str, message: str) -> str: return f"{_A2A_ERROR_PREFIX}{text}" return text elif "error" in data: - return f"{_A2A_ERROR_PREFIX}{data['error'].get('message', 'unknown')}" + err = data["error"] + msg = err.get("message") or "unknown" + code = err.get("code") + if code is not None: + return f"{_A2A_ERROR_PREFIX}[code={code}] {msg}" + return f"{_A2A_ERROR_PREFIX}{msg}" return str(data) except Exception as e: - return f"{_A2A_ERROR_PREFIX}{e}" + # #51: str(e) is empty for bare TimeoutError(), BrokenPipeError(), + # and several httpx transport errors — leaving "[A2A_ERROR] " with + # no diagnostic. Fall back to the exception class name so logs + # always carry at least one actionable breadcrumb. + detail = str(e) or type(e).__name__ + return f"{_A2A_ERROR_PREFIX}{detail}" async def get_peers() -> list[dict]: diff --git a/tests/test_a2a_error_observability.py b/tests/test_a2a_error_observability.py new file mode 100644 index 0000000..449069e --- /dev/null +++ b/tests/test_a2a_error_observability.py @@ -0,0 +1,78 @@ +"""Regression tests for issue #51. + +The `[A2A_ERROR]` prefix from ``send_a2a_message`` must always carry a +diagnostic suffix. Before #51 an exception whose ``str(e)`` was empty +(bare ``TimeoutError()``, ``BrokenPipeError()``, several httpx transport +errors) produced ``"[A2A_ERROR] "`` with a trailing space and zero +context, masking the real cause of peer-delegation failures. +""" + +from __future__ import annotations + +import os +import sys + +import pytest + +# Set WORKSPACE_ID before importing molecule_runtime modules — platform_auth +# evaluates it at import time and refuses to load otherwise. +os.environ.setdefault("WORKSPACE_ID", "test-workspace") + +from molecule_runtime.a2a_client import _A2A_ERROR_PREFIX # noqa: E402 +from molecule_runtime import a2a_client # noqa: E402 + + +class _BareException(Exception): + """Exception whose str() is empty — mimics bare TimeoutError().""" + + def __str__(self) -> str: # noqa: D401 + return "" + + +class _StubAsyncClient: + """Async context manager that raises a supplied exception on .post().""" + + def __init__(self, exc: BaseException) -> None: + self._exc = exc + + async def __aenter__(self) -> "_StubAsyncClient": + return self + + async def __aexit__(self, *_exc) -> bool: + return False + + async def post(self, *_args, **_kwargs): + raise self._exc + + +@pytest.mark.asyncio +async def test_bare_exception_yields_class_name(monkeypatch): + """When str(e) is empty the result must still include the exception class.""" + + def _factory(*_a, **_kw): + return _StubAsyncClient(_BareException()) + + monkeypatch.setattr(a2a_client.httpx, "AsyncClient", _factory) + monkeypatch.setattr(a2a_client, "PLATFORM_URL", "http://stub") + monkeypatch.setattr(a2a_client, "auth_headers", lambda: {}) + + result = await a2a_client.send_a2a_message("peer-ws-id", "hi") + assert result.startswith(_A2A_ERROR_PREFIX) + suffix = result[len(_A2A_ERROR_PREFIX):] + assert suffix.strip() != "", f"expected non-empty suffix, got {result!r}" + assert "BareException" in suffix + + +@pytest.mark.asyncio +async def test_exception_with_message_passes_through(monkeypatch): + """Regular exception messages are preserved.""" + + def _factory(*_a, **_kw): + return _StubAsyncClient(RuntimeError("upstream 429")) + + monkeypatch.setattr(a2a_client.httpx, "AsyncClient", _factory) + monkeypatch.setattr(a2a_client, "PLATFORM_URL", "http://stub") + monkeypatch.setattr(a2a_client, "auth_headers", lambda: {}) + + result = await a2a_client.send_a2a_message("peer-ws-id", "hi") + assert result == f"{_A2A_ERROR_PREFIX}upstream 429" From 4940abdc685b5ffe90f459e778fd20a462642889 Mon Sep 17 00:00:00 2001 From: rabbitblood Date: Fri, 24 Apr 2026 11:34:30 -0700 Subject: [PATCH 2/2] fix(tests): remove pytest-asyncio dependency from #51 regression tests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CI does not install pytest-asyncio — follow test_shared_runtime.py's _run(coro) helper pattern. Tests still cover the same two paths (bare exception class-name fallback + message passthrough) but no longer require the async pytest plugin. Co-Authored-By: Claude Opus 4.7 (1M context) --- tests/test_a2a_error_observability.py | 28 +++++++++++++++++---------- 1 file changed, 18 insertions(+), 10 deletions(-) diff --git a/tests/test_a2a_error_observability.py b/tests/test_a2a_error_observability.py index 449069e..3df8104 100644 --- a/tests/test_a2a_error_observability.py +++ b/tests/test_a2a_error_observability.py @@ -5,21 +5,31 @@ diagnostic suffix. Before #51 an exception whose ``str(e)`` was empty (bare ``TimeoutError()``, ``BrokenPipeError()``, several httpx transport errors) produced ``"[A2A_ERROR] "`` with a trailing space and zero context, masking the real cause of peer-delegation failures. + +CI does not install pytest-asyncio — use the local _run helper pattern +established in test_shared_runtime.py. """ from __future__ import annotations +import asyncio import os -import sys - -import pytest # Set WORKSPACE_ID before importing molecule_runtime modules — platform_auth # evaluates it at import time and refuses to load otherwise. os.environ.setdefault("WORKSPACE_ID", "test-workspace") -from molecule_runtime.a2a_client import _A2A_ERROR_PREFIX # noqa: E402 from molecule_runtime import a2a_client # noqa: E402 +from molecule_runtime.a2a_client import _A2A_ERROR_PREFIX # noqa: E402 + + +def _run(coro): + """Run an async coroutine synchronously (no pytest-asyncio available).""" + loop = asyncio.new_event_loop() + try: + return loop.run_until_complete(coro) + finally: + loop.close() class _BareException(Exception): @@ -45,8 +55,7 @@ class _StubAsyncClient: raise self._exc -@pytest.mark.asyncio -async def test_bare_exception_yields_class_name(monkeypatch): +def test_bare_exception_yields_class_name(monkeypatch): """When str(e) is empty the result must still include the exception class.""" def _factory(*_a, **_kw): @@ -56,15 +65,14 @@ async def test_bare_exception_yields_class_name(monkeypatch): monkeypatch.setattr(a2a_client, "PLATFORM_URL", "http://stub") monkeypatch.setattr(a2a_client, "auth_headers", lambda: {}) - result = await a2a_client.send_a2a_message("peer-ws-id", "hi") + result = _run(a2a_client.send_a2a_message("peer-ws-id", "hi")) assert result.startswith(_A2A_ERROR_PREFIX) suffix = result[len(_A2A_ERROR_PREFIX):] assert suffix.strip() != "", f"expected non-empty suffix, got {result!r}" assert "BareException" in suffix -@pytest.mark.asyncio -async def test_exception_with_message_passes_through(monkeypatch): +def test_exception_with_message_passes_through(monkeypatch): """Regular exception messages are preserved.""" def _factory(*_a, **_kw): @@ -74,5 +82,5 @@ async def test_exception_with_message_passes_through(monkeypatch): monkeypatch.setattr(a2a_client, "PLATFORM_URL", "http://stub") monkeypatch.setattr(a2a_client, "auth_headers", lambda: {}) - result = await a2a_client.send_a2a_message("peer-ws-id", "hi") + result = _run(a2a_client.send_a2a_message("peer-ws-id", "hi")) assert result == f"{_A2A_ERROR_PREFIX}upstream 429"