5858 from apify_client .types import Timeout
5959
6060_RQ_MAX_REQUESTS_PER_BATCH = 25
61- _MAX_PAYLOAD_SIZE_BYTES = 9 * 1024 * 1024 # 9 MB
62- _SAFETY_BUFFER_PERCENT = 0.01 / 100 # 0.01%
61+ """Maximum number of requests the API accepts in a single batch call."""
6362
63+ _MAX_PAYLOAD_SIZE_BYTES = 9 * 1024 * 1024
64+ """Maximum payload size (9 MB) the API accepts for a single batch call."""
6465
65- def _serialize_request (request : dict ) -> bytes :
66- """Serialize a request into the JSON bytes it will occupy in the batch request body.
66+ _SAFETY_BUFFER_PERCENT = 0.01 / 100
67+ """Safety margin (0.01%) deducted from the maximum payload size when splitting requests into batches."""
68+
69+
70+ def _serialize_requests (
71+ requests : list [RequestDraft ] | list [RequestDraftDict ] | list [RequestDraftCamelDict ],
72+ ) -> list [bytes ]:
73+ """Validate requests and serialize each one into the JSON bytes it will occupy in the batch request body.
6774
6875 Each request is serialized exactly once: the same bytes are measured when splitting requests into batches and
69- then assembled into the request body, so batch sizes are computed on exactly the bytes that get sent. Uses the
70- same `json.dumps` options as `HttpClientBase._prepare_request_call` to keep the wire format consistent with
71- other endpoints.
76+ then assembled into the request body, so batch sizes are computed on exactly the bytes that get sent. Validation
77+ and serialization happen in a single pass, so each intermediate dict stays transient instead of a whole dict list
78+ being held in memory alongside the serialized requests. Uses the same `json.dumps` options as
79+ `HttpClientBase._prepare_request_call` to keep the wire format consistent with other endpoints.
7280 """
73- return json .dumps (request , ensure_ascii = False , allow_nan = False , default = str ).encode ('utf-8' )
81+ return [
82+ json .dumps (
83+ (request if isinstance (request , RequestDraft ) else RequestDraft .model_validate (request )).model_dump (
84+ by_alias = True , exclude_none = True
85+ ),
86+ ensure_ascii = False ,
87+ allow_nan = False ,
88+ default = str ,
89+ ).encode ('utf-8' )
90+ for request in requests
91+ ]
7492
7593
7694@docs_group ('Resource clients' )
@@ -412,23 +430,16 @@ def batch_add_requests(
412430 if max_parallel != 1 :
413431 raise NotImplementedError ('max_parallel is only supported in async client' )
414432
415- requests_as_dicts = [
416- (r if isinstance (r , RequestDraft ) else RequestDraft .model_validate (r )).model_dump (
417- by_alias = True , exclude_none = True
418- )
419- for r in requests
420- ]
421-
422- serialized_requests = [_serialize_request (r ) for r in requests_as_dicts ]
433+ # Validate the requests and serialize each of them into JSON bytes.
434+ serialized_requests = _serialize_requests (requests )
423435
436+ # Build the query parameters shared by all the batch API calls.
424437 request_params = self ._build_params (clientKey = self .client_key , forefront = forefront )
425438
426439 # Compute the payload size limit to ensure it doesn't exceed the maximum allowed size.
427440 payload_size_limit_bytes = _MAX_PAYLOAD_SIZE_BYTES - math .ceil (_MAX_PAYLOAD_SIZE_BYTES * _SAFETY_BUFFER_PERCENT )
428441
429- # Split the requests into batches, constrained by the max payload size and max requests per batch. Each
430- # request costs its serialized bytes plus a separator, and the brackets are reserved up front, so an
431- # assembled `[...]` body can never exceed the limit.
442+ # Split the requests into batches by payload size (counting commas and brackets) and max requests per batch.
432443 batches = constrained_batches (
433444 serialized_requests ,
434445 max_size = payload_size_limit_bytes - len (b'[]' ),
@@ -438,17 +449,17 @@ def batch_add_requests(
438449 )
439450
440451 # Put the batches into the queue for processing.
441- queue = Queue [Iterable [bytes ]]()
452+ batch_queue = Queue [Iterable [bytes ]]()
442453
443454 for batch in batches :
444- queue .put (batch )
455+ batch_queue .put (batch )
445456
446457 processed_requests = list [AddedRequest ]()
447458 unprocessed_requests = list [RequestDraft ]()
448459
449460 # Process all batches in the queue sequentially.
450- while not queue .empty ():
451- request_batch = queue .get ()
461+ while not batch_queue .empty ():
462+ request_batch = batch_queue .get ()
452463
453464 # Send the batch to the API, assembling the body from the already serialized requests.
454465 response = self ._http_client .call (
@@ -989,35 +1000,16 @@ async def batch_add_requests(
9891000 Returns:
9901001 Result containing lists of processed and unprocessed requests.
9911002 """
992- requests_as_dicts = [
993- (
994- request
995- if isinstance (request , RequestDraft )
996- else RequestDraft .model_validate (
997- request ,
998- )
999- ).model_dump (
1000- by_alias = True ,
1001- exclude_none = True ,
1002- )
1003- for request in requests
1004- ]
1005-
1006- # Serializing many requests is CPU-bound and would block the event loop, so offload it to a worker
1007- # thread (the HTTP client offloads request body compression the same way).
1008- serialized_requests = await asyncio .to_thread (
1009- lambda : [_serialize_request (request ) for request in requests_as_dicts ]
1010- )
1003+ # Validate and serialize the requests in a worker thread, as it is CPU-bound and would block the event loop.
1004+ serialized_requests = await asyncio .to_thread (_serialize_requests , requests )
10111005
1012- asyncio_queue : asyncio . Queue [ Iterable [ bytes ]] = asyncio . Queue ()
1006+ # Build the query parameters shared by all the batch API calls.
10131007 request_params = self ._build_params (clientKey = self .client_key , forefront = forefront )
10141008
10151009 # Compute the payload size limit to ensure it doesn't exceed the maximum allowed size.
10161010 payload_size_limit_bytes = _MAX_PAYLOAD_SIZE_BYTES - math .ceil (_MAX_PAYLOAD_SIZE_BYTES * _SAFETY_BUFFER_PERCENT )
10171011
1018- # Split the requests into batches, constrained by the max payload size and max requests per batch. Each
1019- # request costs its serialized bytes plus a separator, and the brackets are reserved up front, so an
1020- # assembled `[...]` body can never exceed the limit.
1012+ # Split the requests into batches by payload size (counting commas and brackets) and max requests per batch.
10211013 batches = constrained_batches (
10221014 serialized_requests ,
10231015 max_size = payload_size_limit_bytes - len (b'[]' ),
@@ -1026,24 +1018,27 @@ async def batch_add_requests(
10261018 strict = False ,
10271019 )
10281020
1021+ # Create a queue with all the batches, from which the worker tasks will consume them.
1022+ batch_queue : asyncio .Queue [Iterable [bytes ]] = asyncio .Queue ()
1023+
10291024 for batch in batches :
1030- await asyncio_queue .put (batch )
1025+ await batch_queue .put (batch )
10311026
10321027 # Use TaskGroup for structured concurrency — automatic cleanup and error propagation.
10331028 try :
10341029 async with asyncio .TaskGroup () as tg :
10351030 workers = [
10361031 tg .create_task (
10371032 self ._batch_add_requests_worker (
1038- queue = asyncio_queue , request_params = request_params , timeout = timeout
1033+ queue = batch_queue , request_params = request_params , timeout = timeout
10391034 ),
10401035 name = f'batch_add_requests_worker_{ i } ' ,
10411036 )
10421037 for i in range (max_parallel )
10431038 ]
10441039
10451040 # Wait for all batches to be processed, then cancel idle workers.
1046- await asyncio_queue .join ()
1041+ await batch_queue .join ()
10471042 for worker in workers :
10481043 worker .cancel ()
10491044 except ExceptionGroup as eg :
0 commit comments