Skip to content

Commit 9e820de

Browse files
committed
fix: Stop HttpResponse protocol checks from consuming streaming bodies
1 parent e99cfe9 commit 9e820de

4 files changed

Lines changed: 49 additions & 3 deletions

File tree

pyproject.toml

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,8 @@ dependencies = [
2828
"impit~=0.13.0",
2929
"more_itertools>=10.0.0",
3030
"pydantic[email]>=2.11.0",
31-
"typing-extensions>=4.4.0",
31+
# 4.6.0 is the first release whose runtime protocol checks look attributes up statically.
32+
"typing-extensions>=4.6.0",
3233
]
3334

3435
[project.optional-dependencies]

src/apify_client/http_clients/_base.py

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,9 +11,14 @@
1111
from datetime import UTC, datetime, timedelta
1212
from http import HTTPStatus
1313
from importlib import metadata
14-
from typing import TYPE_CHECKING, Any, Protocol, TypeVar, runtime_checkable
14+
from typing import TYPE_CHECKING, Any, TypeVar
1515
from urllib.parse import urlencode
1616

17+
# `Protocol` comes from `typing_extensions`, not `typing`, because its runtime `isinstance` check looks attributes
18+
# up statically. The `typing` implementation on Python 3.11 calls `hasattr`, which evaluates properties: on an
19+
# unread streaming response that either raises or silently buffers the whole body.
20+
from typing_extensions import Protocol, runtime_checkable
21+
1722
from apify_client._consts import (
1823
DEFAULT_MAX_RETRIES,
1924
DEFAULT_MIN_DELAY_BETWEEN_RETRIES,

tests/unit/test_client_streaming.py

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,8 @@
66
from apify_client.http_clients import HttpResponse
77

88
if TYPE_CHECKING:
9+
from typing import Any
10+
911
from pytest_httpserver import HTTPServer
1012

1113
from apify_client.http_clients import HttpClient, HttpClientAsync
@@ -87,3 +89,41 @@ async def test_key_value_store_stream_record_async(
8789
response = record['value']
8890
assert isinstance(response, HttpResponse)
8991
assert await response.aread() == STREAM_CONTENT
92+
93+
94+
def test_protocol_check_leaves_stream_unread_sync(
95+
httpserver: HTTPServer,
96+
http_client_class: type[HttpClient],
97+
) -> None:
98+
"""Checking a streaming response against the protocol inspects it without pulling the body off the wire."""
99+
httpserver.expect_request(f'/v2/datasets/{DATASET_ID}/items').respond_with_data(STREAM_CONTENT)
100+
api_url = httpserver.url_for('/').removesuffix('/')
101+
client = ApifyClient.with_custom_http_client(
102+
api_url=api_url,
103+
http_client=http_client_class(),
104+
)
105+
106+
with client.dataset(DATASET_ID).stream_items(item_format='json') as response:
107+
assert isinstance(response, HttpResponse)
108+
# `is_stream_consumed` is transport state, not part of the protocol, but both built-in clients expose it.
109+
raw: Any = response
110+
assert raw.is_stream_consumed is False
111+
112+
113+
async def test_protocol_check_leaves_stream_unread_async(
114+
httpserver: HTTPServer,
115+
http_client_async_class: type[HttpClientAsync],
116+
) -> None:
117+
"""Checking a streaming response against the protocol inspects it without pulling the body off the wire."""
118+
httpserver.expect_request(f'/v2/datasets/{DATASET_ID}/items').respond_with_data(STREAM_CONTENT)
119+
api_url = httpserver.url_for('/').removesuffix('/')
120+
client = ApifyClientAsync.with_custom_http_client(
121+
api_url=api_url,
122+
http_client=http_client_async_class(),
123+
)
124+
125+
async with client.dataset(DATASET_ID).stream_items(item_format='json') as response:
126+
assert isinstance(response, HttpResponse)
127+
# `is_stream_consumed` is transport state, not part of the protocol, but both built-in clients expose it.
128+
raw: Any = response
129+
assert raw.is_stream_consumed is False

uv.lock

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)