67392a946c
_call_worker distingue httpx.TimeoutException (504) dal resto dei TransportError/HTTPError (502, RemoteProtocolError incluso). Un 422 del worker si propaga come 422 con il motivo del worker, non più 502 generico. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
190 lines
6.6 KiB
Python
190 lines
6.6 KiB
Python
"""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"
|