diff --git a/mcp/app/backend_client.py b/mcp/app/backend_client.py index e260c39..4487db1 100644 --- a/mcp/app/backend_client.py +++ b/mcp/app/backend_client.py @@ -1,7 +1,50 @@ +import json + import httpx + from .config import settings +class BackendError(RuntimeError): + """A non-2xx answer from the backend, carrying the reason it gave. + + httpx's own error text stops at the status line and the URL, so FastAPI's + `{"detail": ...}` — the half that says what to do about it — never reached + the calling client. A rejected connection point, a duplicate device, a + missing design: all of them arrived as a bare '422 Unprocessable Content'. + """ + + def __init__(self, method: str, path: str, status_code: int, detail: str): + self.status_code = status_code + self.detail = detail + suffix = f": {detail}" if detail else "" + super().__init__(f"{method} {path} failed with {status_code}{suffix}") + + +# Enough for a FastAPI detail — a structured one included — without pasting a +# whole HTML error page into the client's context. +_MAX_DETAIL = 500 + + +def _detail_of(resp: httpx.Response) -> str: + """The backend's explanation for a failed response, as a single string. + + `detail` is a string for most errors and an object for the structured ones + (the approve-duplicate prompt); anything else falls back to the raw body. + """ + try: + payload = resp.json() + except ValueError: + text = resp.text.strip() + else: + detail = payload.get("detail") if isinstance(payload, dict) else None + if detail is None: + text = json.dumps(payload) + else: + text = detail if isinstance(detail, str) else json.dumps(detail) + return text[:_MAX_DETAIL] + + class BackendClient: def __init__(self): self._client: httpx.AsyncClient | None = None @@ -19,7 +62,8 @@ class BackendClient: async def request(self, method: str, path: str, **kwargs) -> dict: resp = await self._client.request(method, path, **kwargs) - resp.raise_for_status() + if resp.is_error: + raise BackendError(method, path, resp.status_code, _detail_of(resp)) if resp.status_code == 204: return {} return resp.json() diff --git a/mcp/tests/test_backend_client.py b/mcp/tests/test_backend_client.py new file mode 100644 index 0000000..563bfc9 --- /dev/null +++ b/mcp/tests/test_backend_client.py @@ -0,0 +1,92 @@ +"""The backend's own explanation must survive the trip to the MCP client. + +httpx's error text stops at the status line, so a rejected call used to reach the +caller as a bare "Client error '422 Unprocessable Content'" — the reason, which is +the only part that says what to do next, was dropped. +""" + +import httpx +import pytest + +from app.backend_client import BackendClient, BackendError + + +def _client_answering(response: httpx.Response) -> BackendClient: + backend = BackendClient() + backend._client = httpx.AsyncClient( + base_url="http://backend", + transport=httpx.MockTransport(lambda _request: response), + ) + return backend + + +@pytest.mark.anyio +async def test_a_string_detail_reaches_the_caller(): + detail = "source_handle 'right-2' does not exist: node n1 has 0 connection point(s) on its right side." + backend = _client_answering(httpx.Response(422, json={"detail": detail})) + + with pytest.raises(BackendError) as exc: + await backend.post("/api/v1/edges", {}) + + assert detail in str(exc.value) + assert "422" in str(exc.value) + assert exc.value.status_code == 422 + assert exc.value.detail == detail + + +@pytest.mark.anyio +async def test_a_structured_detail_is_serialized(): + # The approve-duplicate prompt answers with an object, not a string. + backend = _client_answering(httpx.Response(409, json={"detail": {"code": "duplicate", "node_id": "n1"}})) + + with pytest.raises(BackendError) as exc: + await backend.post("/api/v1/scan/pending/d1/approve", {}) + + assert "duplicate" in str(exc.value) + assert "n1" in str(exc.value) + + +@pytest.mark.anyio +async def test_a_non_json_body_falls_back_to_its_text(): + backend = _client_answering(httpx.Response(500, text="Internal Server Error")) + + with pytest.raises(BackendError) as exc: + await backend.get("/api/v1/nodes") + + assert "Internal Server Error" in str(exc.value) + + +@pytest.mark.anyio +async def test_the_method_and_path_are_named(): + backend = _client_answering(httpx.Response(404, json={"detail": "Edge not found"})) + + with pytest.raises(BackendError) as exc: + await backend.patch("/api/v1/edges/e1", {}) + + assert "PATCH" in str(exc.value) + assert "/api/v1/edges/e1" in str(exc.value) + + +@pytest.mark.anyio +async def test_a_long_detail_is_truncated(): + # An HTML error page must not land whole in the client's context. + backend = _client_answering(httpx.Response(500, text="x" * 5000)) + + with pytest.raises(BackendError) as exc: + await backend.get("/api/v1/nodes") + + assert len(exc.value.detail) == 500 + + +@pytest.mark.anyio +async def test_a_204_still_returns_an_empty_dict(): + backend = _client_answering(httpx.Response(204)) + + assert await backend.delete("/api/v1/edges/e1") == {} + + +@pytest.mark.anyio +async def test_a_success_is_returned_unchanged(): + backend = _client_answering(httpx.Response(200, json={"id": "e1"})) + + assert await backend.get("/api/v1/edges/e1") == {"id": "e1"}