apify-client 3.0.0-beta.5 → 3.0.0-beta.51

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.
Files changed (115) hide show
  1. package/README.md +13 -3
  2. package/dist/apify_api_error.d.ts +62 -21
  3. package/dist/apify_api_error.js +96 -23
  4. package/dist/apify_client.d.ts +125 -10
  5. package/dist/apify_client.js +133 -48
  6. package/dist/base/api_client.d.ts +7 -7
  7. package/dist/base/api_client.js +36 -24
  8. package/dist/base/resource_client.d.ts +21 -8
  9. package/dist/base/resource_client.js +54 -26
  10. package/dist/base/resource_collection_client.d.ts +12 -4
  11. package/dist/base/resource_collection_client.js +34 -11
  12. package/dist/body_parser.d.ts +3 -4
  13. package/dist/body_parser.js +19 -26
  14. package/dist/bundle.js +27 -22
  15. package/dist/bundle.js.map +1 -1
  16. package/dist/generated/api.d.ts +610 -112
  17. package/dist/generated/schemas.d.ts +726 -703
  18. package/dist/generated/schemas.js +92 -85
  19. package/dist/http_clients/axios.d.ts +60 -0
  20. package/dist/http_clients/axios.js +117 -0
  21. package/dist/http_clients/base.d.ts +333 -0
  22. package/dist/http_clients/base.js +563 -0
  23. package/dist/http_clients/index.d.ts +4 -0
  24. package/dist/http_clients/index.js +2 -0
  25. package/dist/http_compressors/base.d.ts +40 -0
  26. package/dist/http_compressors/base.js +9 -0
  27. package/dist/http_compressors/brotli.d.ts +33 -0
  28. package/dist/http_compressors/brotli.js +39 -0
  29. package/dist/http_compressors/gzip.d.ts +33 -0
  30. package/dist/http_compressors/gzip.js +39 -0
  31. package/dist/http_compressors/index.d.ts +6 -0
  32. package/dist/http_compressors/index.js +2 -0
  33. package/dist/http_compressors/resolve.d.ts +23 -0
  34. package/dist/http_compressors/resolve.js +27 -0
  35. package/dist/index.d.ts +4 -1
  36. package/dist/index.js +3 -1
  37. package/dist/invalid_response_body_error.d.ts +13 -0
  38. package/dist/invalid_response_body_error.js +17 -0
  39. package/dist/models.d.ts +19 -22
  40. package/dist/resource_clients/actor.d.ts +72 -32
  41. package/dist/resource_clients/actor.js +87 -56
  42. package/dist/resource_clients/actor_collection.d.ts +9 -5
  43. package/dist/resource_clients/actor_collection.js +11 -3
  44. package/dist/resource_clients/actor_env_var.d.ts +10 -3
  45. package/dist/resource_clients/actor_env_var.js +16 -6
  46. package/dist/resource_clients/actor_env_var_collection.d.ts +14 -18
  47. package/dist/resource_clients/actor_env_var_collection.js +17 -11
  48. package/dist/resource_clients/actor_version.d.ts +18 -3
  49. package/dist/resource_clients/actor_version.js +19 -9
  50. package/dist/resource_clients/actor_version_collection.d.ts +16 -17
  51. package/dist/resource_clients/actor_version_collection.js +19 -11
  52. package/dist/resource_clients/build.d.ts +18 -6
  53. package/dist/resource_clients/build.js +34 -15
  54. package/dist/resource_clients/build_collection.d.ts +3 -1
  55. package/dist/resource_clients/build_collection.js +4 -1
  56. package/dist/resource_clients/dataset.d.ts +51 -33
  57. package/dist/resource_clients/dataset.js +105 -80
  58. package/dist/resource_clients/dataset_collection.d.ts +6 -2
  59. package/dist/resource_clients/dataset_collection.js +13 -2
  60. package/dist/resource_clients/key_value_store.d.ts +58 -16
  61. package/dist/resource_clients/key_value_store.js +78 -40
  62. package/dist/resource_clients/key_value_store_collection.d.ts +6 -2
  63. package/dist/resource_clients/key_value_store_collection.js +13 -2
  64. package/dist/resource_clients/log.d.ts +12 -20
  65. package/dist/resource_clients/log.js +93 -58
  66. package/dist/resource_clients/request_queue.d.ts +56 -26
  67. package/dist/resource_clients/request_queue.js +176 -103
  68. package/dist/resource_clients/request_queue_collection.d.ts +6 -2
  69. package/dist/resource_clients/request_queue_collection.js +9 -3
  70. package/dist/resource_clients/run.d.ts +108 -18
  71. package/dist/resource_clients/run.js +183 -43
  72. package/dist/resource_clients/run_collection.d.ts +3 -1
  73. package/dist/resource_clients/run_collection.js +4 -1
  74. package/dist/resource_clients/schedule.d.ts +14 -5
  75. package/dist/resource_clients/schedule.js +29 -21
  76. package/dist/resource_clients/schedule_collection.d.ts +6 -2
  77. package/dist/resource_clients/schedule_collection.js +9 -3
  78. package/dist/resource_clients/store_collection.d.ts +3 -1
  79. package/dist/resource_clients/store_collection.js +4 -1
  80. package/dist/resource_clients/task.d.ts +33 -10
  81. package/dist/resource_clients/task.js +79 -46
  82. package/dist/resource_clients/task_collection.d.ts +6 -2
  83. package/dist/resource_clients/task_collection.js +9 -3
  84. package/dist/resource_clients/user.d.ts +17 -8
  85. package/dist/resource_clients/user.js +42 -38
  86. package/dist/resource_clients/webhook.d.ts +14 -5
  87. package/dist/resource_clients/webhook.js +30 -22
  88. package/dist/resource_clients/webhook_collection.d.ts +6 -2
  89. package/dist/resource_clients/webhook_collection.js +9 -3
  90. package/dist/resource_clients/webhook_dispatch.d.ts +4 -1
  91. package/dist/resource_clients/webhook_dispatch.js +7 -2
  92. package/dist/resource_clients/webhook_dispatch_collection.d.ts +3 -1
  93. package/dist/resource_clients/webhook_dispatch_collection.js +4 -1
  94. package/dist/runtime/node.d.ts +6 -0
  95. package/dist/runtime/node.js +49 -0
  96. package/dist/runtime/types.d.ts +52 -0
  97. package/dist/runtime/types.js +1 -0
  98. package/dist/runtime/web.d.ts +5 -0
  99. package/dist/runtime/web.js +18 -0
  100. package/dist/schemas.d.ts +1 -1
  101. package/dist/schemas.js +1 -1
  102. package/dist/statistics.d.ts +1 -6
  103. package/dist/statistics.js +2 -2
  104. package/dist/timeouts.d.ts +84 -0
  105. package/dist/timeouts.js +37 -0
  106. package/dist/timezones.d.ts +1 -1
  107. package/dist/utils.d.ts +81 -52
  108. package/dist/utils.js +201 -170
  109. package/dist/wait_for_resources.d.ts +8 -0
  110. package/dist/wait_for_resources.js +39 -0
  111. package/package.json +32 -29
  112. package/dist/http_client.d.ts +0 -88
  113. package/dist/http_client.js +0 -255
  114. package/dist/interceptors.d.ts +0 -19
  115. package/dist/interceptors.js +0 -105
