From bec98b3cee78493bfbf21dbbc975890930eeeda7 Mon Sep 17 00:00:00 2001 From: Tri Lam Date: Mon, 22 Jun 2026 12:10:27 -0700 Subject: [PATCH] =?UTF-8?q?=F0=9F=90=9B=20Swallow=20BrokenResource/ClosedR?= =?UTF-8?q?esource=20in=20SSE=20keepalive=20on=20client=20disconnect?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When an SSE client disconnects, the keepalive task could try to send to a memory channel whose receive end was already closed by exit-stack teardown, raising anyio.BrokenResourceError inside the task group. Treat it the same as EndOfStream (also handle ClosedResourceError). Fixes #15725. --- fastapi/routing.py | 6 +++++- tests/test_sse.py | 53 ++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 58 insertions(+), 1 deletion(-) diff --git a/fastapi/routing.py b/fastapi/routing.py index 3a2d75422..37089ccfa 100644 --- a/fastapi/routing.py +++ b/fastapi/routing.py @@ -590,7 +590,11 @@ def get_request_handler( await send_keepalive.send(data) except TimeoutError: await send_keepalive.send(KEEPALIVE_COMMENT) - except anyio.EndOfStream: + except ( + anyio.EndOfStream, + anyio.BrokenResourceError, + anyio.ClosedResourceError, + ): pass async with anyio.create_task_group() as tg: diff --git a/tests/test_sse.py b/tests/test_sse.py index 6a9d669fe..ddcd3db3d 100644 --- a/tests/test_sse.py +++ b/tests/test_sse.py @@ -2,6 +2,7 @@ import asyncio import time from collections.abc import AsyncIterable, Iterable +import anyio import fastapi.routing import pytest from fastapi import APIRouter, FastAPI @@ -9,6 +10,7 @@ from fastapi.responses import EventSourceResponse from fastapi.sse import ServerSentEvent from fastapi.testclient import TestClient from pydantic import BaseModel +from starlette.types import Message, Scope class Item(BaseModel): @@ -325,3 +327,54 @@ def test_no_keepalive_when_fast(client: TestClient): assert response.status_code == 200 # KEEPALIVE_COMMENT is ": ping\n\n". assert ": ping\n" not in response.text + + +disconnect_app = FastAPI() + + +@disconnect_app.get("/slow-stream", response_class=EventSourceResponse) +async def slow_stream() -> AsyncIterable[dict]: + yield {"message": "first"} + while True: + await anyio.sleep(5) + yield {"message": "ping"} + + +@pytest.mark.anyio +@pytest.mark.filterwarnings("ignore::pytest.PytestUnraisableExceptionWarning") +async def test_keepalive_no_error_on_client_disconnect( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Client disconnect during keepalive must not raise BrokenResourceError. + + Regression test for https://github.com/fastapi/fastapi/discussions/15725. + """ + monkeypatch.setattr(fastapi.routing, "_PING_INTERVAL", 0.02) + + receive_called = anyio.Event() + + async def receive() -> Message: + if not receive_called.is_set(): + receive_called.set() + await anyio.sleep(0.15) + return {"type": "http.disconnect"} + await anyio.sleep(float("inf")) + return {"type": "http.disconnect"} # pragma: no cover + + async def send(message: Message) -> None: + await anyio.sleep(0.05) + + scope: Scope = { + "type": "http", + "asgi": {"version": "3.0", "spec_version": "2.0"}, + "http_version": "1.1", + "method": "GET", + "path": "/slow-stream", + "query_string": b"", + "root_path": "", + "headers": [], + "server": ("test", 80), + } + + with anyio.fail_after(3.0): + await disconnect_app(scope, receive, send)