apify-client 3.0.0-beta.19 → 3.0.0-beta.20

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -54,9 +54,10 @@ export class HttpClient {
54
54
  return new URLSearchParams(formattedParams).toString();
55
55
  },
56
56
  validateStatus: null,
57
- // Using interceptors for this functionality.
58
- transformRequest: undefined,
59
- transformResponse: undefined,
57
+ // Interceptors serialize requests and parse responses instead. Empty arrays rather than `undefined`,
58
+ // which axios fills in with its default transforms.
59
+ transformRequest: [],
60
+ transformResponse: [],
60
61
  responseType: 'arraybuffer',
61
62
  // Every request sets its own timeout in `createRequestHandler`, so the default only backs a raw
62
63
  // `axios.request()` call.
@@ -31,6 +31,11 @@ function getHeader(config, name) {
31
31
  return typeof value === 'string' ? value : undefined;
32
32
  }
33
33
  function serializeRequest(config) {
34
+ // A string body with an explicit content type is already serialized and goes out as it is. The axios default
35
+ // transform would otherwise parse a JSON one in full just to check that it is valid, which for a body assembled
36
+ // from thousands of pre-serialized requests costs about as much as serializing them did.
37
+ if (typeof config.data === 'string' && getHeader(config, 'content-type'))
38
+ return config;
34
39
  const [defaultTransform] = axios.defaults.transformRequest;
35
40
  // The function not only serializes data, but it also adds correct headers.
36
41
  const data = defaultTransform(config.data, config.headers);
@@ -3,6 +3,15 @@ import type { ApiClientSubResourceOptions } from '../base/api_client.js';
3
3
  import { ResourceClient } from '../base/resource_client.js';
4
4
  import type { RequestQueue, RequestQueueClientAddRequestResult, RequestQueueClientBatchDeleteRequestsResult, RequestQueueClientBatchRequestsOperationResult, RequestQueueClientListAndLockHeadResult, RequestQueueClientListHeadResult, RequestQueueClientListRequestsResult, RequestQueueClientProlongRequestLockResult, RequestQueueClientRequestSchema, RequestQueueClientRequestToAdd, RequestQueueClientRequestToUpdate, RequestQueueClientUnlockRequestsResult } from '../models.js';
5
5
  import type { TimeoutOptions } from '../timeouts.js';
6
+ /**
7
+ * A request to add, serialized once up front: `byteLength` decides which batch it goes into, `json` is what the body of
8
+ * that batch is assembled from, and `request` is what the result bookkeeping needs.
9
+ */
10
+ interface SerializedRequestToAdd {
11
+ request: RequestQueueClientRequestToAdd;
12
+ json: string;
13
+ byteLength: number;
14
+ }
6
15
  export type { AllowedHttpMethods, RequestQueue, RequestQueueClientAddRequestResult, RequestQueueClientBatchDeleteRequestsResult, RequestQueueClientBatchRequestsOperationResult, RequestQueueClientListAndLockHeadResult, RequestQueueClientListHeadResult, RequestQueueClientListItem, RequestQueueClientListRequestsResult, RequestQueueClientLockedListItem, RequestQueueClientProlongRequestLockResult, RequestQueueClientRequestSchema, RequestQueueClientRequestToAdd, RequestQueueClientRequestToUpdate, RequestQueueClientUnlockRequestsResult, RequestQueueStats, } from '../models.js';
7
16
  /**
8
17
  * Client for managing a specific Request queue.
@@ -160,8 +169,8 @@ export declare class RequestQueueClient extends ResourceClient {
160
169
  *
161
170
  * @private
162
171
  */
163
- protected addRequestBatch(requests: RequestQueueClientRequestToAdd[], options?: RequestQueueClientAddRequestOptions): Promise<RequestQueueClientBatchRequestsOperationResult>;
164
- protected addRequestBatchWithRetries(requests: RequestQueueClientRequestToAdd[], options?: RequestQueueClientBatchAddRequestWithRetriesOptions): Promise<RequestQueueClientBatchRequestsOperationResult>;
172
+ protected addRequestBatch(requests: SerializedRequestToAdd[], options?: RequestQueueClientAddRequestOptions): Promise<RequestQueueClientBatchRequestsOperationResult>;
173
+ protected addRequestBatchWithRetries(requests: SerializedRequestToAdd[], options?: RequestQueueClientBatchAddRequestWithRetriesOptions): Promise<RequestQueueClientBatchRequestsOperationResult>;
165
174
  /**
166
175
  * Adds multiple requests to the queue in a single operation.
167
176
  *
@@ -5,7 +5,7 @@ import { ResourceClient } from '../base/resource_client.js';
5
5
  import { ResponseValidationError } from '../response_validation_error.js';
6
6
  import * as schemas from '../schemas.js';
7
7
  import { optionalTimeoutSchema, timeoutOptionsSchema, timeoutOptionsShape } from '../timeouts.js';
8
- import { anyObjectSchema, catchNotFoundOrThrow, isNonArrayObject, parseArgument, parseResponse, RequestQueuePaginationIterator, sliceArrayByByteLength, } from '../utils.js';
8
+ import { anyObjectSchema, catchNotFoundOrThrow, isNonArrayObject, parseArgument, parseResponse, RequestQueuePaginationIterator, splitIntoJsonArrayBatches, utf8ByteLength, } from '../utils.js';
9
9
  const DEFAULT_PARALLEL_BATCH_ADD_REQUESTS = 5;
10
10
  const DEFAULT_UNPROCESSED_RETRIES_BATCH_ADD_REQUESTS = 3;
11
11
  const DEFAULT_MIN_DELAY_BETWEEN_UNPROCESSED_REQUESTS_RETRIES_MILLIS = 500;
@@ -21,7 +21,6 @@ const listAndLockHeadOptionsSchema = z.strictObject({
21
21
  // every key of every request. `id` is assigned by the API, so a new request must not carry one.
22
22
  const newRequestSchema = z.custom((value) => isNonArrayObject(value) && value.id === undefined, 'Expected a request object without an `id`');
23
23
  const forefrontOptionsSchema = z.strictObject({ forefront: z.boolean().optional(), ...timeoutOptionsShape });
24
- const batchAddRequestsSchema = z.array(newRequestSchema).min(1).max(REQUEST_QUEUE_MAX_REQUESTS_PER_BATCH_OPERATION);
25
24
  const batchAddRequestsWithRetriesSchema = z.array(newRequestSchema).min(1);
26
25
  const optionalBooleanSchema = z.boolean().optional();
27
26
  const optionalNumberSchema = z.number().optional();
@@ -277,13 +276,15 @@ export class RequestQueueClient extends ResourceClient {
277
276
  * @private
278
277
  */
279
278
  async addRequestBatch(requests, options = {}) {
280
- parseArgument(requests, batchAddRequestsSchema);
281
279
  const parsed = parseArgument(options, forefrontOptionsSchema, 'RequestQueueClientAddRequestOptions');
282
280
  const response = await this.httpClient.call({
283
281
  url: this.buildUrl('requests/batch'),
284
282
  method: 'POST',
285
283
  timeoutSecs: this.#resolveTimeout(parsed.timeoutSecs, 'medium'),
286
- data: requests,
284
+ // The body is assembled from the requests as `batchAddRequests` serialized them; the explicit content type
285
+ // makes the request interceptor send the string as it is instead of serializing the requests again.
286
+ headers: { 'content-type': 'application/json' },
287
+ data: `[${requests.map(({ json }) => json).join(',')}]`,
287
288
  params: this.buildParams({
288
289
  forefront: parsed.forefront,
289
290
  clientKey: this.#clientKey,
@@ -316,7 +317,7 @@ export class RequestQueueClient extends ResourceClient {
316
317
  // Get unique keys of all requests processed so far
317
318
  const processedRequestsUniqueKeys = processedRequests.map(({ uniqueKey }) => uniqueKey);
318
319
  // Requests remaining to be processed are the all that remain
319
- remainingRequests = requests.filter(({ uniqueKey }) => !processedRequestsUniqueKeys.includes(uniqueKey));
320
+ remainingRequests = requests.filter(({ request }) => !processedRequestsUniqueKeys.includes(request.uniqueKey));
320
321
  // Stop if all requests have been processed
321
322
  if (remainingRequests.length === 0) {
322
323
  break;
@@ -332,8 +333,8 @@ export class RequestQueueClient extends ResourceClient {
332
333
  // This ensures that this method does not throw and keeps the signature.
333
334
  const processedRequestsUniqueKeys = processedRequests.map(({ uniqueKey }) => uniqueKey);
334
335
  unprocessedRequests = requests
335
- .filter(({ uniqueKey }) => !processedRequestsUniqueKeys.includes(uniqueKey))
336
- .map(({ method, uniqueKey, url }) => ({ method, uniqueKey, url }));
336
+ .filter(({ request }) => !processedRequestsUniqueKeys.includes(request.uniqueKey))
337
+ .map(({ request: { method, uniqueKey, url } }) => ({ method, uniqueKey, url }));
337
338
  break;
338
339
  }
339
340
  // Exponential backoff
@@ -394,12 +395,25 @@ export class RequestQueueClient extends ResourceClient {
394
395
  const executingRequests = new Set();
395
396
  const individualResults = [];
396
397
  const payloadSizeLimitBytes = MAX_PAYLOAD_SIZE_BYTES - Math.ceil(MAX_PAYLOAD_SIZE_BYTES * SAFETY_BUFFER_PERCENT);
398
+ // Serialize every request once: the byte lengths decide the batch boundaries, and the same strings are joined
399
+ // into the batch bodies, so nothing is stringified a second time when it is sent.
400
+ const serializedRequests = requests.map((request, index) => {
401
+ const json = JSON.stringify(request);
402
+ const byteLength = utf8ByteLength(json);
403
+ // Two more bytes for the brackets, which even a batch of one request carries.
404
+ if (byteLength + 2 > payloadSizeLimitBytes) {
405
+ throw new Error(`RequestQueueClient.batchAddRequests: The size of the request with index: ${index} ` +
406
+ `exceeds the maximum allowed size (${payloadSizeLimitBytes} bytes).`);
407
+ }
408
+ return { request, json, byteLength };
409
+ });
410
+ const batches = splitIntoJsonArrayBatches(serializedRequests, {
411
+ maxCount: REQUEST_QUEUE_MAX_REQUESTS_PER_BATCH_OPERATION,
412
+ maxByteLength: payloadSizeLimitBytes,
413
+ });
397
414
  // Keep a pool of up to `maxParallel` requests running at once
398
- let i = 0;
399
- while (i < requests.length) {
400
- const slicedRequests = requests.slice(i, i + REQUEST_QUEUE_MAX_REQUESTS_PER_BATCH_OPERATION);
401
- const requestsInBatch = sliceArrayByByteLength(slicedRequests, payloadSizeLimitBytes, i);
402
- const requestPromise = this.addRequestBatchWithRetries(requestsInBatch, options);
415
+ for (const batch of batches) {
416
+ const requestPromise = this.addRequestBatchWithRetries(batch, options);
403
417
  executingRequests.add(requestPromise);
404
418
  // A rejection reaches the caller through the awaits below; this bookkeeping chain only has to avoid
405
419
  // turning it into an unhandled one of its own.
@@ -410,7 +424,6 @@ export class RequestQueueClient extends ResourceClient {
410
424
  if (executingRequests.size >= maxParallel) {
411
425
  await Promise.race(executingRequests);
412
426
  }
413
- i += requestsInBatch.length;
414
427
  }
415
428
  // Get results from remaining operations
416
429
  await Promise.all(executingRequests);
package/dist/utils.d.ts CHANGED
@@ -79,9 +79,21 @@ export declare function isCompressibleContentType(contentType?: string): boolean
79
79
  */
80
80
  export declare function maybeCompressValue(value: unknown): Promise<CompressedValue | undefined>;
81
81
  /**
82
- * Helper function slice the items from array to fit the max byte length.
82
+ * Returns the UTF-8 byte length of a string.
83
83
  */
84
- export declare function sliceArrayByByteLength<T>(array: T[], maxByteLength: number, startIndex: number): T[];
84
+ export declare function utf8ByteLength(value: string): number;
85
+ /**
86
+ * Splits JSON-serialized items into consecutive batches of at most `maxCount` items, each of which fits into a JSON
87
+ * array body - the items joined by commas between brackets - of at most `maxByteLength` bytes. The `byteLength` of an
88
+ * 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
89
+ * caller that cannot send such an item has to reject it beforehand.
90
+ */
91
+ export declare function splitIntoJsonArrayBatches<T extends {
92
+ byteLength: number;
93
+ }>(items: readonly T[], { maxCount, maxByteLength }: {
94
+ maxCount: number;
95
+ maxByteLength: number;
96
+ }): T[][];
85
97
  export declare function isNode(): boolean;
86
98
  export declare function isBuffer(value: unknown): value is Buffer | ArrayBuffer | TypedArray;
87
99
  export declare function isStream(value: unknown): value is Readable;
package/dist/utils.js CHANGED
@@ -213,28 +213,35 @@ export async function maybeCompressValue(value) {
213
213
  }
214
214
  }
215
215
  /**
216
- * Helper function slice the items from array to fit the max byte length.
216
+ * Returns the UTF-8 byte length of a string.
217
217
  */
218
- export function sliceArrayByByteLength(array, maxByteLength, startIndex) {
219
- const stringByteLength = (str) => (isNode() ? Buffer.byteLength(str) : new Blob([str]).size);
220
- const arrayByteLength = stringByteLength(JSON.stringify(array));
221
- if (arrayByteLength < maxByteLength)
222
- return array;
223
- const slicedArray = [];
224
- let byteLength = 2; // 2 bytes for the empty array []
225
- for (let i = 0; i < array.length; i++) {
226
- const item = array[i];
227
- const itemByteSize = stringByteLength(JSON.stringify(item));
228
- if (itemByteSize > maxByteLength) {
229
- throw new Error(`RequestQueueClient.batchAddRequests: The size of the request with index: ${startIndex + i} ` +
230
- `exceeds the maximum allowed size (${maxByteLength} bytes).`);
218
+ export function utf8ByteLength(value) {
219
+ return isNode() ? Buffer.byteLength(value) : new Blob([value]).size;
220
+ }
221
+ /**
222
+ * Splits JSON-serialized items into consecutive batches of at most `maxCount` items, each of which fits into a JSON
223
+ * array body - the items joined by commas between brackets - of at most `maxByteLength` bytes. The `byteLength` of an
224
+ * 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
225
+ * caller that cannot send such an item has to reject it beforehand.
226
+ */
227
+ export function splitIntoJsonArrayBatches(items, { maxCount, maxByteLength }) {
228
+ const batches = [];
229
+ let batch = [];
230
+ // One byte for the opening bracket; each item then adds its own bytes plus one for the comma or the closing
231
+ // bracket that follows it.
232
+ let byteLength = 1;
233
+ for (const item of items) {
234
+ if (batch.length > 0 && (batch.length >= maxCount || byteLength + item.byteLength + 1 > maxByteLength)) {
235
+ batches.push(batch);
236
+ batch = [];
237
+ byteLength = 1;
231
238
  }
232
- if (byteLength + itemByteSize >= maxByteLength)
233
- break;
234
- byteLength += itemByteSize;
235
- slicedArray.push(item);
239
+ batch.push(item);
240
+ byteLength += item.byteLength + 1;
236
241
  }
237
- return slicedArray;
242
+ if (batch.length > 0)
243
+ batches.push(batch);
244
+ return batches;
238
245
  }
239
246
  export function isNode() {
240
247
  if (typeof BROWSER_BUILD !== 'undefined')
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "apify-client",
3
- "version": "3.0.0-beta.19",
3
+ "version": "3.0.0-beta.20",
4
4
  "description": "Apify API client for JavaScript",
5
5
  "engines": {
6
6
  "node": ">=22.0.0"