From e75eeb7e770a8b885330c79b9a3d41a3fd5bb5a0 Mon Sep 17 00:00:00 2001 From: Evan Tahler Date: Tue, 2 Dec 2025 13:44:34 -0800 Subject: [PATCH] Handle client disconnect for large payloads (#701) Catch `ClientDisconnect` in FastAPI worker to return HTTP 499 for large payloads and reduce noisy error logs. --- Linear Issue: [TOO-189](https://linear.app/arcadedev/issue/TOO-189/catch-clientdisconnect-and-return-499-for-large-payloads) Open in
Cursor Open in Web --------- Co-authored-by: Cursor Agent Co-authored-by: Eric Gustin --- libs/arcade-serve/arcade_serve/fastapi/worker.py | 10 +++++++++- libs/arcade-serve/pyproject.toml | 2 +- libs/tests/worker/test_worker_fastapi.py | 14 ++++++++++++++ 3 files changed, 24 insertions(+), 2 deletions(-) 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