diff --git a/src/backend/services/vision_service.py b/src/backend/services/vision_service.py index 567351d..e9fa85e 100644 --- a/src/backend/services/vision_service.py +++ b/src/backend/services/vision_service.py @@ -26,12 +26,14 @@ async def _call_worker(image_bytes: bytes, graph: dict) -> dict: The worker's fault must not become the API's fault (spec's edge-case table: "il worker isola il guasto, l'API resta in piedi, il task - riporta l'errore"). A stopped worker raises `httpx.ConnectError`; a job - past the timeout raises `httpx.ReadTimeout` - both would otherwise - propagate past this function and surface to the operator as a naked - 500. The response shape is validated here too: a proxy answering 200 - with something that is not the worker's JSON contract must not turn - into a bare KeyError further down. + riporta l'errore"). httpx.TimeoutException (ConnectTimeout, ReadTimeout, + WriteTimeout, PoolTimeout) becomes 504; every other transport failure + (ConnectError, ReadError, RemoteProtocolError when the worker dies + mid-response, ...) becomes 502 - both would otherwise propagate past + this function and surface to the operator as a naked 500. The response + shape is validated here too: a proxy answering 200 with something that + is not the worker's JSON contract must not turn into a bare KeyError + further down. """ try: async with httpx.AsyncClient(timeout=120.0) as client: @@ -40,16 +42,28 @@ async def _call_worker(image_bytes: bytes, graph: dict) -> dict: files={"image": ("frame.png", image_bytes, "image/png")}, data={"graph": json.dumps(graph)}, ) - except httpx.ConnectError as exc: - raise HTTPException( - status_code=status.HTTP_502_BAD_GATEWAY, - detail=f"the vision worker is unreachable: {exc}", - ) from exc - except httpx.ReadTimeout as exc: + except httpx.TimeoutException as exc: raise HTTPException( status_code=status.HTTP_504_GATEWAY_TIMEOUT, detail=f"the vision worker did not answer in time: {exc}", ) from exc + except (httpx.TransportError, httpx.HTTPError) as exc: + raise HTTPException( + status_code=status.HTTP_502_BAD_GATEWAY, + detail=f"the vision worker is unreachable: {exc}", + ) from exc + + if response.status_code == status.HTTP_422_UNPROCESSABLE_CONTENT: + # The worker already diagnosed a bad request (undecodable image, + # malformed graph) - forward its reason instead of flattening it + # into a generic 502. + try: + detail = response.json().get("detail", response.text) + except ValueError: + detail = response.text + raise HTTPException( + status_code=status.HTTP_422_UNPROCESSABLE_CONTENT, detail=detail, + ) if response.status_code != 200: raise HTTPException( diff --git a/src/backend/tests/test_vision_worker_transport.py b/src/backend/tests/test_vision_worker_transport.py index 0365c5b..63a99de 100644 --- a/src/backend/tests/test_vision_worker_transport.py +++ b/src/backend/tests/test_vision_worker_transport.py @@ -115,3 +115,75 @@ async def test_a_well_formed_response_still_passes_through(monkeypatch): report = await _call_worker(b"img", {"schema_version": 1, "tools": []}) assert report == body + + +# --------------------------------------------------------------------------- +# #14: the worker dying mid-response (RemoteProtocolError) or a connect +# timeout (ConnectTimeout, an httpx.TimeoutException) used to fall through +# every except clause here and surface as a naked 500. +# --------------------------------------------------------------------------- + + +@pytest.mark.asyncio +async def test_the_worker_dying_mid_response_answers_502_not_a_naked_500(monkeypatch): + _install( + monkeypatch, + raises=httpx.RemoteProtocolError( + "Server disconnected without sending a response." + ), + ) + + with pytest.raises(HTTPException) as excinfo: + await _call_worker(b"img", {"schema_version": 1, "tools": []}) + + assert excinfo.value.status_code == 502 + assert "unreachable" in excinfo.value.detail.lower() + + +@pytest.mark.asyncio +async def test_a_connect_timeout_answers_504(monkeypatch): + """ConnectTimeout is an httpx.TimeoutException, not caught by name before - + only httpx.ReadTimeout was.""" + _install(monkeypatch, raises=httpx.ConnectTimeout("connect timed out")) + + with pytest.raises(HTTPException) as excinfo: + await _call_worker(b"img", {"schema_version": 1, "tools": []}) + + assert excinfo.value.status_code == 504 + assert "time" in excinfo.value.detail.lower() + + +# --------------------------------------------------------------------------- +# #15: a 422 from the worker (bad image, malformed graph) must reach the +# Maker as a 422 with the worker's own reason, not a flattened 502. +# --------------------------------------------------------------------------- + + +@pytest.mark.asyncio +async def test_a_422_from_the_worker_propagates_as_422_with_its_detail(monkeypatch): + _install( + monkeypatch, + response=_FakeResponse( + 422, body={"detail": "image is not decodable: cannot identify image file"}, + ), + ) + + with pytest.raises(HTTPException) as excinfo: + await _call_worker(b"img", {"schema_version": 1, "tools": []}) + + assert excinfo.value.status_code == 422 + assert "not decodable" in excinfo.value.detail + + +@pytest.mark.asyncio +async def test_a_422_from_the_worker_without_json_body_still_propagates(monkeypatch): + _install( + monkeypatch, + response=_FakeResponse(422, text="plain text reason", raise_on_json=True), + ) + + with pytest.raises(HTTPException) as excinfo: + await _call_worker(b"img", {"schema_version": 1, "tools": []}) + + assert excinfo.value.status_code == 422 + assert excinfo.value.detail == "plain text reason"