From 9277bec44797b77c941e29659c9296fc17d8c05c Mon Sep 17 00:00:00 2001 From: Marcelo Trylesinski Date: Sun, 6 Sep 2026 17:30:00 +0200 Subject: [PATCH 1/3] Stop file transfers when HTTP clients disconnect --- benchmarks/file_response_benchmark.py | 18 +++- starlette/responses.py | 45 +++++++-- tests/test_file_response_disconnect.py | 135 +++++++++++++++++++++++++ tests/test_responses.py | 3 +- 4 files changed, 187 insertions(+), 14 deletions(-) create mode 100644 tests/test_file_response_disconnect.py diff --git a/benchmarks/file_response_benchmark.py b/benchmarks/file_response_benchmark.py index 8220e39ff..04a0e6b05 100644 --- a/benchmarks/file_response_benchmark.py +++ b/benchmarks/file_response_benchmark.py @@ -3,6 +3,7 @@ from dataclasses import dataclass from pathlib import Path +import anyio import pytest from pytest_codspeed.plugin import BenchmarkFixture @@ -40,12 +41,12 @@ async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None: await FileResponse(self.path, media_type="application/octet-stream")(scope, receive, send) -def http_scope(case: BenchmarkCase) -> Scope: +def http_scope(case: BenchmarkCase, spec_version: str = "2.5") -> Scope: headers = [] if case.range_header is None else [(b"range", case.range_header)] extensions: dict[str, dict[str, object]] = {"http.response.pathsend": {}} if case.pathsend else {} return { "type": "http", - "asgi": {"version": "3.0", "spec_version": "2.5"}, + "asgi": {"version": "3.0", "spec_version": spec_version}, "http_version": "1.1", "method": "GET", "scheme": "http", @@ -60,8 +61,12 @@ def http_scope(case: BenchmarkCase) -> Scope: } -def dispatch(runner: ASGIRunner, app: FileApp, case: BenchmarkCase) -> list[Message]: - return runner.run(app, http_scope(case)) +def dispatch(runner: ASGIRunner, app: FileApp, case: BenchmarkCase, spec_version: str = "2.5") -> list[Message]: + async def receive() -> Message: + await anyio.sleep_forever() + raise AssertionError("The disconnect listener should be cancelled") + + return runner.run(app, http_scope(case, spec_version), receive) @pytest.fixture(scope="module", autouse=True) @@ -80,16 +85,18 @@ def warm_file_response(asgi_runner: ASGIRunner, tmp_path_factory: pytest.TempPat @pytest.mark.parametrize("case", CASES, ids=lambda case: case.id) +@pytest.mark.parametrize("spec_version", ["2.3", "2.5"]) @pytest.mark.benchmark(max_time=0.5, max_rounds=1) def test_file_response( tmp_path: Path, asgi_runner: ASGIRunner, benchmark: BenchmarkFixture, case: BenchmarkCase, + spec_version: str, ) -> None: path = tmp_path / "file.bin" path.write_bytes(b"x" * case.file_size) - messages = benchmark.pedantic(dispatch, args=(asgi_runner, FileApp(path), case), rounds=1) + messages = benchmark.pedantic(dispatch, args=(asgi_runner, FileApp(path), case, spec_version), rounds=1) expected_status = 206 if case.range_header is not None else 200 assert messages[0]["type"] == "http.response.start" @@ -111,3 +118,4 @@ def test_file_response( benchmark.extra_info["file_bytes"] = case.file_size benchmark.extra_info["response_bytes"] = expected_size benchmark.extra_info["pathsend"] = case.pathsend + benchmark.extra_info["asgi_spec_version"] = spec_version diff --git a/starlette/responses.py b/starlette/responses.py index d5df75d28..9751f360d 100644 --- a/starlette/responses.py +++ b/starlette/responses.py @@ -6,7 +6,8 @@ import os import stat import sys -from collections.abc import AsyncIterable, Awaitable, Callable, Iterable, Mapping, Sequence +from collections.abc import AsyncIterable, AsyncIterator, Awaitable, Callable, Iterable, Mapping, Sequence +from contextlib import asynccontextmanager from datetime import datetime from email.utils import format_datetime, formatdate from functools import partial @@ -365,7 +366,7 @@ async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None: http_if_range = headers.get("if-range") if http_range is None or (http_if_range is not None and not self._should_use_range(http_if_range)): - await self._handle_simple(send, send_header_only, send_pathsend) + send_file = partial(self._handle_simple, send, send_header_only, send_pathsend) else: try: ranges = self._parse_range_header(http_range, stat_result.st_size) @@ -376,16 +377,44 @@ async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None: return await response(scope, receive, send) if len(ranges) == 0: - await self._handle_simple(send, send_header_only, send_pathsend) + send_file = partial(self._handle_simple, send, send_header_only, send_pathsend) elif len(ranges) == 1: start, end = ranges[0] - await self._handle_single_range(send, start, end, stat_result.st_size, send_header_only) + send_file = partial(self._handle_single_range, send, start, end, stat_result.st_size, send_header_only) + send_pathsend = False else: - await self._handle_multiple_ranges(send, ranges, stat_result.st_size, send_header_only) + send_file = partial(self._handle_multiple_ranges, send, ranges, stat_result.st_size, send_header_only) + send_pathsend = False + + spec_version = tuple(map(int, scope.get("asgi", {}).get("spec_version", "2.0").split("."))) + if scope_type != "http" or send_header_only or send_pathsend or spec_version >= (2, 4): + await send_file() + else: + async with create_collapsing_task_group() as task_group: + + async def stream_file() -> None: + await send_file() + task_group.cancel_scope.cancel() + + task_group.start_soon(stream_file) + while True: + if (await receive())["type"] == "http.disconnect": + task_group.cancel_scope.cancel() + break if self.background is not None: await self.background() + @asynccontextmanager + async def _open_file(self) -> AsyncIterator[anyio.AsyncFile[bytes]]: + file = await anyio.open_file(self.path, mode="rb") + try: + yield file + finally: + # Closing must finish even when the transfer is cancelled. + with anyio.CancelScope(shield=True): + await file.aclose() + async def _handle_simple(self, send: Send, send_header_only: bool, send_pathsend: bool) -> None: await send({"type": "http.response.start", "status": self.status_code, "headers": self.raw_headers}) if send_header_only: @@ -393,7 +422,7 @@ async def _handle_simple(self, send: Send, send_header_only: bool, send_pathsend elif send_pathsend: await send({"type": "http.response.pathsend", "path": str(self.path)}) else: - async with await anyio.open_file(self.path, mode="rb") as file: + async with self._open_file() as file: more_body = True while more_body: chunk = await file.read(self.chunk_size) @@ -410,7 +439,7 @@ async def _handle_single_range( if send_header_only: await send({"type": "http.response.body", "body": b"", "more_body": False}) else: - async with await anyio.open_file(self.path, mode="rb") as file: + async with self._open_file() as file: await file.seek(start) more_body = True while more_body: @@ -438,7 +467,7 @@ async def _handle_multiple_ranges( if send_header_only: await send({"type": "http.response.body", "body": b"", "more_body": False}) else: - async with await anyio.open_file(self.path, mode="rb") as file: + async with self._open_file() as file: for start, end in ranges: await send({"type": "http.response.body", "body": header_generator(start, end), "more_body": True}) await file.seek(start) diff --git a/tests/test_file_response_disconnect.py b/tests/test_file_response_disconnect.py new file mode 100644 index 000000000..ebeb12f3d --- /dev/null +++ b/tests/test_file_response_disconnect.py @@ -0,0 +1,135 @@ +from __future__ import annotations + +import os +from pathlib import Path +from typing import Literal + +import anyio +import pytest + +from starlette.background import BackgroundTask +from starlette.responses import FileResponse +from starlette.types import Message, Scope + +pytestmark = pytest.mark.anyio + + +@pytest.fixture +def file_path(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> tuple[Path, list[anyio.AsyncFile[bytes]]]: + path = tmp_path / "file.bin" + path.write_bytes(b"x" * (8 * FileResponse.chunk_size)) + files: list[anyio.AsyncFile[bytes]] = [] + open_file = anyio.open_file + + async def track_open_file(path: str | os.PathLike[str], mode: Literal["rb"]) -> anyio.AsyncFile[bytes]: + file = await open_file(path, mode=mode) + files.append(file) + return file + + monkeypatch.setattr(anyio, "open_file", track_open_file) + return path, files + + +@pytest.fixture(params=[None, b"bytes=0-", b"bytes=0-131071,196608-524287"]) +def scope(request: pytest.FixtureRequest) -> Scope: + return { + "type": "http", + "method": "GET", + "headers": [] if request.param is None else [(b"range", request.param)], + "extensions": {"http.response.pathsend": {}} if request.param is not None else {}, + } + + +@pytest.mark.parametrize("spec_version", [None, "2.0", "2.3"]) +async def test_file_response_stops_on_disconnect( + file_path: tuple[Path, list[anyio.AsyncFile[bytes]]], scope: Scope, spec_version: str | None +) -> None: + path, files = file_path + if spec_version is not None: + scope["asgi"] = {"spec_version": spec_version} + disconnected = anyio.Event() + received_request = False + submitted = 0 + background_ran = False + + async def receive() -> Message: + nonlocal received_request + if not received_request: + received_request = True + return {"type": "http.request", "body": b"", "more_body": False} + await disconnected.wait() + return {"type": "http.disconnect"} + + async def send(message: Message) -> None: + nonlocal submitted + if message["type"] == "http.response.body": + submitted += len(message["body"]) + if submitted >= FileResponse.chunk_size: + disconnected.set() + + async def cleanup() -> None: + nonlocal background_ran + assert len(files) == 1 + assert files[0].closed + await anyio.sleep(0) + background_ran = True + + with anyio.fail_after(5): + await FileResponse(path, background=BackgroundTask(cleanup))(scope, receive, send) + + assert FileResponse.chunk_size <= submitted < 3 * FileResponse.chunk_size + assert background_ran + + +@pytest.mark.parametrize("spec_version", ["2.0", "2.3", "2.4"]) +async def test_file_response_closes_on_cancellation( + file_path: tuple[Path, list[anyio.AsyncFile[bytes]]], scope: Scope, spec_version: str +) -> None: + path, files = file_path + scope["asgi"] = {"spec_version": spec_version} + + async def receive() -> Message: + await anyio.sleep_forever() + pytest.fail("The disconnect listener should be cancelled") # pragma: no cover - sleep never returns + + async def send(message: Message) -> None: + if message["type"] == "http.response.body": + cancel_scope.cancel() + await anyio.sleep_forever() + + async def cleanup() -> None: + pytest.fail( + "Background tasks should not run after external cancellation" + ) # pragma: no cover - failure sentinel + + with anyio.fail_after(5), anyio.CancelScope() as cancel_scope: + await FileResponse(path, background=BackgroundTask(cleanup))(scope, receive, send) + + assert cancel_scope.cancelled_caught + assert len(files) == 1 + assert files[0].closed + + +@pytest.mark.parametrize("spec_version", ["2.0", "2.3", "2.4"]) +async def test_file_response_closes_on_send_error( + file_path: tuple[Path, list[anyio.AsyncFile[bytes]]], scope: Scope, spec_version: str +) -> None: + path, files = file_path + scope["asgi"] = {"spec_version": spec_version} + error = OSError("Disconnected") + + async def receive() -> Message: + assert spec_version != "2.4" + await anyio.sleep_forever() + pytest.fail("The disconnect listener should be cancelled") # pragma: no cover - sleep never returns + + async def send(message: Message) -> None: + if message["type"] == "http.response.body": + raise error + + with anyio.fail_after(5), pytest.raises(OSError) as exc: + await FileResponse(path)(scope, receive, send) + + assert exc.value is error + assert len(files) == 1 + assert files[0].closed diff --git a/tests/test_responses.py b/tests/test_responses.py index 2733c9f22..174ae0bd4 100644 --- a/tests/test_responses.py +++ b/tests/test_responses.py @@ -1018,7 +1018,8 @@ class SmallChunkSizeFileResponse(FileResponse): start_message: dict[str, Any] = {} async def receive() -> Message: - raise NotImplementedError("Should not be called!") + await anyio.sleep_forever() + pytest.fail("The disconnect listener should be cancelled") # pragma: no cover - sleep never returns async def send(message: Message) -> None: if message["type"] == "http.response.start": From 29f8e7dc805b76601767712436eb21f8c0956d61 Mon Sep 17 00:00:00 2001 From: Marcelo Trylesinski Date: Sun, 6 Sep 2026 17:42:06 +0200 Subject: [PATCH 2/3] Keep file response disconnect tests in test_responses.py --- tests/test_file_response_disconnect.py | 135 ------------------------- tests/test_responses.py | 127 ++++++++++++++++++++++- 2 files changed, 126 insertions(+), 136 deletions(-) delete mode 100644 tests/test_file_response_disconnect.py diff --git a/tests/test_file_response_disconnect.py b/tests/test_file_response_disconnect.py deleted file mode 100644 index ebeb12f3d..000000000 --- a/tests/test_file_response_disconnect.py +++ /dev/null @@ -1,135 +0,0 @@ -from __future__ import annotations - -import os -from pathlib import Path -from typing import Literal - -import anyio -import pytest - -from starlette.background import BackgroundTask -from starlette.responses import FileResponse -from starlette.types import Message, Scope - -pytestmark = pytest.mark.anyio - - -@pytest.fixture -def file_path(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> tuple[Path, list[anyio.AsyncFile[bytes]]]: - path = tmp_path / "file.bin" - path.write_bytes(b"x" * (8 * FileResponse.chunk_size)) - files: list[anyio.AsyncFile[bytes]] = [] - open_file = anyio.open_file - - async def track_open_file(path: str | os.PathLike[str], mode: Literal["rb"]) -> anyio.AsyncFile[bytes]: - file = await open_file(path, mode=mode) - files.append(file) - return file - - monkeypatch.setattr(anyio, "open_file", track_open_file) - return path, files - - -@pytest.fixture(params=[None, b"bytes=0-", b"bytes=0-131071,196608-524287"]) -def scope(request: pytest.FixtureRequest) -> Scope: - return { - "type": "http", - "method": "GET", - "headers": [] if request.param is None else [(b"range", request.param)], - "extensions": {"http.response.pathsend": {}} if request.param is not None else {}, - } - - -@pytest.mark.parametrize("spec_version", [None, "2.0", "2.3"]) -async def test_file_response_stops_on_disconnect( - file_path: tuple[Path, list[anyio.AsyncFile[bytes]]], scope: Scope, spec_version: str | None -) -> None: - path, files = file_path - if spec_version is not None: - scope["asgi"] = {"spec_version": spec_version} - disconnected = anyio.Event() - received_request = False - submitted = 0 - background_ran = False - - async def receive() -> Message: - nonlocal received_request - if not received_request: - received_request = True - return {"type": "http.request", "body": b"", "more_body": False} - await disconnected.wait() - return {"type": "http.disconnect"} - - async def send(message: Message) -> None: - nonlocal submitted - if message["type"] == "http.response.body": - submitted += len(message["body"]) - if submitted >= FileResponse.chunk_size: - disconnected.set() - - async def cleanup() -> None: - nonlocal background_ran - assert len(files) == 1 - assert files[0].closed - await anyio.sleep(0) - background_ran = True - - with anyio.fail_after(5): - await FileResponse(path, background=BackgroundTask(cleanup))(scope, receive, send) - - assert FileResponse.chunk_size <= submitted < 3 * FileResponse.chunk_size - assert background_ran - - -@pytest.mark.parametrize("spec_version", ["2.0", "2.3", "2.4"]) -async def test_file_response_closes_on_cancellation( - file_path: tuple[Path, list[anyio.AsyncFile[bytes]]], scope: Scope, spec_version: str -) -> None: - path, files = file_path - scope["asgi"] = {"spec_version": spec_version} - - async def receive() -> Message: - await anyio.sleep_forever() - pytest.fail("The disconnect listener should be cancelled") # pragma: no cover - sleep never returns - - async def send(message: Message) -> None: - if message["type"] == "http.response.body": - cancel_scope.cancel() - await anyio.sleep_forever() - - async def cleanup() -> None: - pytest.fail( - "Background tasks should not run after external cancellation" - ) # pragma: no cover - failure sentinel - - with anyio.fail_after(5), anyio.CancelScope() as cancel_scope: - await FileResponse(path, background=BackgroundTask(cleanup))(scope, receive, send) - - assert cancel_scope.cancelled_caught - assert len(files) == 1 - assert files[0].closed - - -@pytest.mark.parametrize("spec_version", ["2.0", "2.3", "2.4"]) -async def test_file_response_closes_on_send_error( - file_path: tuple[Path, list[anyio.AsyncFile[bytes]]], scope: Scope, spec_version: str -) -> None: - path, files = file_path - scope["asgi"] = {"spec_version": spec_version} - error = OSError("Disconnected") - - async def receive() -> Message: - assert spec_version != "2.4" - await anyio.sleep_forever() - pytest.fail("The disconnect listener should be cancelled") # pragma: no cover - sleep never returns - - async def send(message: Message) -> None: - if message["type"] == "http.response.body": - raise error - - with anyio.fail_after(5), pytest.raises(OSError) as exc: - await FileResponse(path)(scope, receive, send) - - assert exc.value is error - assert len(files) == 1 - assert files[0].closed diff --git a/tests/test_responses.py b/tests/test_responses.py index 174ae0bd4..f62c2f37a 100644 --- a/tests/test_responses.py +++ b/tests/test_responses.py @@ -1,13 +1,14 @@ from __future__ import annotations import datetime as dt +import os import sys import time from collections.abc import AsyncGenerator, AsyncIterator, Iterator from dataclasses import dataclass from http.cookies import SimpleCookie from pathlib import Path -from typing import Any +from typing import Any, Literal import anyio import pytest @@ -395,6 +396,130 @@ async def send(message: Message) -> None: ) +@pytest.fixture +def file_path(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> tuple[Path, list[anyio.AsyncFile[bytes]]]: + path = tmp_path / "file.bin" + path.write_bytes(b"x" * (8 * FileResponse.chunk_size)) + files: list[anyio.AsyncFile[bytes]] = [] + open_file = anyio.open_file + + async def track_open_file(path: str | os.PathLike[str], mode: Literal["rb"]) -> anyio.AsyncFile[bytes]: + file = await open_file(path, mode=mode) + files.append(file) + return file + + monkeypatch.setattr(anyio, "open_file", track_open_file) + return path, files + + +@pytest.fixture(params=[None, b"bytes=0-", b"bytes=0-131071,196608-524287"]) +def scope(request: pytest.FixtureRequest) -> Scope: + return { + "type": "http", + "method": "GET", + "headers": [] if request.param is None else [(b"range", request.param)], + "extensions": {"http.response.pathsend": {}} if request.param is not None else {}, + } + + +@pytest.mark.anyio +@pytest.mark.parametrize("spec_version", [None, "2.0", "2.3"]) +async def test_file_response_stops_on_disconnect( + file_path: tuple[Path, list[anyio.AsyncFile[bytes]]], scope: Scope, spec_version: str | None +) -> None: + path, files = file_path + if spec_version is not None: + scope["asgi"] = {"spec_version": spec_version} + disconnected = anyio.Event() + received_request = False + submitted = 0 + background_ran = False + + async def receive() -> Message: + nonlocal received_request + if not received_request: + received_request = True + return {"type": "http.request", "body": b"", "more_body": False} + await disconnected.wait() + return {"type": "http.disconnect"} + + async def send(message: Message) -> None: + nonlocal submitted + if message["type"] == "http.response.body": + submitted += len(message["body"]) + if submitted >= FileResponse.chunk_size: + disconnected.set() + + async def cleanup() -> None: + nonlocal background_ran + assert len(files) == 1 + assert files[0].closed + await anyio.sleep(0) + background_ran = True + + with anyio.fail_after(5): + await FileResponse(path, background=BackgroundTask(cleanup))(scope, receive, send) + + assert FileResponse.chunk_size <= submitted < 3 * FileResponse.chunk_size + assert background_ran + + +@pytest.mark.anyio +@pytest.mark.parametrize("spec_version", ["2.0", "2.3", "2.4"]) +async def test_file_response_closes_on_cancellation( + file_path: tuple[Path, list[anyio.AsyncFile[bytes]]], scope: Scope, spec_version: str +) -> None: + path, files = file_path + scope["asgi"] = {"spec_version": spec_version} + + async def receive() -> Message: + await anyio.sleep_forever() + pytest.fail("The disconnect listener should be cancelled") # pragma: no cover - sleep never returns + + async def send(message: Message) -> None: + if message["type"] == "http.response.body": + cancel_scope.cancel() + await anyio.sleep_forever() + + async def cleanup() -> None: + pytest.fail( + "Background tasks should not run after external cancellation" + ) # pragma: no cover - failure sentinel + + with anyio.fail_after(5), anyio.CancelScope() as cancel_scope: + await FileResponse(path, background=BackgroundTask(cleanup))(scope, receive, send) + + assert cancel_scope.cancelled_caught + assert len(files) == 1 + assert files[0].closed + + +@pytest.mark.anyio +@pytest.mark.parametrize("spec_version", ["2.0", "2.3", "2.4"]) +async def test_file_response_closes_on_send_error( + file_path: tuple[Path, list[anyio.AsyncFile[bytes]]], scope: Scope, spec_version: str +) -> None: + path, files = file_path + scope["asgi"] = {"spec_version": spec_version} + error = OSError("Disconnected") + + async def receive() -> Message: + assert spec_version != "2.4" + await anyio.sleep_forever() + pytest.fail("The disconnect listener should be cancelled") # pragma: no cover - sleep never returns + + async def send(message: Message) -> None: + if message["type"] == "http.response.body": + raise error + + with anyio.fail_after(5), pytest.raises(OSError) as exc: + await FileResponse(path)(scope, receive, send) + + assert exc.value is error + assert len(files) == 1 + assert files[0].closed + + def test_set_cookie(test_client_factory: TestClientFactory, monkeypatch: pytest.MonkeyPatch) -> None: # Mock time used as a reference for `Expires` by stdlib `SimpleCookie`. mocked_now = dt.datetime(2037, 1, 22, 12, 0, 0, tzinfo=dt.timezone.utc) From 0a781cbb648a5bc0fa65c28b68e753130aad32d2 Mon Sep 17 00:00:00 2001 From: Marcelo Trylesinski Date: Sun, 6 Sep 2026 17:43:27 +0200 Subject: [PATCH 3/3] Keep the existing file response benchmarks unchanged --- benchmarks/file_response_benchmark.py | 18 +++++------------- 1 file changed, 5 insertions(+), 13 deletions(-) diff --git a/benchmarks/file_response_benchmark.py b/benchmarks/file_response_benchmark.py index 04a0e6b05..8220e39ff 100644 --- a/benchmarks/file_response_benchmark.py +++ b/benchmarks/file_response_benchmark.py @@ -3,7 +3,6 @@ from dataclasses import dataclass from pathlib import Path -import anyio import pytest from pytest_codspeed.plugin import BenchmarkFixture @@ -41,12 +40,12 @@ async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None: await FileResponse(self.path, media_type="application/octet-stream")(scope, receive, send) -def http_scope(case: BenchmarkCase, spec_version: str = "2.5") -> Scope: +def http_scope(case: BenchmarkCase) -> Scope: headers = [] if case.range_header is None else [(b"range", case.range_header)] extensions: dict[str, dict[str, object]] = {"http.response.pathsend": {}} if case.pathsend else {} return { "type": "http", - "asgi": {"version": "3.0", "spec_version": spec_version}, + "asgi": {"version": "3.0", "spec_version": "2.5"}, "http_version": "1.1", "method": "GET", "scheme": "http", @@ -61,12 +60,8 @@ def http_scope(case: BenchmarkCase, spec_version: str = "2.5") -> Scope: } -def dispatch(runner: ASGIRunner, app: FileApp, case: BenchmarkCase, spec_version: str = "2.5") -> list[Message]: - async def receive() -> Message: - await anyio.sleep_forever() - raise AssertionError("The disconnect listener should be cancelled") - - return runner.run(app, http_scope(case, spec_version), receive) +def dispatch(runner: ASGIRunner, app: FileApp, case: BenchmarkCase) -> list[Message]: + return runner.run(app, http_scope(case)) @pytest.fixture(scope="module", autouse=True) @@ -85,18 +80,16 @@ def warm_file_response(asgi_runner: ASGIRunner, tmp_path_factory: pytest.TempPat @pytest.mark.parametrize("case", CASES, ids=lambda case: case.id) -@pytest.mark.parametrize("spec_version", ["2.3", "2.5"]) @pytest.mark.benchmark(max_time=0.5, max_rounds=1) def test_file_response( tmp_path: Path, asgi_runner: ASGIRunner, benchmark: BenchmarkFixture, case: BenchmarkCase, - spec_version: str, ) -> None: path = tmp_path / "file.bin" path.write_bytes(b"x" * case.file_size) - messages = benchmark.pedantic(dispatch, args=(asgi_runner, FileApp(path), case, spec_version), rounds=1) + messages = benchmark.pedantic(dispatch, args=(asgi_runner, FileApp(path), case), rounds=1) expected_status = 206 if case.range_header is not None else 200 assert messages[0]["type"] == "http.response.start" @@ -118,4 +111,3 @@ def test_file_response( benchmark.extra_info["file_bytes"] = case.file_size benchmark.extra_info["response_bytes"] = expected_size benchmark.extra_info["pathsend"] = case.pathsend - benchmark.extra_info["asgi_spec_version"] = spec_version