"""I2: a broken worker must answer with a named status, never a naked 500. `_call_worker` mapped a non-200 HTTP response to 502, but let `httpx.ConnectError` (worker stopped) and `httpx.ReadTimeout` (job past the 120s budget) propagate unhandled - with no exception handler in main.py that turns those into anything but FastAPI's generic 500. It also read `report["outputs"]`, `report["failures"]` and `report["engine_version"]` without checking the response had that shape at all, so a proxy answering 200 with something else (HTML, an empty body) would raise a bare KeyError instead of a diagnosable error. The spec's edge-case table: "il worker isola il guasto, l'API resta in piedi, il task riporta l'errore" - the worker's fault must not become the API's fault. """ import httpx import pytest from fastapi import HTTPException from src.backend.services.vision_service import _call_worker class _FakeResponse: """Stands in for httpx.Response: only what `_call_worker` touches.""" def __init__(self, status_code: int, body=..., text: str = "", raise_on_json: bool = False): self.status_code = status_code self._body = body self.text = text self._raise_on_json = raise_on_json def json(self): if self._raise_on_json: raise ValueError("not JSON") return self._body class _FakeAsyncClient: """Stands in for httpx.AsyncClient: post() does whatever the test wants.""" def __init__(self, *, response=None, raises=None): self._response = response self._raises = raises async def __aenter__(self): return self async def __aexit__(self, *exc_info): return False async def post(self, *args, **kwargs): if self._raises is not None: raise self._raises return self._response def _install(monkeypatch, *, response=None, raises=None): monkeypatch.setattr( "src.backend.services.vision_service.httpx.AsyncClient", lambda *a, **k: _FakeAsyncClient(response=response, raises=raises), ) @pytest.mark.asyncio async def test_a_stopped_worker_answers_502_not_a_naked_500(monkeypatch): _install(monkeypatch, raises=httpx.ConnectError("Connection refused")) 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_job_past_the_time_budget_answers_504(monkeypatch): _install(monkeypatch, raises=httpx.ReadTimeout("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() @pytest.mark.asyncio async def test_a_response_missing_the_expected_keys_is_reported_not_raised_raw(monkeypatch): """A proxy answering 200 with an unexpected shape must not KeyError.""" _install(monkeypatch, response=_FakeResponse(200, body={"outputs": {}})) with pytest.raises(HTTPException) as excinfo: await _call_worker(b"img", {"schema_version": 1, "tools": []}) assert excinfo.value.status_code == 502 assert "malformed" in excinfo.value.detail.lower() @pytest.mark.asyncio async def test_a_response_that_is_not_a_json_object_is_reported(monkeypatch): """200 with HTML or a bare list, say - not a dict at all.""" _install(monkeypatch, response=_FakeResponse(200, body=["not", "a", "dict"])) with pytest.raises(HTTPException) as excinfo: await _call_worker(b"img", {"schema_version": 1, "tools": []}) assert excinfo.value.status_code == 502 assert "malformed" in excinfo.value.detail.lower() @pytest.mark.asyncio async def test_a_well_formed_response_still_passes_through(monkeypatch): body = {"outputs": {"t1.diameter": 10.0}, "failures": [], "engine_version": "a" * 40} _install(monkeypatch, response=_FakeResponse(200, body=body)) 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"