Skip to content

Commit 2ca4b04

Browse files
authored
perf!: serialize requests once in batchAddRequests (#1051)
### Description - Mirroring apify/apify-client-python#953. - `batchAddRequests` stringified every request twice: once in `sliceArrayByByteLength` to measure the batch, and again in the `serializeRequest` interceptor when sending. Axios then ran its default `transformRequest` on top, because `transformRequest: undefined` in the instance config falls back to the defaults, and that default validates a JSON string body by parsing it in full. Each batch body was stringified twice and parsed once more. - Each request is now serialized once up front. The byte lengths decide the batch boundaries (commas and brackets counted, which the old measurement skipped) and the same strings are joined into the batch body, sent with an explicit `content-type: application/json`. The interceptor passes a string body with an explicit content type through untouched, and the axios instance sets `transformRequest` and `transformResponse` to `[]`. That also removes the validation re-parse for every JSON body the client sends. ### Issue - Closes #972 ### Breaking changes - A string body declared as JSON but not valid JSON (for example `setRecord` with `contentType: 'application/json'` and a non-JSON string) is sent as it is. Axios used to double-encode it into a JSON string literal. - A request too large for the payload limit is rejected before any batch is sent. It used to be detected only when its batch came up, with earlier batches already in flight. - The protected `_batchAddRequests` and `_batchAddRequestsWithRetries` take the serialized entries, and `_batchAddRequests` no longer re-validates a batch the public method already validated. The API report is updated. *✍️ Drafted by Claude Code*
1 parent abf9b66 commit 2ca4b04

9 files changed

Lines changed: 241 additions & 42 deletions

File tree

docs/04_upgrading/upgrading_v3.md

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -272,6 +272,37 @@ A trailing slash appears only on a field the API returns without a path, so on `
272272

273273
A URL field whose value isn't a valid absolute URL now fails response validation and throws <ApiLink to="class/ResponseValidationError">`ResponseValidationError`</ApiLink>, the same as any other field that doesn't match the specification.
274274

275+
## `batchAddRequests()` rejects an oversized request before sending anything
276+
277+
<ApiLink to="class/RequestQueueClient#batchAddRequests">`batchAddRequests()`</ApiLink> now measures the whole input before it sends the first batch. A request too large for the payload limit still throws the same error, and the message still names the request's index. What changed is that nothing has been sent by the time it throws. In v2 each batch was measured as it came up, so every batch before the oversized request had already gone out.
278+
279+
```js
280+
// In v2 the four batches before the oversized request had already been sent.
281+
// In v3 nothing is sent.
282+
await client.requestQueue('my-queue').batchAddRequests([...hundredRequests, oversizedRequest]);
283+
```
284+
285+
Code that treated the throw as "some of these landed" and reconciled the queue afterwards can drop the reconciliation.
286+
287+
## A string body with a JSON content type is sent as it is
288+
289+
A string body sent with an explicit `application/json` content type now goes out verbatim. Axios used to parse it to check that it was valid JSON, and wrapped it in a JSON string literal when it wasn't. The client skips that step so that `batchAddRequests()` can send a body it has already serialized.
290+
291+
<ApiLink to="class/KeyValueStoreClient#setRecord">`setRecord()`</ApiLink> is where you'd notice. Storing a non-JSON string under `contentType: 'application/json'` used to save `"my value"`, quotes included, and now saves `my value`, which <ApiLink to="class/KeyValueStoreClient#getRecord">`getRecord()`</ApiLink> can't parse back:
292+
293+
```js
294+
// v2 stored `"my value"`, v3 stores `my value`.
295+
await client.keyValueStore('my-store').setRecord({
296+
key: 'my-key',
297+
value: 'my value',
298+
contentType: 'application/json',
299+
});
300+
```
301+
302+
Give a record a content type matching what it holds, such as `text/plain`, or pass the value as an object and let the client serialize it.
303+
304+
The same applies to a string input passed together with a `contentType` to <ApiLink to="class/ActorClient#start">`start()`</ApiLink>, <ApiLink to="class/ActorClient#call">`call()`</ApiLink>, or <ApiLink to="class/RunClient#metamorph">`metamorph()`</ApiLink>. v2 handed the API the string wrapped in quotes, v3 hands it over as it is.
305+
275306
## Timeouts are configured per tier
276307

277308
The `timeoutSecs` option of the <ApiLink to="class/ApifyClient">`ApifyClient`</ApiLink> constructor is gone. Every method takes its timeout from one of three tiers, and the constructor sets the duration of each tier, along with a cap on any single request attempt:

docs/public-api/apify-client.api.md

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3282,9 +3282,9 @@ export interface RequestQueue extends Omit<Schemas['RequestQueue'], keyof Reques
32823282
export class RequestQueueClient extends ResourceClient {
32833283
constructor(options: ApiClientSubResourceOptions, userOptions?: RequestQueueUserOptions);
32843284
addRequest(request: RequestQueueClientRequestToAdd, options?: RequestQueueClientAddRequestOptions): Promise<RequestQueueClientAddRequestResult>;
3285-
protected addRequestBatch(requests: RequestQueueClientRequestToAdd[], options?: RequestQueueClientAddRequestOptions): Promise<RequestQueueClientBatchRequestsOperationResult>;
3285+
protected addRequestBatch(requests: SerializedRequestToAdd[], options?: RequestQueueClientAddRequestOptions): Promise<RequestQueueClientBatchRequestsOperationResult>;
32863286
// (undocumented)
3287-
protected addRequestBatchWithRetries(requests: RequestQueueClientRequestToAdd[], options?: RequestQueueClientBatchAddRequestWithRetriesOptions): Promise<RequestQueueClientBatchRequestsOperationResult>;
3287+
protected addRequestBatchWithRetries(requests: SerializedRequestToAdd[], options?: RequestQueueClientBatchAddRequestWithRetriesOptions): Promise<RequestQueueClientBatchRequestsOperationResult>;
32883288
batchAddRequests(requests: RequestQueueClientRequestToAdd[], options?: RequestQueueClientBatchAddRequestWithRetriesOptions): Promise<RequestQueueClientBatchRequestsOperationResult>;
32893289
batchDeleteRequests(requests: RequestQueueClientRequestToDelete[], options?: TimeoutOptions): Promise<RequestQueueClientBatchDeleteRequestsResult>;
32903290
delete(options?: TimeoutOptions): Promise<void>;
@@ -3738,6 +3738,17 @@ interface ScheduleRePointed {
37383738
// @public (undocumented)
37393739
type Schemas = components['schemas'];
37403740

3741+
// Not exported by the entry point; reachable only as a referenced type.
3742+
// @public
3743+
interface SerializedRequestToAdd {
3744+
// (undocumented)
3745+
byteLength: number;
3746+
// (undocumented)
3747+
json: string;
3748+
// (undocumented)
3749+
request: RequestQueueClientRequestToAdd;
3750+
}
3751+
37413752
// @public
37423753
export class ServerError extends ApifyApiError {
37433754
}

src/http_client.ts

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -84,9 +84,10 @@ export class HttpClient {
8484
return new URLSearchParams(formattedParams).toString();
8585
},
8686
validateStatus: null,
87-
// Using interceptors for this functionality.
88-
transformRequest: undefined,
89-
transformResponse: undefined,
87+
// Interceptors serialize requests and parse responses instead. Empty arrays rather than `undefined`,
88+
// which axios fills in with its default transforms.
89+
transformRequest: [],
90+
transformResponse: [],
9091
responseType: 'arraybuffer',
9192
// Every request sets its own timeout in `createRequestHandler`, so the default only backs a raw
9293
// `axios.request()` call.

src/interceptors.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,11 @@ function getHeader(config: ApifyRequestConfig, name: string): string | undefined
4343
}
4444

4545
function serializeRequest(config: ApifyRequestConfig): ApifyRequestConfig {
46+
// A string body with an explicit content type is already serialized and goes out as it is. The axios default
47+
// transform would otherwise parse a JSON one in full just to check that it is valid, which for a body assembled
48+
// from thousands of pre-serialized requests costs about as much as serializing them did.
49+
if (typeof config.data === 'string' && getHeader(config, 'content-type')) return config;
50+
4651
const [defaultTransform] = axios.defaults.transformRequest as AxiosRequestTransformer[];
4752

4853
// The function not only serializes data, but it also adds correct headers.

src/resource_clients/request_queue.ts

Lines changed: 42 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,8 @@ import {
3333
parseArgument,
3434
parseResponse,
3535
RequestQueuePaginationIterator,
36-
sliceArrayByByteLength,
36+
splitIntoJsonArrayBatches,
37+
utf8ByteLength,
3738
} from '../utils.js';
3839

3940
const DEFAULT_PARALLEL_BATCH_ADD_REQUESTS = 5;
@@ -55,7 +56,6 @@ const newRequestSchema = z.custom<RequestQueueClientRequestToAdd>(
5556
'Expected a request object without an `id`',
5657
);
5758
const forefrontOptionsSchema = z.strictObject({ forefront: z.boolean().optional(), ...timeoutOptionsShape });
58-
const batchAddRequestsSchema = z.array(newRequestSchema).min(1).max(REQUEST_QUEUE_MAX_REQUESTS_PER_BATCH_OPERATION);
5959
const batchAddRequestsWithRetriesSchema = z.array(newRequestSchema).min(1);
6060
const optionalBooleanSchema = z.boolean().optional();
6161
const optionalNumberSchema = z.number().optional();
@@ -92,6 +92,16 @@ const paginateRequestsOptionsSchema = z.strictObject({
9292
...timeoutOptionsShape,
9393
});
9494

95+
/**
96+
* A request to add, serialized once up front: `byteLength` decides which batch it goes into, `json` is what the body of
97+
* that batch is assembled from, and `request` is what the result bookkeeping needs.
98+
*/
99+
interface SerializedRequestToAdd {
100+
request: RequestQueueClientRequestToAdd;
101+
json: string;
102+
byteLength: number;
103+
}
104+
95105
export type {
96106
AllowedHttpMethods,
97107
RequestQueue,
@@ -360,17 +370,19 @@ export class RequestQueueClient extends ResourceClient {
360370
* @private
361371
*/
362372
protected async addRequestBatch(
363-
requests: RequestQueueClientRequestToAdd[],
373+
requests: SerializedRequestToAdd[],
364374
options: RequestQueueClientAddRequestOptions = {},
365375
): Promise<RequestQueueClientBatchRequestsOperationResult> {
366-
parseArgument(requests, batchAddRequestsSchema);
367376
const parsed = parseArgument(options, forefrontOptionsSchema, 'RequestQueueClientAddRequestOptions');
368377

369378
const response = await this.httpClient.call({
370379
url: this.buildUrl('requests/batch'),
371380
method: 'POST',
372381
timeoutSecs: this.#resolveTimeout(parsed.timeoutSecs, 'medium'),
373-
data: requests,
382+
// The body is assembled from the requests as `batchAddRequests` serialized them; the explicit content type
383+
// makes the request interceptor send the string as it is instead of serializing the requests again.
384+
headers: { 'content-type': 'application/json' },
385+
data: `[${requests.map(({ json }) => json).join(',')}]`,
374386
params: this.buildParams({
375387
forefront: parsed.forefront,
376388
clientKey: this.#clientKey,
@@ -381,7 +393,7 @@ export class RequestQueueClient extends ResourceClient {
381393
}
382394

383395
protected async addRequestBatchWithRetries(
384-
requests: RequestQueueClientRequestToAdd[],
396+
requests: SerializedRequestToAdd[],
385397
options: RequestQueueClientBatchAddRequestWithRetriesOptions = {},
386398
): Promise<RequestQueueClientBatchRequestsOperationResult> {
387399
const {
@@ -416,7 +428,7 @@ export class RequestQueueClient extends ResourceClient {
416428
const processedRequestsUniqueKeys = processedRequests.map(({ uniqueKey }) => uniqueKey);
417429
// Requests remaining to be processed are the all that remain
418430
remainingRequests = requests.filter(
419-
({ uniqueKey }) => !processedRequestsUniqueKeys.includes(uniqueKey),
431+
({ request }) => !processedRequestsUniqueKeys.includes(request.uniqueKey),
420432
);
421433

422434
// Stop if all requests have been processed
@@ -432,8 +444,8 @@ export class RequestQueueClient extends ResourceClient {
432444
// This ensures that this method does not throw and keeps the signature.
433445
const processedRequestsUniqueKeys = processedRequests.map(({ uniqueKey }) => uniqueKey);
434446
unprocessedRequests = requests
435-
.filter(({ uniqueKey }) => !processedRequestsUniqueKeys.includes(uniqueKey))
436-
.map(({ method, uniqueKey, url }) => ({ method, uniqueKey, url }));
447+
.filter(({ request }) => !processedRequestsUniqueKeys.includes(request.uniqueKey))
448+
.map(({ request: { method, uniqueKey, url } }) => ({ method, uniqueKey, url }));
437449

438450
break;
439451
}
@@ -512,12 +524,28 @@ export class RequestQueueClient extends ResourceClient {
512524
const payloadSizeLimitBytes =
513525
MAX_PAYLOAD_SIZE_BYTES - Math.ceil(MAX_PAYLOAD_SIZE_BYTES * SAFETY_BUFFER_PERCENT);
514526

527+
// Serialize every request once: the byte lengths decide the batch boundaries, and the same strings are joined
528+
// into the batch bodies, so nothing is stringified a second time when it is sent.
529+
const serializedRequests = requests.map((request, index): SerializedRequestToAdd => {
530+
const json = JSON.stringify(request);
531+
const byteLength = utf8ByteLength(json);
532+
// Two more bytes for the brackets, which even a batch of one request carries.
533+
if (byteLength + 2 > payloadSizeLimitBytes) {
534+
throw new Error(
535+
`RequestQueueClient.batchAddRequests: The size of the request with index: ${index} ` +
536+
`exceeds the maximum allowed size (${payloadSizeLimitBytes} bytes).`,
537+
);
538+
}
539+
return { request, json, byteLength };
540+
});
541+
const batches = splitIntoJsonArrayBatches(serializedRequests, {
542+
maxCount: REQUEST_QUEUE_MAX_REQUESTS_PER_BATCH_OPERATION,
543+
maxByteLength: payloadSizeLimitBytes,
544+
});
545+
515546
// Keep a pool of up to `maxParallel` requests running at once
516-
let i = 0;
517-
while (i < requests.length) {
518-
const slicedRequests = requests.slice(i, i + REQUEST_QUEUE_MAX_REQUESTS_PER_BATCH_OPERATION);
519-
const requestsInBatch = sliceArrayByByteLength(slicedRequests, payloadSizeLimitBytes, i);
520-
const requestPromise = this.addRequestBatchWithRetries(requestsInBatch, options);
547+
for (const batch of batches) {
548+
const requestPromise = this.addRequestBatchWithRetries(batch, options);
521549
executingRequests.add(requestPromise);
522550
// A rejection reaches the caller through the awaits below; this bookkeeping chain only has to avoid
523551
// turning it into an unhandled one of its own.
@@ -531,7 +559,6 @@ export class RequestQueueClient extends ResourceClient {
531559
if (executingRequests.size >= maxParallel) {
532560
await Promise.race(executingRequests);
533561
}
534-
i += requestsInBatch.length;
535562
}
536563
// Get results from remaining operations
537564
await Promise.all(executingRequests);

src/utils.ts

Lines changed: 29 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -257,30 +257,39 @@ export async function maybeCompressValue(value: unknown): Promise<CompressedValu
257257
}
258258

259259
/**
260-
* Helper function slice the items from array to fit the max byte length.
260+
* Returns the UTF-8 byte length of a string.
261261
*/
262-
export function sliceArrayByByteLength<T>(array: T[], maxByteLength: number, startIndex: number): T[] {
263-
const stringByteLength = (str: string) => (isNode() ? Buffer.byteLength(str) : new Blob([str]).size);
264-
const arrayByteLength = stringByteLength(JSON.stringify(array));
265-
if (arrayByteLength < maxByteLength) return array;
266-
267-
const slicedArray: T[] = [];
268-
let byteLength = 2; // 2 bytes for the empty array []
269-
for (let i = 0; i < array.length; i++) {
270-
const item = array[i];
271-
const itemByteSize = stringByteLength(JSON.stringify(item));
272-
if (itemByteSize > maxByteLength) {
273-
throw new Error(
274-
`RequestQueueClient.batchAddRequests: The size of the request with index: ${startIndex + i} ` +
275-
`exceeds the maximum allowed size (${maxByteLength} bytes).`,
276-
);
262+
export function utf8ByteLength(value: string): number {
263+
return isNode() ? Buffer.byteLength(value) : new Blob([value]).size;
264+
}
265+
266+
/**
267+
* Splits JSON-serialized items into consecutive batches of at most `maxCount` items, each of which fits into a JSON
268+
* array body - the items joined by commas between brackets - of at most `maxByteLength` bytes. The `byteLength` of an
269+
* item is the UTF-8 byte length of its serialization. An item too large for a body of its own still gets one, so a
270+
* caller that cannot send such an item has to reject it beforehand.
271+
*/
272+
export function splitIntoJsonArrayBatches<T extends { byteLength: number }>(
273+
items: readonly T[],
274+
{ maxCount, maxByteLength }: { maxCount: number; maxByteLength: number },
275+
): T[][] {
276+
const batches: T[][] = [];
277+
let batch: T[] = [];
278+
// One byte for the opening bracket; each item then adds its own bytes plus one for the comma or the closing
279+
// bracket that follows it.
280+
let byteLength = 1;
281+
for (const item of items) {
282+
if (batch.length > 0 && (batch.length >= maxCount || byteLength + item.byteLength + 1 > maxByteLength)) {
283+
batches.push(batch);
284+
batch = [];
285+
byteLength = 1;
277286
}
278-
if (byteLength + itemByteSize >= maxByteLength) break;
279-
byteLength += itemByteSize;
280-
slicedArray.push(item);
287+
batch.push(item);
288+
byteLength += item.byteLength + 1;
281289
}
290+
if (batch.length > 0) batches.push(batch);
282291

283-
return slicedArray;
292+
return batches;
284293
}
285294

286295
export function isNode(): boolean {

test/http_client.test.ts

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -196,6 +196,21 @@ describe('HttpClient', () => {
196196
expect(request?.headers['content-length']).toBe(String(payload.length));
197197
});
198198

199+
test('sends a string body with an explicit content type as it is', async () => {
200+
// The axios default transform would re-parse the body to validate it and trim this whitespace away.
201+
const body = ' [{"uniqueKey": "key-1", "url": "http://example.com/1"}] ';
202+
203+
const response = await client.httpClient.call({
204+
url: `${baseUrl}/v2/request-queues/some-id/requests/batch`,
205+
method: 'POST',
206+
headers: { 'content-type': 'application/json' },
207+
data: body,
208+
});
209+
210+
expect(response.config.data).toBe(body);
211+
expect(mockServer.getLastRequest()?.body).toEqual(JSON.parse(body));
212+
});
213+
199214
describe('timeout across retries', () => {
200215
/**
201216
* Routes the client's axios instance to an in-process adapter that fails `failures` times with a 500 and

0 commit comments

Comments
 (0)