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)

<a
href="https://cursor.com/background-agent?bcId=bc-f777f89b-d2bc-4c0c-bcb1-b76fcf601e05"><picture><source
media="(prefers-color-scheme: dark)"
srcset="https://cursor.com/open-in-cursor-dark.svg"><source
media="(prefers-color-scheme: light)"
srcset="https://cursor.com/open-in-cursor-light.svg"><img alt="Open in
Cursor"
src="https://cursor.com/open-in-cursor.svg"></picture></a>&nbsp;<a
href="https://cursor.com/agents?id=bc-f777f89b-d2bc-4c0c-bcb1-b76fcf601e05"><picture><source
media="(prefers-color-scheme: dark)"
srcset="https://cursor.com/open-in-web-dark.svg"><source
media="(prefers-color-scheme: light)"
srcset="https://cursor.com/open-in-web-light.svg"><img alt="Open in Web"
src="https://cursor.com/open-in-web.svg"></picture></a>

---------

Co-authored-by: Cursor Agent <cursoragent@cursor.com>
Co-authored-by: Eric Gustin <eric@arcade.dev>
This commit is contained in:
Evan Tahler 2025-12-02 13:44:34 -08:00 committed by GitHub
parent 83ec80c08f
commit e75eeb7e77
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
3 changed files with 24 additions and 2 deletions

View file

@ -4,6 +4,8 @@ from typing import Any, Callable
from fastapi import Depends, FastAPI, Request from fastapi import Depends, FastAPI, Request
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
from opentelemetry.metrics import Meter from opentelemetry.metrics import Meter
from starlette.requests import ClientDisconnect
from starlette.responses import Response
from starlette.routing import Mount from starlette.routing import Mount
from arcade_serve.core.base import ( from arcade_serve.core.base import (
@ -96,7 +98,13 @@ class FastAPIRouter(Router):
if use_auth_for_route if use_auth_for_route
else None, else None,
) -> Any: ) -> 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 {} body_json = json.loads(body_str) if body_str else {}
request_data = RequestData( request_data = RequestData(
path=request.url.path, path=request.url.path,

View file

@ -1,6 +1,6 @@
[project] [project]
name = "arcade-serve" name = "arcade-serve"
version = "3.1.3" version = "3.1.4"
description = "Arcade Serve - Serving infrastructure for Arcade tools and workers" description = "Arcade Serve - Serving infrastructure for Arcade tools and workers"
readme = "README.md" readme = "README.md"
license = {text = "MIT"} license = {text = "MIT"}

View file

@ -1,4 +1,5 @@
from typing import Annotated from typing import Annotated
from unittest.mock import AsyncMock, patch
import pytest import pytest
from arcade_core.schema import ToolCallRequest, ToolContext, ToolReference 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 arcade_tdk import tool
from fastapi import FastAPI from fastapi import FastAPI
from fastapi.testclient import TestClient from fastapi.testclient import TestClient
from starlette.requests import ClientDisconnect
@tool() @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 # 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. # which isn't automatically mapped to a 4xx by FastAPI unless handled explicitly.
# TODO fix this. # 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