fix(vision): worker down o in timeout non esce più come 500 nudo (#14)
_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>
This commit is contained in:
@@ -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
|
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
|
table: "il worker isola il guasto, l'API resta in piedi, il task
|
||||||
riporta l'errore"). A stopped worker raises `httpx.ConnectError`; a job
|
riporta l'errore"). httpx.TimeoutException (ConnectTimeout, ReadTimeout,
|
||||||
past the timeout raises `httpx.ReadTimeout` - both would otherwise
|
WriteTimeout, PoolTimeout) becomes 504; every other transport failure
|
||||||
propagate past this function and surface to the operator as a naked
|
(ConnectError, ReadError, RemoteProtocolError when the worker dies
|
||||||
500. The response shape is validated here too: a proxy answering 200
|
mid-response, ...) becomes 502 - both would otherwise propagate past
|
||||||
with something that is not the worker's JSON contract must not turn
|
this function and surface to the operator as a naked 500. The response
|
||||||
into a bare KeyError further down.
|
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:
|
try:
|
||||||
async with httpx.AsyncClient(timeout=120.0) as client:
|
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")},
|
files={"image": ("frame.png", image_bytes, "image/png")},
|
||||||
data={"graph": json.dumps(graph)},
|
data={"graph": json.dumps(graph)},
|
||||||
)
|
)
|
||||||
except httpx.ConnectError as exc:
|
except httpx.TimeoutException 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:
|
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=status.HTTP_504_GATEWAY_TIMEOUT,
|
status_code=status.HTTP_504_GATEWAY_TIMEOUT,
|
||||||
detail=f"the vision worker did not answer in time: {exc}",
|
detail=f"the vision worker did not answer in time: {exc}",
|
||||||
) from 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:
|
if response.status_code != 200:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
|
|||||||
@@ -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": []})
|
report = await _call_worker(b"img", {"schema_version": 1, "tools": []})
|
||||||
|
|
||||||
assert report == body
|
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"
|
||||||
|
|||||||
Reference in New Issue
Block a user