Skip to content

Commit 4f1e9ff

Browse files
committed
fix: Read duck-typed file-like values and encode KVS records off the event loop
1 parent bc092b8 commit 4f1e9ff

4 files changed

Lines changed: 44 additions & 5 deletions

File tree

src/apify_client/_resource_clients/key_value_store.py

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
from __future__ import annotations
22

3+
import asyncio
34
import re
45
from contextlib import asynccontextmanager, contextmanager
56
from http import HTTPStatus
@@ -801,7 +802,11 @@ async def set_record(
801802
content_type: The content type of the saved value.
802803
timeout: Timeout for the API HTTP request.
803804
"""
804-
value, content_type = encode_key_value_store_record_value(value, content_type=content_type)
805+
# Encoding reads file-like values and may serialize large payloads, which is blocking; offload it to a
806+
# worker thread so it does not stall the event loop (mirrors the transport's own body-prep offload).
807+
value, content_type = await asyncio.to_thread(
808+
encode_key_value_store_record_value, value, content_type=content_type
809+
)
805810

806811
headers = {'content-type': content_type}
807812

src/apify_client/_utils/encoding.py

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,5 @@
11
from __future__ import annotations
22

3-
import io
43
import json
54
from base64 import b64encode
65
from functools import cache
@@ -23,9 +22,12 @@ def encode_key_value_store_record_value(value: Any, *, content_type: str | None
2322
A tuple of (encoded_value, content_type).
2423
"""
2524
# Read file-like values into memory; the underlying HTTP transport only accepts bytes-like bodies,
26-
# so a file object would otherwise reach it unread and raise a raw `TypeError`.
27-
if isinstance(value, io.IOBase):
28-
value = value.read()
25+
# so a file object would otherwise reach it unread and raise a raw `TypeError`. Detect them by a
26+
# callable `read` rather than `io.IOBase` so duck-typed file-likes (upload wrappers, raw streams)
27+
# are read too, instead of falling through to JSON serialization.
28+
read = getattr(value, 'read', None)
29+
if callable(read):
30+
value = read()
2931

3032
if not content_type:
3133
if isinstance(value, (bytes, bytearray)):

tests/unit/test_key_value_store.py

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -58,3 +58,23 @@ def capture_request(request: Request) -> Response:
5858
assert len(captured_requests) == 1
5959
assert _decode_body(captured_requests[0]) == b'buffer data'
6060
assert captured_requests[0].headers['content-type'] == 'application/octet-stream'
61+
62+
63+
def test_set_record_reads_stringio_value_sync(httpserver: HTTPServer) -> None:
64+
"""Regression test: a text file-like value is read and uploaded as text/plain through the HTTP stack."""
65+
captured_requests: list[Request] = []
66+
67+
def capture_request(request: Request) -> Response:
68+
captured_requests.append(request)
69+
return Response(status=201)
70+
71+
httpserver.expect_request(_RECORD_PATH, method='PUT').respond_with_handler(capture_request)
72+
73+
api_url = httpserver.url_for('/').removesuffix('/')
74+
client = ApifyClient(token='test_token', api_url=api_url)
75+
76+
client.key_value_store(_MOCKED_KVS_ID).set_record('f', io.StringIO('buffer data'))
77+
78+
assert len(captured_requests) == 1
79+
assert _decode_body(captured_requests[0]) == b'buffer data'
80+
assert captured_requests[0].headers['content-type'] == 'text/plain; charset=utf-8'

tests/unit/test_utils.py

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -264,6 +264,18 @@ def test_encode_key_value_store_record_value_stringio() -> None:
264264
assert content_type == 'text/plain; charset=utf-8'
265265

266266

267+
def test_encode_key_value_store_record_value_duck_typed_file_like() -> None:
268+
"""Test that a duck-typed file-like value (a callable `read`, not an `io.IOBase`) is read into bytes."""
269+
270+
class _Reader:
271+
def read(self) -> bytes:
272+
return b'buffer data'
273+
274+
value, content_type = encode_key_value_store_record_value(_Reader())
275+
assert value == b'buffer data'
276+
assert content_type == 'application/octet-stream'
277+
278+
267279
def test_response_to_dict() -> None:
268280
"""Test parsing response as dictionary."""
269281
mock_response = Mock()

0 commit comments

Comments
 (0)