Skip to content

Commit 6717e35

Browse files
committed
fix: Make batch_add_requests split batches by serialized payload size
1 parent 5ad7c91 commit 6717e35

2 files changed

Lines changed: 140 additions & 0 deletions

File tree

src/apify_client/_resource_clients/request_queue.py

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

33
import asyncio
4+
import json
45
import math
56
from collections.abc import Iterable
67
from queue import Queue
@@ -413,10 +414,14 @@ def batch_add_requests(
413414
payload_size_limit_bytes = _MAX_PAYLOAD_SIZE_BYTES - math.ceil(_MAX_PAYLOAD_SIZE_BYTES * _SAFETY_BUFFER_PERCENT)
414415

415416
# Split the requests into batches, constrained by the max payload size and max requests per batch.
417+
# Sizes are measured with the same JSON serialization the HTTP client applies to request bodies.
416418
batches = constrained_batches(
417419
requests_as_dicts,
418420
max_size=payload_size_limit_bytes,
419421
max_count=_RQ_MAX_REQUESTS_PER_BATCH,
422+
get_len=lambda r: len(json.dumps(r, ensure_ascii=False, allow_nan=False, default=str).encode('utf-8')),
423+
# An individually oversized request gets its own batch and is left for the API to reject.
424+
strict=False,
420425
)
421426

422427
# Put the batches into the queue for processing.
@@ -990,10 +995,14 @@ async def batch_add_requests(
990995
payload_size_limit_bytes = _MAX_PAYLOAD_SIZE_BYTES - math.ceil(_MAX_PAYLOAD_SIZE_BYTES * _SAFETY_BUFFER_PERCENT)
991996

992997
# Split the requests into batches, constrained by the max payload size and max requests per batch.
998+
# Sizes are measured with the same JSON serialization the HTTP client applies to request bodies.
993999
batches = constrained_batches(
9941000
requests_as_dicts,
9951001
max_size=payload_size_limit_bytes,
9961002
max_count=_RQ_MAX_REQUESTS_PER_BATCH,
1003+
get_len=lambda r: len(json.dumps(r, ensure_ascii=False, allow_nan=False, default=str).encode('utf-8')),
1004+
# An individually oversized request gets its own batch and is left for the API to reject.
1005+
strict=False,
9971006
)
9981007

9991008
for batch in batches:

tests/unit/test_client_request_queue.py

Lines changed: 131 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,18 +1,29 @@
11
from __future__ import annotations
22

3+
import gzip
4+
import json
35
import re
46
from typing import TYPE_CHECKING
57

68
import pytest
9+
from werkzeug.wrappers import Response
710

811
from apify_client import ApifyClient, ApifyClientAsync
912
from apify_client.errors import ApifyApiError
1013

1114
if TYPE_CHECKING:
15+
from collections.abc import Callable
16+
1217
from pytest_httpserver import HTTPServer
18+
from werkzeug.wrappers import Request
1319

1420
from apify_client._typeddicts import RequestDraftDict
1521

22+
# The Apify API limit on the payload size of a batch-add request, which the client's batching must respect.
23+
_API_MAX_PAYLOAD_SIZE_BYTES = 9 * 1024 * 1024
24+
25+
_EMPTY_BATCH_RESPONSE_CONTENT = '{"data": {"processedRequests": [], "unprocessedRequests": []}}'
26+
1627
_PARTIALLY_ADDED_BATCH_RESPONSE_CONTENT = """{
1728
"data": {
1829
"processedRequests": [
@@ -96,6 +107,126 @@ def test_batch_not_processed_raises_exception_sync(httpserver: HTTPServer) -> No
96107
rq_client.batch_add_requests(requests=requests)
97108

98109

110+
def _make_large_requests() -> list[RequestDraftDict]:
111+
"""Return 3 requests of ~4 MB each, so that all of them together exceed the 9 MB payload limit."""
112+
return [
113+
{
114+
'unique_key': f'http://example.com/{i}',
115+
'url': f'http://example.com/{i}?filler={"x" * (4 * 1024 * 1024)}',
116+
'method': 'GET',
117+
}
118+
for i in range(3)
119+
]
120+
121+
122+
def _payload_capturing_handler(payloads: list[bytes]) -> Callable[[Request], Response]:
123+
"""Return a handler that records each POST body (gzip-decompressed) and responds with an empty batch result."""
124+
125+
def handler(request: Request) -> Response:
126+
payloads.append(gzip.decompress(request.get_data()))
127+
return Response(_EMPTY_BATCH_RESPONSE_CONTENT, status=200, content_type='application/json')
128+
129+
return handler
130+
131+
132+
async def test_batch_add_requests_splits_batches_by_payload_size_async(httpserver: HTTPServer) -> None:
133+
"""Test that batches are split by serialized byte size so no POST payload exceeds the 9 MB API limit."""
134+
server_url = httpserver.url_for('/').removesuffix('/')
135+
client = ApifyClientAsync(
136+
token='placeholder_token',
137+
api_url=server_url,
138+
api_public_url=server_url,
139+
)
140+
141+
payloads = list[bytes]()
142+
httpserver.expect_request(re.compile(r'.*'), method='POST').respond_with_handler(
143+
_payload_capturing_handler(payloads)
144+
)
145+
rq_client = client.request_queue(request_queue_id='whatever')
146+
147+
await rq_client.batch_add_requests(requests=_make_large_requests())
148+
149+
assert len(payloads) > 1
150+
assert all(len(payload) <= _API_MAX_PAYLOAD_SIZE_BYTES for payload in payloads)
151+
assert sum(len(json.loads(payload)) for payload in payloads) == 3
152+
153+
154+
def test_batch_add_requests_splits_batches_by_payload_size_sync(httpserver: HTTPServer) -> None:
155+
"""Test that batches are split by serialized byte size so no POST payload exceeds the 9 MB API limit."""
156+
server_url = httpserver.url_for('/').removesuffix('/')
157+
client = ApifyClient(
158+
token='placeholder_token',
159+
api_url=server_url,
160+
api_public_url=server_url,
161+
)
162+
163+
payloads = list[bytes]()
164+
httpserver.expect_request(re.compile(r'.*'), method='POST').respond_with_handler(
165+
_payload_capturing_handler(payloads)
166+
)
167+
rq_client = client.request_queue(request_queue_id='whatever')
168+
169+
rq_client.batch_add_requests(requests=_make_large_requests())
170+
171+
assert len(payloads) > 1
172+
assert all(len(payload) <= _API_MAX_PAYLOAD_SIZE_BYTES for payload in payloads)
173+
assert sum(len(json.loads(payload)) for payload in payloads) == 3
174+
175+
176+
def _make_oversized_and_small_requests() -> list[RequestDraftDict]:
177+
"""Return a small request plus one whose serialized size alone exceeds the 9 MB payload limit."""
178+
return [
179+
{'unique_key': 'small', 'url': 'http://example.com/small', 'method': 'GET'},
180+
{
181+
'unique_key': 'oversized',
182+
'url': f'http://example.com/oversized?filler={"x" * (10 * 1024 * 1024)}',
183+
'method': 'GET',
184+
},
185+
]
186+
187+
188+
async def test_batch_add_requests_sends_oversized_request_alone_async(httpserver: HTTPServer) -> None:
189+
"""Test that a request exceeding the payload limit is sent in its own batch for the API to judge, not rejected."""
190+
server_url = httpserver.url_for('/').removesuffix('/')
191+
client = ApifyClientAsync(
192+
token='placeholder_token',
193+
api_url=server_url,
194+
api_public_url=server_url,
195+
)
196+
197+
payloads = list[bytes]()
198+
httpserver.expect_request(re.compile(r'.*'), method='POST').respond_with_handler(
199+
_payload_capturing_handler(payloads)
200+
)
201+
rq_client = client.request_queue(request_queue_id='whatever')
202+
203+
await rq_client.batch_add_requests(requests=_make_oversized_and_small_requests())
204+
205+
assert sum(len(json.loads(payload)) for payload in payloads) == 2
206+
assert any(len(payload) > _API_MAX_PAYLOAD_SIZE_BYTES for payload in payloads)
207+
208+
209+
def test_batch_add_requests_sends_oversized_request_alone_sync(httpserver: HTTPServer) -> None:
210+
"""Test that a request exceeding the payload limit is sent in its own batch for the API to judge, not rejected."""
211+
server_url = httpserver.url_for('/').removesuffix('/')
212+
client = ApifyClient(
213+
token='placeholder_token',
214+
api_url=server_url,
215+
api_public_url=server_url,
216+
)
217+
218+
payloads = list[bytes]()
219+
httpserver.expect_request(re.compile(r'.*'), method='POST').respond_with_handler(
220+
_payload_capturing_handler(payloads)
221+
)
222+
rq_client = client.request_queue(request_queue_id='whatever')
223+
224+
rq_client.batch_add_requests(requests=_make_oversized_and_small_requests())
225+
226+
assert sum(len(json.loads(payload)) for payload in payloads) == 2
227+
assert any(len(payload) > _API_MAX_PAYLOAD_SIZE_BYTES for payload in payloads)
228+
229+
99230
def test_batch_processed_partially_sync(httpserver: HTTPServer) -> None:
100231
server_url = httpserver.url_for('/').removesuffix('/')
101232
client = ApifyClient(

0 commit comments

Comments
 (0)