diff --git a/libs/arcade-serve/arcade_serve/fastapi/worker.py b/libs/arcade-serve/arcade_serve/fastapi/worker.py index 0d89b026..c923d8aa 100644 --- a/libs/arcade-serve/arcade_serve/fastapi/worker.py +++ b/libs/arcade-serve/arcade_serve/fastapi/worker.py @@ -4,6 +4,8 @@ from typing import Any, Callable from fastapi import Depends, FastAPI, Request from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer from opentelemetry.metrics import Meter +from starlette.requests import ClientDisconnect +from starlette.responses import Response from starlette.routing import Mount from arcade_serve.core.base import ( @@ -96,7 +98,13 @@ class FastAPIRouter(Router): if use_auth_for_route else None, ) -> Any: - body_str = await request.body() + try: + body_str = await request.body() + except ClientDisconnect: + # Client disconnected while reading request body (often due to large payloads) + # Return HTTP 499 (Client Closed Request) + return Response(status_code=499) + body_json = json.loads(body_str) if body_str else {} request_data = RequestData( path=request.url.path, diff --git a/libs/arcade-serve/pyproject.toml b/libs/arcade-serve/pyproject.toml index a4d350c4..9fed39bb 100644 --- a/libs/arcade-serve/pyproject.toml +++ b/libs/arcade-serve/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "arcade-serve" -version = "3.1.3" +version = "3.1.4" description = "Arcade Serve - Serving infrastructure for Arcade tools and workers" readme = "README.md" license = {text = "MIT"} diff --git a/libs/tests/worker/test_worker_fastapi.py b/libs/tests/worker/test_worker_fastapi.py index f4b5cbf8..84eccd19 100644 --- a/libs/tests/worker/test_worker_fastapi.py +++ b/libs/tests/worker/test_worker_fastapi.py @@ -1,4 +1,5 @@ from typing import Annotated +from unittest.mock import AsyncMock, patch import pytest from arcade_core.schema import ToolCallRequest, ToolContext, ToolReference @@ -6,6 +7,7 @@ from arcade_serve.fastapi.worker import FastAPIWorker from arcade_tdk import tool from fastapi import FastAPI from fastapi.testclient import TestClient +from starlette.requests import ClientDisconnect @tool() @@ -164,3 +166,15 @@ def test_call_tool_route_tool_not_found(client_no_auth, call_tool_payload): # Ideally, this might be a 404 or 400, but BaseWorker.call_tool raises ValueError # which isn't automatically mapped to a 4xx by FastAPI unless handled explicitly. # TODO fix this. + + +def test_client_disconnect_returns_499(client_no_auth, call_tool_payload): + """Test that ClientDisconnect during body read returns HTTP 499.""" + # Mock request.body() to raise ClientDisconnect + with patch("starlette.requests.Request.body", new_callable=AsyncMock) as mock_body: + mock_body.side_effect = ClientDisconnect() + + response = client_no_auth.post("/worker/tools/invoke", json=call_tool_payload) + + # Verify that we get a 499 status code + assert response.status_code == 499