@@ -0,0 +1,563 @@
1
+ import { z } from 'zod';
2
+ import { APIFY_ENV_VARS } from '@apify/consts';
3
+ import log from '@apify/log';
4
+ import { concatStreamToBuffer } from '@apify/utilities';
5
+ import { ApifyApiError } from '../apify_api_error.js';
6
+ import { maybeParseBody } from '../body_parser.js';
7
+ import { resolveCompressor } from '../http_compressors/resolve.js';
8
+ import { InvalidResponseBodyError } from '../invalid_response_body_error.js';
9
+ import { Statistics } from '../statistics.js';
10
+ import { DEFAULT_TIMEOUT_LONG_SECS, DEFAULT_TIMEOUT_MAX_SECS, DEFAULT_TIMEOUT_MEDIUM_SECS, DEFAULT_TIMEOUT_SHORT_SECS, } from '../timeouts.js';
11
+ import { runtime } from '#runtime';
12
+ import { asArray, getEnv, isBuffer, isCompressibleContentType, isStream, MIN_COMPRESS_BYTES, parseArgument, sleep, toBytes, version, } from '../utils.js';
13
+ export const DEFAULT_MAX_RETRIES = 8;
14
+ export const DEFAULT_MIN_DELAY_BETWEEN_RETRIES_MILLIS = 500;
15
+ const RATE_LIMIT_EXCEEDED_STATUS_CODE = 429;
16
+ const CONTENT_TYPE_JSON = 'application/json';
17
+ const CONTENT_TYPE_FORM_URLENCODED = 'application/x-www-form-urlencoded';
18
+ /**
19
+ * Bounds of the numeric {@link HttpClientOptions}. `ApifyClientOptions` checks the same bounds and adds the defaults.
20
+ * @internal
21
+ */
22
+ export const httpClientOptionsShape = {
23
+ maxRetries: z.number().int().nonnegative(),
24
+ minDelayBetweenRetriesMillis: z.number().nonnegative(),
25
+ timeoutShortSecs: z.number().positive(),
26
+ timeoutMediumSecs: z.number().positive(),
27
+ timeoutLongSecs: z.number().positive(),
28
+ timeoutMaxSecs: z.number().positive(),
29
+ };
30
+ const httpClientOptionsSchema = z.looseObject(httpClientOptionsShape).partial();
31
+ /** Objects whose payload does not live in their own enumerable keys, so JSON serialization loses it. */
32
+ const UNSERIALIZABLE_OBJECT_TAGS = new Set(['Blob', 'File', 'FormData', 'ReadableStream']);
33
+ /**
34
+ * Base class for the HTTP clients used by {@link ApifyClient}.
35
+ *
36
+ * It holds the shared request pipeline: {@link call} merges the default headers in, serializes and compresses the
37
+ * body, encodes the query parameters, retries transient failures with exponential backoff, grows the timeout with
38
+ * every attempt, decodes the response body by content type, records statistics and converts error statuses to
39
+ * {@link ApifyApiError}. A concrete client only has to implement {@link sendRequest}, the transport that moves one
40
+ * prepared request over the wire, and can override the classification and lifecycle hooks {@link isTimeoutError},
41
+ * {@link isRetryableTransportError} and {@link close}.
42
+ *
43
+ * Overriding {@link call} itself is also supported and bypasses the transport hooks entirely. Such a client has
44
+ * to send the default headers from `defaultHeaders` with every request, otherwise the `Authorization` header never
45
+ * reaches the API. The protected helpers `prepareRequest()`, `buildUrl()` and `computeTimeoutMillis()` stay
46
+ * available to it.
47
+ *
48
+ * @example
49
+ * ```javascript
50
+ * import { ApifyClient, HttpClient } from 'apify-client';
51
+ *
52
+ * class FetchHttpClient extends HttpClient {
53
+ * async sendRequest({ method, url, headers, body, timeoutMillis, signal }) {
54
+ * const signals = signal ? [signal] : [];
55
+ * if (timeoutMillis !== undefined) signals.push(AbortSignal.timeout(timeoutMillis));
56
+ * const response = await fetch(url, { method, headers, body, signal: AbortSignal.any(signals) });
57
+ * return {
58
+ * status: response.status,
59
+ * headers: Object.fromEntries(response.headers),
60
+ * body: Buffer.from(await response.arrayBuffer()),
61
+ * };
62
+ * }
63
+ *
64
+ * isRetryableTransportError(error) {
65
+ * // Without this the client gives up on the first connection failure.
66
+ * return this.isTimeoutError(error) || error instanceof TypeError;
67
+ * }
68
+ * }
69
+ *
70
+ * const client = ApifyClient.withCustomHttpClient({ token: 'my-token', httpClient: new FetchHttpClient() });
71
+ * ```
72
+ */
73
+ export class HttpClient {
74
+ /** Statistics of the API calls made through this client. */
75
+ stats;
76
+ /** Logger for the retry warnings. */
77
+ logger;
78
+ /** How many times a failed request is retried at most. */
79
+ maxRetries;
80
+ /** Lower bound for the delay before the first retry in milliseconds. It doubles with every further retry. */
81
+ minDelayBetweenRetriesMillis;
82
+ /** Duration of each timeout tier, in milliseconds. */
83
+ timeoutMillis;
84
+ /** Upper bound for the timeout of a single attempt, in milliseconds. */
85
+ timeoutMaxMillis;
86
+ /** Compressor the request pipeline runs the bodies worth compressing through. */
87
+ httpCompressor;
88
+ /**
89
+ * Headers sent with every request: the `Authorization` header built from the token, the `User-Agent` where the
90
+ * runtime lets a client set one, the `X-Apify-Workflow-Key` when a workflow key is configured, and whatever
91
+ * {@link HttpClientOptions.headers} added.
92
+ */
93
+ defaultHeaders;
94
+ /**
95
+ * @param options - Configuration of the pipeline. The retry and timeout options are validated the same way the
96
+ * `ApifyClient` constructor validates them.
97
+ * @throws {ArgumentValidationError} When a retry or timeout option is out of bounds.
98
+ */
99
+ constructor(options = {}) {
100
+ parseArgument(options, httpClientOptionsSchema, 'HttpClientOptions');
101
+ this.stats = options.stats ?? new Statistics();
102
+ this.logger = options.logger ?? log.child({ prefix: 'ApifyClient' });
103
+ this.maxRetries = options.maxRetries ?? DEFAULT_MAX_RETRIES;
104
+ this.minDelayBetweenRetriesMillis =
105
+ options.minDelayBetweenRetriesMillis ?? DEFAULT_MIN_DELAY_BETWEEN_RETRIES_MILLIS;
106
+ this.timeoutMillis = {
107
+ short: (options.timeoutShortSecs ?? DEFAULT_TIMEOUT_SHORT_SECS) * 1000,
108
+ medium: (options.timeoutMediumSecs ?? DEFAULT_TIMEOUT_MEDIUM_SECS) * 1000,
109
+ long: (options.timeoutLongSecs ?? DEFAULT_TIMEOUT_LONG_SECS) * 1000,
110
+ };
111
+ this.timeoutMaxMillis = (options.timeoutMaxSecs ?? DEFAULT_TIMEOUT_MAX_SECS) * 1000;
112
+ this.httpCompressor = resolveCompressor(options.compression ?? 'brotli');
113
+ const defaults = {};
114
+ const workflowKey = options.workflowKey || getEnv(APIFY_ENV_VARS.WORKFLOW_KEY);
115
+ if (workflowKey)
116
+ defaults['X-Apify-Workflow-Key'] = workflowKey;
117
+ // Browsers do not let a page set the header, so it only goes out where the runtime describes its platform.
118
+ if (runtime.platform) {
119
+ const isAtHome = !!getEnv(APIFY_ENV_VARS.IS_AT_HOME);
120
+ let userAgent = `ApifyClient/${version} (${runtime.platform}); isAtHome/${isAtHome}`;
121
+ if (options.userAgentSuffix)
122
+ userAgent += `; ${asArray(options.userAgentSuffix).join('; ')}`;
123
+ defaults['User-Agent'] = userAgent;
124
+ }
125
+ if (options.token)
126
+ defaults.Authorization = `Bearer ${options.token}`;
127
+ this.defaultHeaders = mergeHeaders(defaults, options.headers);
128
+ }
129
+ /**
130
+ * Sets the `Authorization` header from the token, unless an authorization header is already configured.
131
+ *
132
+ * @param token - The Apify API token to send as the `Bearer` token.
133
+ */
134
+ setDefaultAuthorization(token) {
135
+ if (getHeader(this.defaultHeaders, 'authorization') === undefined) {
136
+ this.defaultHeaders.Authorization = `Bearer ${token}`;
137
+ }
138
+ }
139
+ /**
140
+ * Whether an error thrown by the transport is a timeout.
141
+ *
142
+ * Recognizes errors named `TimeoutError`, which is what `AbortSignal.timeout()` produces. Transports extend it
143
+ * with the timeout errors their HTTP library throws. The classification is independent of retryability: a
144
+ * timeout the retry loop should retry has to be covered by {@link isRetryableTransportError} too.
145
+ */
146
+ isTimeoutError(error) {
147
+ return error instanceof Error && error.name === 'TimeoutError';
148
+ }
149
+ /**
150
+ * Whether an error thrown by the transport is worth retrying.
151
+ *
152
+ * The default classifies nothing as retryable, so a transport that does not override it gives up on the first
153
+ * connection failure. Every transport should map its own transient errors here: connection resets, refused
154
+ * connections, timeouts and the like. Error responses are not the transport's concern, the pipeline decides on
155
+ * them from the status code.
156
+ */
157
+ isRetryableTransportError(_error) {
158
+ return false;
159
+ }
160
+ /**
161
+ * Releases resources owned by the transport, such as a connection pool. The default does nothing.
162
+ */
163
+ async close() {
164
+ // Nothing to release without a transport of its own.
165
+ }
166
+ /**
167
+ * Sends one prepared request through the underlying HTTP library.
168
+ *
169
+ * The inherited {@link call} needs it, so every transport has to implement it. Let the library's errors
170
+ * propagate unwrapped: {@link call} classifies them through {@link isRetryableTransportError} and
171
+ * {@link isTimeoutError}. Return error responses as they are too, the pipeline turns them into
172
+ * {@link ApifyApiError} and decides whether to retry.
173
+ *
174
+ * @param request - The request to send, with the headers merged, the body serialized and the query encoded.
175
+ * @returns The response, with the body unread when `request.stream` is set and as raw bytes otherwise.
176
+ */
177
+ async sendRequest(request) {
178
+ throw new Error(`Implement sendRequest() to send ${request.method} ${request.url}, or override call() entirely.`);
179
+ }
180
+ /**
181
+ * Makes an API request with automatic retries and exponential backoff.
182
+ *
183
+ * Network errors the transport classifies as retryable, rate limits (HTTP 429) and server errors (HTTP 5xx)
184
+ * are retried up to {@link maxRetries} times. Any other error status is thrown as {@link ApifyApiError} right
185
+ * away. A request whose body is a `Readable` is never retried, since part of the stream has already been
186
+ * consumed by the time the failure shows. Aborting `config.signal` ends the attempt in flight, skips the
187
+ * remaining retries and rejects the call with the signal's `reason`.
188
+ *
189
+ * @template T - Type of the parsed response body.
190
+ * @param config - The request to make.
191
+ * @returns The successful response.
192
+ * @throws {ApifyApiError} When the API responds with an error status the retries could not fix.
193
+ */
194
+ async call(config) {
195
+ config.signal?.throwIfAborted();
196
+ this.stats.calls++;
197
+ const { headers, body } = await this.prepareRequest(config);
198
+ const url = this.buildUrl(config.url, config.params);
199
+ return this.#retryWithExpBackoff(async (stopRetrying, attempt) => this.#makeRequest({ config, url, headers, body, attempt, stopRetrying }), config.signal);
200
+ }
201
+ /**
202
+ * Prepares the headers and the body of a request.
203
+ *
204
+ * Merges the client's default headers with the per-request ones, header names compared case-insensitively and
205
+ * the per-request values winning. Serializes an object body to JSON, setting `Content-Type: application/json`
206
+ * unless the caller supplied a content type. Runs the body through {@link httpCompressor} unless a
207
+ * `Content-Encoding` header is already set, the body is too small, or its content type says the payload is
208
+ * already compressed. A caller-supplied `Content-Encoding` is forwarded as it is, which is how a pre-encoded
209
+ * body is uploaded, and `Content-Encoding: identity` opts a single request out of compression.
210
+ */
211
+ async prepareRequest(config) {
212
+ let headers = mergeHeaders(this.defaultHeaders, config.headers);
213
+ let body = serializeBody(config.data, headers, config.stringifyFunctions);
214
+ const bytes = compressibleBytes(body, headers);
215
+ if (bytes) {
216
+ body = await this.httpCompressor.compress(Buffer.from(bytes.buffer, bytes.byteOffset, bytes.byteLength));
217
+ // Compressing changes the body length, so a `Content-Length` the caller set describes the wrong body
218
+ // and would leave the server waiting for bytes that never arrive.
219
+ headers = mergeHeaders(headers, { 'Content-Encoding': this.httpCompressor.contentEncoding });
220
+ deleteHeader(headers, 'content-length');
221
+ }
222
+ return { headers, body };
223
+ }
224
+ /**
225
+ * Appends the query parameters to the URL. `undefined` values are dropped, booleans are sent as `1` and `0`,
226
+ * `Date` values as ISO 8601 strings and arrays comma-separated.
227
+ */
228
+ buildUrl(url, params) {
229
+ const pairs = [];
230
+ for (const [key, value] of Object.entries(params ?? {})) {
231
+ if (value === undefined)
232
+ continue;
233
+ if (value instanceof Date)
234
+ pairs.push([key, value.toISOString()]);
235
+ else if (typeof value === 'boolean')
236
+ pairs.push([key, String(Number(value))]);
237
+ else
238
+ pairs.push([key, String(value)]);
239
+ }
240
+ if (pairs.length === 0)
241
+ return url;
242
+ const query = new URLSearchParams(pairs).toString();
243
+ return `${url}${url.includes('?') ? '&' : '?'}${query}`;
244
+ }
245
+ /**
246
+ * Resolves `timeoutSecs` to the number of milliseconds the given attempt gets. A tier name resolves to its
247
+ * configured duration, a number is taken as seconds, and `'noTimeout'` becomes `undefined`. The result doubles
248
+ * with each attempt and is capped at `timeoutMaxMillis`. A requested value above the cap is capped too, which
249
+ * warns once, since it does not take effect in full.
250
+ *
251
+ * @param attempt - Current attempt number, starting at 1.
252
+ * @param timeoutSecs - The request's timeout. Defaults to the `medium` tier.
253
+ */
254
+ computeTimeoutMillis(attempt, timeoutSecs = 'medium') {
255
+ if (timeoutSecs === 'noTimeout')
256
+ return undefined;
257
+ const requestedMillis = typeof timeoutSecs === 'number' ? timeoutSecs * 1000 : this.timeoutMillis[timeoutSecs];
258
+ if (requestedMillis > this.timeoutMaxMillis) {
259
+ // `warningOnce` keys by message, so each requested value warns once.
260
+ this.logger.warningOnce(`The requested timeout of ${requestedMillis / 1000}s exceeds timeoutMaxSecs ` +
261
+ `(${this.timeoutMaxMillis / 1000}s) and is capped at it. ` +
262
+ 'Raise timeoutMaxSecs on the client to allow longer request timeouts.');
263
+ }
264
+ return Math.min(requestedMillis * 2 ** (attempt - 1), this.timeoutMaxMillis);
265
+ }
266
+ /**
267
+ * Retries `fn` with randomized exponential backoff until it resolves, `stopRetrying()` was called before it
268
+ * threw, the retries are exhausted, or `signal` aborts. The last attempt's error propagates as it is, and an
269
+ * abort throws the signal's `reason`, whether it lands during an attempt or during the wait before the next.
270
+ */
271
+ async #retryWithExpBackoff(fn, signal) {
272
+ let retry = true;
273
+ const stopRetrying = () => {
274
+ retry = false;
275
+ };
276
+ for (let attempt = 1; attempt <= this.maxRetries; attempt++) {
277
+ signal?.throwIfAborted();
278
+ try {
279
+ return await fn(stopRetrying, attempt);
280
+ }
281
+ catch (err) {
282
+ // A transport that does not honor the signal may still answer the aborted attempt with an error
283
+ // status, and the caller expects the reason for that too.
284
+ signal?.throwIfAborted();
285
+ if (!retry)
286
+ throw err;
287
+ this.#onRequestRetry(err, attempt);
288
+ }
289
+ // The delay doubles with every attempt and is spread over a random factor between 1 and 2, so that
290
+ // callers that failed together do not come back at the API in lockstep on every retry.
291
+ const delayMillis = this.minDelayBetweenRetriesMillis * 2 ** (attempt - 1) * (1 + Math.random());
292
+ await sleep(delayMillis, signal);
293
+ }
294
+ signal?.throwIfAborted();
295
+ return fn(stopRetrying, this.maxRetries + 1);
296
+ }
297
+ /**
298
+ * Executes one attempt: sends the request, decodes the body and turns an error status into {@link ApifyApiError}.
299
+ * Throws for anything that is not a success, and flags the failure as final through `stopRetrying()` when a
300
+ * retry could not fix it.
301
+ */
302
+ async #makeRequest(options) {
303
+ const { config, url, headers, body, attempt, stopRetrying } = options;
304
+ this.stats.requests++;
305
+ const requestIsStream = isStream(config.data);
306
+ let response;
307
+ try {
308
+ response = await this.sendRequest({
309
+ method: config.method,
310
+ url,
311
+ headers,
312
+ body,
313
+ timeoutMillis: this.computeTimeoutMillis(attempt, config.timeoutSecs),
314
+ stream: config.responseType === 'stream',
315
+ signal: config.signal,
316
+ });
317
+ }
318
+ catch (err) {
319
+ // Every HTTP library reports an abort with an error of its own, so the caller gets the reason instead.
320
+ config.signal?.throwIfAborted();
321
+ this.#handleRequestError(err, config, stopRetrying);
322
+ throw err;
323
+ }
324
+ // A failed streaming request carries the API error body in the stream, so read it and let
325
+ // `ApifyApiError` parse it like any other error body. A body that cannot be read leaves the error
326
+ // without a message, which beats losing the status code to a stream error.
327
+ if (response.status >= 300 && isStream(response.body)) {
328
+ response.body = await concatStreamToBuffer(response.body).catch(() => undefined);
329
+ }
330
+ let data;
331
+ try {
332
+ data = this.#parseResponseBody(response, config);
333
+ }
334
+ catch (err) {
335
+ // A body that does not parse is usually a connection dropped mid-response, which a retry fixes.
336
+ if (requestIsStream) {
337
+ this.#informAboutStreamNoRetry();
338
+ stopRetrying();
339
+ }
340
+ throw err;
341
+ }
342
+ const apifyResponse = {
343
+ status: response.status,
344
+ headers: response.headers,
345
+ data: data,
346
+ config,
347
+ };
348
+ if (response.status < 300)
349
+ return apifyResponse;
350
+ if (response.status === RATE_LIMIT_EXCEEDED_STATUS_CODE) {
351
+ this.stats.addRateLimitError(attempt);
352
+ }
353
+ const apiError = ApifyApiError.fromResponse(apifyResponse, attempt);
354
+ if (!this.#isStatusCodeRetryable(response.status)) {
355
+ stopRetrying();
356
+ }
357
+ else if (requestIsStream) {
358
+ this.#informAboutStreamNoRetry();
359
+ stopRetrying();
360
+ }
361
+ throw apiError;
362
+ }
363
+ /**
364
+ * Decodes the response body according to the request's `responseType`. An empty body decodes to `undefined`.
365
+ *
366
+ * @throws {InvalidResponseBodyError} When the body does not parse as its content type claims.
367
+ */
368
+ #parseResponseBody(response, config) {
369
+ const { body } = response;
370
+ if (config.responseType === 'stream' || config.responseType === 'buffer')
371
+ return body;
372
+ if (body === undefined || isStream(body))
373
+ return body;
374
+ if (body.byteLength === 0)
375
+ return undefined;
376
+ try {
377
+ return maybeParseBody(body, headerValue(response.headers['content-type']) ?? '');
378
+ }
379
+ catch (err) {
380
+ throw new InvalidResponseBodyError(response, err);
381
+ }
382
+ }
383
+ /**
384
+ * Decides whether a transport error ends the call. Timeouts the request opted out of retrying and errors the
385
+ * transport does not classify as retryable are final, and so is any error on a request with a stream body.
386
+ */
387
+ #handleRequestError(err, config, stopRetrying) {
388
+ if (config.doNotRetryTimeouts && this.isTimeoutError(err)) {
389
+ stopRetrying();
390
+ return;
391
+ }
392
+ if (!this.isRetryableTransportError(err)) {
393
+ stopRetrying();
394
+ return;
395
+ }
396
+ if (isStream(config.data)) {
397
+ this.#informAboutStreamNoRetry();
398
+ stopRetrying();
399
+ }
400
+ }
401
+ /**
402
+ * Rate limits (429) and server errors (500+) are retried. Anything else in 300-499 is a redirect the client
403
+ * cannot follow or invalid input, which repeating the request cannot fix.
404
+ */
405
+ #isStatusCodeRetryable(statusCode) {
406
+ return statusCode === RATE_LIMIT_EXCEEDED_STATUS_CODE || statusCode >= 500;
407
+ }
408
+ #informAboutStreamNoRetry() {
409
+ this.logger.warningOnce('Request body was a stream - retrying will not work, as part of it was already consumed.');
410
+ this.logger.warningOnce('If you want Apify client to handle retries for you, collect the stream into a buffer before sending it.');
411
+ }
412
+ #onRequestRetry(error, attempt) {
413
+ if (attempt === Math.round(this.maxRetries / 2)) {
414
+ this.logger.warning(`API request failed ${attempt} times. Max attempts: ${this.maxRetries + 1}.\nCause:${error.stack}`);
415
+ }
416
+ }
417
+ }
418
+ /**
419
+ * Looks a header up by name, compared case-insensitively.
420
+ */
421
+ function getHeader(headers, name) {
422
+ const wanted = name.toLowerCase();
423
+ const key = Object.keys(headers).find((candidate) => candidate.toLowerCase() === wanted);
424
+ return key === undefined ? undefined : headers[key];
425
+ }
426
+ /**
427
+ * Merges two header records, header names compared case-insensitively. A header from `override` replaces a
428
+ * same-named header in `base` whatever the casing of either, and keeps the casing it was passed with.
429
+ */
430
+ function mergeHeaders(base, override) {
431
+ const merged = { ...base };
432
+ for (const [key, value] of Object.entries(override ?? {})) {
433
+ for (const existing of Object.keys(merged)) {
434
+ if (existing.toLowerCase() === key.toLowerCase())
435
+ delete merged[existing];
436
+ }
437
+ merged[key] = value;
438
+ }
439
+ return merged;
440
+ }
441
+ /**
442
+ * Removes a header by name, compared case-insensitively.
443
+ */
444
+ function deleteHeader(headers, name) {
445
+ const wanted = name.toLowerCase();
446
+ for (const key of Object.keys(headers)) {
447
+ if (key.toLowerCase() === wanted)
448
+ delete headers[key];
449
+ }
450
+ }
451
+ /**
452
+ * Views a serialized body as the bytes to compress, or `undefined` when compressing it would not pay off: the
453
+ * caller already labeled the body with a `Content-Encoding`, its content type carries its own compression, it is
454
+ * a stream or smaller than {@link MIN_COMPRESS_BYTES}, or the runtime has no compression to offer.
455
+ */
456
+ function compressibleBytes(body, headers) {
457
+ if (body === undefined || !runtime.isNode)
458
+ return undefined;
459
+ if (getHeader(headers, 'content-encoding') !== undefined)
460
+ return undefined;
461
+ if (!isCompressibleContentType(getHeader(headers, 'content-type')))
462
+ return undefined;
463
+ const bytes = toBytes(body);
464
+ return bytes && bytes.byteLength >= MIN_COMPRESS_BYTES ? bytes : undefined;
465
+ }
466
+ function headerValue(value) {
467
+ return Array.isArray(value) ? value[0] : value;
468
+ }
469
+ function mediaType(contentType) {
470
+ return contentType?.split(';', 1)[0].trim().toLowerCase();
471
+ }
472
+ function isJsonContentType(contentType) {
473
+ return mediaType(contentType) === CONTENT_TYPE_JSON;
474
+ }
475
+ /**
476
+ * Serializes a request body and sets the `Content-Type` header a JSON body needs. Binary bodies become a `Buffer`
477
+ * in Node.js, sharing the memory of the value passed in, so the compressor can handle them.
478
+ */
479
+ function serializeBody(data, headers, stringifyFunctions) {
480
+ if (data === undefined || data === null)
481
+ return undefined;
482
+ if (isStream(data))
483
+ return data;
484
+ if (isBuffer(data)) {
485
+ // The axios Node.js adapter refuses a body that is neither a `Buffer`, an `ArrayBuffer` nor a string.
486
+ if (!runtime.isNode || Buffer.isBuffer(data))
487
+ return data;
488
+ return ArrayBuffer.isView(data)
489
+ ? Buffer.from(data.buffer, data.byteOffset, data.byteLength)
490
+ : Buffer.from(data);
491
+ }
492
+ // A string body is already serialized and goes out as it is. Parsing a JSON one to check that it is valid
493
+ // would cost about as much as serializing it did, for a body assembled from thousands of requests.
494
+ if (typeof data === 'string')
495
+ return data;
496
+ if (typeof data === 'object') {
497
+ if (data instanceof URLSearchParams) {
498
+ if (getHeader(headers, 'content-type') === undefined) {
499
+ headers['Content-Type'] = `${CONTENT_TYPE_FORM_URLENCODED};charset=utf-8`;
500
+ }
501
+ return data.toString();
502
+ }
503
+ // Refusing these is louder than the empty body the JSON fallback below would send.
504
+ const tag = objectTag(data);
505
+ if (UNSERIALIZABLE_OBJECT_TAGS.has(tag)) {
506
+ throw new TypeError(`Unsupported request body type: ${tag}`);
507
+ }
508
+ if (getHeader(headers, 'content-type') === undefined)
509
+ headers['Content-Type'] = CONTENT_TYPE_JSON;
510
+ const contentType = getHeader(headers, 'content-type');
511
+ if (mediaType(contentType) === CONTENT_TYPE_FORM_URLENCODED)
512
+ return toFormUrlEncoded(data);
513
+ const withFunctions = stringifyFunctions && isJsonContentType(contentType);
514
+ return JSON.stringify(data, withFunctions ? stringifyFunctionReplacer : undefined);
515
+ }
516
+ throw new TypeError(`Unsupported request body type: ${typeof data}`);
517
+ }
518
+ function objectTag(value) {
519
+ return Object.prototype.toString.call(value).slice('[object '.length, -1);
520
+ }
521
+ function stringifyFunctionReplacer(_key, value) {
522
+ return typeof value === 'function' ? value.toString() : value;
523
+ }
524
+ /**
525
+ * Form-encodes an object the way HTML forms and axios do: nested objects as `key[sub]`, arrays of plain values as
526
+ * repeated `key[]`, arrays holding objects or arrays as indexed `key[0]`, binary values base64-encoded, dates in
527
+ * ISO 8601, and `null` and `undefined` fields left out.
528
+ */
529
+ function toFormUrlEncoded(data) {
530
+ const params = new URLSearchParams();
531
+ const append = (key, value) => {
532
+ if (value === undefined || value === null)
533
+ return;
534
+ if (value instanceof Date)
535
+ params.append(key, value.toISOString());
536
+ else if (isBuffer(value))
537
+ params.append(key, binaryToBase64(value));
538
+ else if (Array.isArray(value)) {
539
+ // An item spanning several fields needs its own index, otherwise the items merge into one on the
540
+ // receiving side: `list[]=` twice reads back as a single object with an array-valued field.
541
+ const indexed = value.some(spansSeveralFields);
542
+ value.forEach((item, index) => append(indexed ? `${key}[${index}]` : `${key}[]`, item));
543
+ }
544
+ else if (typeof value === 'object') {
545
+ Object.entries(value).forEach(([sub, item]) => append(`${key}[${sub}]`, item));
546
+ }
547
+ else
548
+ params.append(key, String(value));
549
+ };
550
+ Object.entries(data).forEach(([key, value]) => append(key, value));
551
+ return params.toString();
552
+ }
553
+ function spansSeveralFields(value) {
554
+ if (Array.isArray(value))
555
+ return true;
556
+ return typeof value === 'object' && value !== null && !(value instanceof Date) && !isBuffer(value);
557
+ }
558
+ function binaryToBase64(value) {
559
+ const buffer = ArrayBuffer.isView(value)
560
+ ? Buffer.from(value.buffer, value.byteOffset, value.byteLength)
561
+ : Buffer.from(value);
562
+ return buffer.toString('base64');
563
+ }
@@ -0,0 +1,4 @@
1
+ export type { AxiosHttpClientOptions, RequestInterceptorFunction } from './axios.js';
2
+ export { AxiosHttpClient } from './axios.js';
3
+ export type { ApifyRequestConfig, ApifyResponse, ApifyResponseType, HttpClientOptions, HttpMethod, HttpRequest, HttpRequestBody, HttpResponse, HttpResponseBody, HttpResponseHeaders, } from './base.js';
4
+ export { HttpClient } from './base.js';
@@ -0,0 +1,2 @@
1
+ export { AxiosHttpClient } from './axios.js';
2
+ export { HttpClient } from './base.js';
@@ -0,0 +1,40 @@
1
+ import { z } from 'zod';
2
+ /**
3
+ * Strategy for compressing HTTP request bodies.
4
+ *
5
+ * Implement this interface to create a custom compressor. Set `contentEncoding` to the value that should be sent in
6
+ * the `Content-Encoding` header and implement `compress()`. The client calls it only for bodies that are large
7
+ * enough to benefit from compression, whose content type does not already carry its own compression, and which the
8
+ * caller did not send with a `Content-Encoding` of their own.
9
+ *
10
+ * @example
11
+ * ```javascript
12
+ * import { ApifyClient } from 'apify-client';
13
+ *
14
+ * const identityCompressor = {
15
+ * contentEncoding: 'identity',
16
+ *
17
+ * async compress(data) {
18
+ * return data;
19
+ * },
20
+ * };
21
+ *
22
+ * const client = new ApifyClient({ token: 'my-token', compression: identityCompressor });
23
+ * ```
24
+ */
25
+ export interface HttpCompressor {
26
+ /** Value sent in the `Content-Encoding` header, for example `gzip` or `br`. */
27
+ readonly contentEncoding: string;
28
+ /**
29
+ * Compresses a request body.
30
+ *
31
+ * @param data - The raw bytes to compress.
32
+ * @returns The compressed bytes.
33
+ */
34
+ compress(data: Buffer): Promise<Buffer>;
35
+ }
36
+ /**
37
+ * Schema accepting anything shaped like an {@link HttpCompressor}.
38
+ * @internal
39
+ */
40
+ export declare const httpCompressorSchema: z.ZodCustom<HttpCompressor, HttpCompressor>;
@@ -0,0 +1,9 @@
1
+ import { z } from 'zod';
2
+ /**
3
+ * Schema accepting anything shaped like an {@link HttpCompressor}.
4
+ * @internal
5
+ */
6
+ export const httpCompressorSchema = z.custom((value) => typeof value === 'object' &&
7
+ value !== null &&
8
+ typeof value.contentEncoding === 'string' &&
9
+ typeof value.compress === 'function', { error: 'Invalid input: expected an HttpCompressor' });
@@ -0,0 +1,33 @@
1
+ import type { HttpCompressor } from './base.js';
2
+ /**
3
+ * Compresses request bodies using brotli.
4
+ *
5
+ * Built on the `node:zlib` module, so it works wherever the client runs on Node.js.
6
+ *
7
+ * @example
8
+ * ```javascript
9
+ * import { ApifyClient, BrotliHttpCompressor } from 'apify-client';
10
+ *
11
+ * const client = new ApifyClient({ token: 'my-token', compression: new BrotliHttpCompressor({ quality: 11 }) });
12
+ * ```
13
+ */
14
+ export declare class BrotliHttpCompressor implements HttpCompressor {
15
+ #private;
16
+ readonly contentEncoding = "br";
17
+ /**
18
+ * @param options - Compressor options.
19
+ * @throws {ArgumentValidationError} If `quality` is out of the valid range.
20
+ */
21
+ constructor(options?: BrotliHttpCompressorOptions);
22
+ compress(data: Buffer): Promise<Buffer>;
23
+ }
24
+ /**
25
+ * Options for {@link BrotliHttpCompressor}.
26
+ */
27
+ export interface BrotliHttpCompressorOptions {
28
+ /**
29
+ * Compression level, from `0` (the fastest) to `11` (the best compression).
30
+ * @default 6
31
+ */
32
+ quality?: number;
33
+ }