|
1 | 1 | from __future__ import annotations |
2 | 2 |
|
| 3 | +import gzip |
| 4 | +import json |
3 | 5 | import re |
4 | 6 | from typing import TYPE_CHECKING |
5 | 7 |
|
6 | 8 | import pytest |
| 9 | +from werkzeug.wrappers import Response |
7 | 10 |
|
8 | 11 | from apify_client import ApifyClient, ApifyClientAsync |
9 | 12 | from apify_client.errors import ApifyApiError |
10 | 13 |
|
11 | 14 | if TYPE_CHECKING: |
| 15 | + from collections.abc import Callable |
| 16 | + |
12 | 17 | from pytest_httpserver import HTTPServer |
| 18 | + from werkzeug.wrappers import Request |
13 | 19 |
|
14 | 20 | from apify_client._typeddicts import RequestDraftDict |
15 | 21 |
|
| 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 | + |
16 | 27 | _PARTIALLY_ADDED_BATCH_RESPONSE_CONTENT = """{ |
17 | 28 | "data": { |
18 | 29 | "processedRequests": [ |
@@ -96,6 +107,126 @@ def test_batch_not_processed_raises_exception_sync(httpserver: HTTPServer) -> No |
96 | 107 | rq_client.batch_add_requests(requests=requests) |
97 | 108 |
|
98 | 109 |
|
| 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 | + |
99 | 230 | def test_batch_processed_partially_sync(httpserver: HTTPServer) -> None: |
100 | 231 | server_url = httpserver.url_for('/').removesuffix('/') |
101 | 232 | client = ApifyClient( |
|
0 commit comments