fix(vision): file non immagine e grafo malformato non escono più come 500 (#15)
Worker: Image.open().convert() in try/except (422 "image is not decodable"); il grafo deve essere un dict (422 altrimenti); run_graph distingue costruzione (GraphBuildError -> 422) da esecuzione (500 con motivo). Router: verifica dei magic bytes (PNG/JPEG/BMP/TIFF/WebP/GIF) prima di chiamare il worker. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
@@ -10,9 +10,9 @@ import json
|
||||
|
||||
import numpy as np
|
||||
from fastapi import FastAPI, File, Form, HTTPException, UploadFile, status
|
||||
from PIL import Image
|
||||
from PIL import Image, UnidentifiedImageError
|
||||
|
||||
from src.vision.runner import engine_version, run_graph
|
||||
from src.vision.runner import GraphBuildError, engine_version, run_graph
|
||||
|
||||
app = FastAPI(title="TieMeasureFlow Vision Worker", version="0.1.0")
|
||||
|
||||
@@ -35,17 +35,36 @@ async def run(
|
||||
detail=f"graph is not valid JSON: {exc}",
|
||||
) from exc
|
||||
|
||||
frame = np.array(Image.open(image.file).convert("L"))
|
||||
if not isinstance(parsed_graph, dict):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_422_UNPROCESSABLE_CONTENT,
|
||||
detail=f"graph must be a JSON object, got {type(parsed_graph).__name__}",
|
||||
)
|
||||
|
||||
try:
|
||||
frame = np.array(Image.open(image.file).convert("L"))
|
||||
except (UnidentifiedImageError, OSError) as exc:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_422_UNPROCESSABLE_CONTENT,
|
||||
detail=f"image is not decodable: {exc}",
|
||||
) from exc
|
||||
|
||||
try:
|
||||
outcome = run_graph(frame, parsed_graph)
|
||||
except ValueError as exc:
|
||||
# from_dict refuses a schema version it does not handle; that is a bad
|
||||
# request, not a server fault.
|
||||
except GraphBuildError as exc:
|
||||
# The graph itself is bad (schema version, a tool missing "kind", ...) -
|
||||
# a bad request, not a server fault.
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_422_UNPROCESSABLE_CONTENT,
|
||||
detail=str(exc),
|
||||
) from exc
|
||||
except Exception as exc:
|
||||
# The graph built fine; something failed while actually running it -
|
||||
# an engine fault, not the caller's. Still named, not a naked 500.
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail=f"vision run failed: {type(exc).__name__}: {exc}",
|
||||
) from exc
|
||||
|
||||
return {
|
||||
# `outcome.outputs` never carries NaN/inf/bool (see runner.run_graph):
|
||||
|
||||
@@ -95,3 +95,55 @@ async def test_a_malformed_graph_is_refused_not_crashed():
|
||||
|
||||
assert response.status_code == 422
|
||||
assert "99" in response.json()["detail"]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# #15: a non-image upload, a graph that is not a dict, or a tool with no
|
||||
# "kind" used to reach the worker as an unhandled exception - a naked 500,
|
||||
# and then a 502 with no reason once the backend forwarded it.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_a_non_image_upload_is_refused_not_crashed():
|
||||
"""A text file declared image/png: Image.open() must not escape as 500."""
|
||||
transport = ASGITransport(app=app)
|
||||
async with AsyncClient(transport=transport, base_url="http://worker") as client:
|
||||
response = await client.post(
|
||||
"/run",
|
||||
files={"image": ("piece.png", b"i am not an image", "image/png")},
|
||||
data={"graph": json.dumps(a_circle_graph())},
|
||||
)
|
||||
|
||||
assert response.status_code == 422
|
||||
assert "not decodable" in response.json()["detail"].lower()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_a_graph_that_is_not_an_object_is_refused():
|
||||
transport = ASGITransport(app=app)
|
||||
async with AsyncClient(transport=transport, base_url="http://worker") as client:
|
||||
response = await client.post(
|
||||
"/run",
|
||||
files={"image": ("piece.png", a_disc_png(), "image/png")},
|
||||
data={"graph": "[]"},
|
||||
)
|
||||
|
||||
assert response.status_code == 422
|
||||
assert "object" in response.json()["detail"].lower()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_a_tool_without_a_kind_is_refused_not_crashed():
|
||||
graph = {"schema_version": 1, "name": "p", "tools": [{"id": "a", "name": "x"}]}
|
||||
transport = ASGITransport(app=app)
|
||||
async with AsyncClient(transport=transport, base_url="http://worker") as client:
|
||||
response = await client.post(
|
||||
"/run",
|
||||
files={"image": ("piece.png", a_disc_png(), "image/png")},
|
||||
data={"graph": json.dumps(graph)},
|
||||
)
|
||||
|
||||
assert response.status_code == 422
|
||||
assert "malformed graph" in response.json()["detail"].lower()
|
||||
assert "kind" in response.json()["detail"].lower()
|
||||
|
||||
Reference in New Issue
Block a user