apify-client 3.0.0-beta.8 → 3.0.0-beta.81

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 (120) hide show
  1. package/README.md +10 -2
  2. package/dist/apify_api_error.d.ts +13 -27
  3. package/dist/apify_api_error.js +33 -23
  4. package/dist/apify_client.d.ts +125 -10
  5. package/dist/apify_client.js +133 -48
  6. package/dist/apify_client_error.d.ts +10 -0
  7. package/dist/apify_client_error.js +10 -0
  8. package/dist/base/api_client.d.ts +7 -7
  9. package/dist/base/api_client.js +36 -36
  10. package/dist/base/resource_client.d.ts +21 -8
  11. package/dist/base/resource_client.js +67 -33
  12. package/dist/base/resource_collection_client.d.ts +12 -4
  13. package/dist/base/resource_collection_client.js +34 -11
  14. package/dist/body_parser.d.ts +3 -4
  15. package/dist/body_parser.js +19 -26
  16. package/dist/bundle.js +27 -22
  17. package/dist/bundle.js.map +1 -1
  18. package/dist/generated/api.d.ts +3033 -971
  19. package/dist/generated/schemas.d.ts +2340 -932
  20. package/dist/generated/schemas.js +570 -229
  21. package/dist/http_clients/axios.d.ts +60 -0
  22. package/dist/http_clients/axios.js +117 -0
  23. package/dist/http_clients/base.d.ts +336 -0
  24. package/dist/http_clients/base.js +612 -0
  25. package/dist/http_clients/index.d.ts +4 -0
  26. package/dist/http_clients/index.js +2 -0
  27. package/dist/http_compressors/base.d.ts +40 -0
  28. package/dist/http_compressors/base.js +9 -0
  29. package/dist/http_compressors/brotli.d.ts +33 -0
  30. package/dist/http_compressors/brotli.js +39 -0
  31. package/dist/http_compressors/gzip.d.ts +33 -0
  32. package/dist/http_compressors/gzip.js +39 -0
  33. package/dist/http_compressors/index.d.ts +6 -0
  34. package/dist/http_compressors/index.js +2 -0
  35. package/dist/http_compressors/resolve.d.ts +23 -0
  36. package/dist/http_compressors/resolve.js +27 -0
  37. package/dist/index.d.ts +5 -1
  38. package/dist/index.js +4 -1
  39. package/dist/invalid_response_body_error.d.ts +14 -0
  40. package/dist/invalid_response_body_error.js +18 -0
  41. package/dist/models.d.ts +166 -243
  42. package/dist/models.js +6 -4
  43. package/dist/resource_clients/actor.d.ts +74 -34
  44. package/dist/resource_clients/actor.js +87 -56
  45. package/dist/resource_clients/actor_collection.d.ts +18 -12
  46. package/dist/resource_clients/actor_collection.js +11 -3
  47. package/dist/resource_clients/actor_env_var.d.ts +11 -4
  48. package/dist/resource_clients/actor_env_var.js +16 -6
  49. package/dist/resource_clients/actor_env_var_collection.d.ts +9 -4
  50. package/dist/resource_clients/actor_env_var_collection.js +11 -4
  51. package/dist/resource_clients/actor_version.d.ts +20 -5
  52. package/dist/resource_clients/actor_version.js +19 -9
  53. package/dist/resource_clients/actor_version_collection.d.ts +11 -4
  54. package/dist/resource_clients/actor_version_collection.js +13 -4
  55. package/dist/resource_clients/build.d.ts +20 -7
  56. package/dist/resource_clients/build.js +35 -15
  57. package/dist/resource_clients/build_collection.d.ts +7 -5
  58. package/dist/resource_clients/build_collection.js +4 -1
  59. package/dist/resource_clients/dataset.d.ts +81 -36
  60. package/dist/resource_clients/dataset.js +109 -120
  61. package/dist/resource_clients/dataset_collection.d.ts +10 -6
  62. package/dist/resource_clients/dataset_collection.js +13 -2
  63. package/dist/resource_clients/key_value_store.d.ts +106 -25
  64. package/dist/resource_clients/key_value_store.js +131 -50
  65. package/dist/resource_clients/key_value_store_collection.d.ts +10 -6
  66. package/dist/resource_clients/key_value_store_collection.js +13 -2
  67. package/dist/resource_clients/log.d.ts +40 -21
  68. package/dist/resource_clients/log.js +144 -66
  69. package/dist/resource_clients/request_queue.d.ts +76 -31
  70. package/dist/resource_clients/request_queue.js +178 -93
  71. package/dist/resource_clients/request_queue_collection.d.ts +9 -5
  72. package/dist/resource_clients/request_queue_collection.js +9 -3
  73. package/dist/resource_clients/run.d.ts +126 -28
  74. package/dist/resource_clients/run.js +193 -49
  75. package/dist/resource_clients/run_collection.d.ts +5 -3
  76. package/dist/resource_clients/run_collection.js +4 -1
  77. package/dist/resource_clients/schedule.d.ts +16 -7
  78. package/dist/resource_clients/schedule.js +30 -22
  79. package/dist/resource_clients/schedule_collection.d.ts +6 -2
  80. package/dist/resource_clients/schedule_collection.js +9 -3
  81. package/dist/resource_clients/store_collection.d.ts +6 -4
  82. package/dist/resource_clients/store_collection.js +4 -1
  83. package/dist/resource_clients/task.d.ts +36 -13
  84. package/dist/resource_clients/task.js +79 -46
  85. package/dist/resource_clients/task_collection.d.ts +9 -5
  86. package/dist/resource_clients/task_collection.js +9 -3
  87. package/dist/resource_clients/user.d.ts +19 -10
  88. package/dist/resource_clients/user.js +42 -38
  89. package/dist/resource_clients/webhook.d.ts +17 -8
  90. package/dist/resource_clients/webhook.js +30 -22
  91. package/dist/resource_clients/webhook_collection.d.ts +8 -4
  92. package/dist/resource_clients/webhook_collection.js +9 -3
  93. package/dist/resource_clients/webhook_dispatch.d.ts +4 -1
  94. package/dist/resource_clients/webhook_dispatch.js +7 -2
  95. package/dist/resource_clients/webhook_dispatch_collection.d.ts +3 -1
  96. package/dist/resource_clients/webhook_dispatch_collection.js +4 -1
  97. package/dist/response_validation_error.d.ts +2 -1
  98. package/dist/response_validation_error.js +2 -1
  99. package/dist/runtime/node.d.ts +6 -0
  100. package/dist/runtime/node.js +80 -0
  101. package/dist/runtime/types.d.ts +60 -0
  102. package/dist/runtime/types.js +1 -0
  103. package/dist/runtime/web.d.ts +5 -0
  104. package/dist/runtime/web.js +21 -0
  105. package/dist/schemas.d.ts +368 -4
  106. package/dist/schemas.js +26 -4
  107. package/dist/statistics.d.ts +1 -6
  108. package/dist/statistics.js +2 -2
  109. package/dist/timeouts.d.ts +84 -0
  110. package/dist/timeouts.js +37 -0
  111. package/dist/timezones.d.ts +1 -1
  112. package/dist/utils.d.ts +93 -58
  113. package/dist/utils.js +226 -158
  114. package/dist/wait_for_resources.d.ts +8 -0
  115. package/dist/wait_for_resources.js +39 -0
  116. package/package.json +32 -29
  117. package/dist/http_client.d.ts +0 -88
  118. package/dist/http_client.js +0 -255
  119. package/dist/interceptors.d.ts +0 -19
  120. package/dist/interceptors.js +0 -105
@@ -0,0 +1,612 @@
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, isBlob, 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(['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. Reject when the connection drops before the body is
173
+ * complete: the pipeline takes a returned body as whole and does not retry one that fails to parse.
174
+ *
175
+ * @param request - The request to send, with the headers merged, the body serialized and the query encoded.
176
+ * @returns The response, with the body unread when `request.stream` is set and as raw bytes otherwise.
177
+ */
178
+ async sendRequest(request) {
179
+ throw new Error(`Implement sendRequest() to send ${request.method} ${request.url}, or override call() entirely.`);
180
+ }
181
+ /**
182
+ * Makes an API request with automatic retries and exponential backoff.
183
+ *
184
+ * Network errors the transport classifies as retryable, rate limits (HTTP 429) and server errors (HTTP 5xx)
185
+ * are retried up to {@link maxRetries} times. Any other error status is thrown as {@link ApifyApiError} right
186
+ * away. A request whose body is a `Readable` is never retried, since part of the stream has already been
187
+ * consumed by the time the failure shows. A `Blob` body is read again for every attempt, so it is retried like
188
+ * any other, unless reading the blob itself fails: that error is thrown right away, with the transport error
189
+ * as its `cause` when that error does not already wrap it. Aborting `config.signal` ends the attempt in flight,
190
+ * skips the remaining retries and rejects the call with the signal's `reason`.
191
+ *
192
+ * @template T - Type of the parsed response body.
193
+ * @param config - The request to make.
194
+ * @returns The successful response.
195
+ * @throws {ApifyApiError} When the API responds with an error status the retries could not fix.
196
+ */
197
+ async call(config) {
198
+ config.signal?.throwIfAborted();
199
+ this.stats.calls++;
200
+ const { headers, body } = await this.prepareRequest(config);
201
+ const url = this.buildUrl(config.url, config.params);
202
+ return this.#retryWithExpBackoff(async (stopRetrying, attempt) => this.#makeRequest({ config, url, headers, body, attempt, stopRetrying }), config.signal);
203
+ }
204
+ /**
205
+ * Prepares the headers and the body of a request.
206
+ *
207
+ * Merges the client's default headers with the per-request ones, header names compared case-insensitively and
208
+ * the per-request values winning. Serializes an object body to JSON, setting `Content-Type: application/json`
209
+ * unless the caller supplied a content type. Runs the body through {@link httpCompressor} unless a
210
+ * `Content-Encoding` header is already set, the body is too small, or its content type says the payload is
211
+ * already compressed. A caller-supplied `Content-Encoding` is forwarded as it is, which is how a pre-encoded
212
+ * body is uploaded, and `Content-Encoding: identity` opts a single request out of compression.
213
+ */
214
+ async prepareRequest(config) {
215
+ let headers = mergeHeaders(this.defaultHeaders, config.headers);
216
+ let body = serializeBody(config.data, headers, config.stringifyFunctions);
217
+ const bytes = compressibleBytes(body, headers);
218
+ if (bytes) {
219
+ body = await this.httpCompressor.compress(Buffer.from(bytes.buffer, bytes.byteOffset, bytes.byteLength));
220
+ // Compressing changes the body length, so a `Content-Length` the caller set describes the wrong body
221
+ // and would leave the server waiting for bytes that never arrive.
222
+ headers = mergeHeaders(headers, { 'Content-Encoding': this.httpCompressor.contentEncoding });
223
+ deleteHeader(headers, 'content-length');
224
+ }
225
+ // A blob reaches a Node.js transport as a stream, which would go out chunked without a known length.
226
+ // Browsers set the header themselves and refuse to let a page set it.
227
+ if (isBlob(body) && runtime.isNode) {
228
+ headers = mergeHeaders(headers, { 'Content-Length': String(body.size) });
229
+ }
230
+ return { headers, body };
231
+ }
232
+ /**
233
+ * Appends the query parameters to the URL. `undefined` values are dropped, booleans are sent as `1` and `0`,
234
+ * `Date` values as ISO 8601 strings and arrays comma-separated.
235
+ */
236
+ buildUrl(url, params) {
237
+ const pairs = [];
238
+ for (const [key, value] of Object.entries(params ?? {})) {
239
+ if (value === undefined)
240
+ continue;
241
+ if (value instanceof Date)
242
+ pairs.push([key, value.toISOString()]);
243
+ else if (typeof value === 'boolean')
244
+ pairs.push([key, String(Number(value))]);
245
+ else
246
+ pairs.push([key, String(value)]);
247
+ }
248
+ if (pairs.length === 0)
249
+ return url;
250
+ const query = new URLSearchParams(pairs).toString();
251
+ return `${url}${url.includes('?') ? '&' : '?'}${query}`;
252
+ }
253
+ /**
254
+ * Resolves `timeoutSecs` to the number of milliseconds the given attempt gets. A tier name resolves to its
255
+ * configured duration, a number is taken as seconds, and `'noTimeout'` becomes `undefined`. The result doubles
256
+ * with each attempt and is capped at `timeoutMaxMillis`. A requested value above the cap is capped too, which
257
+ * warns once, since it does not take effect in full.
258
+ *
259
+ * @param attempt - Current attempt number, starting at 1.
260
+ * @param timeoutSecs - The request's timeout. Defaults to the `medium` tier.
261
+ */
262
+ computeTimeoutMillis(attempt, timeoutSecs = 'medium') {
263
+ if (timeoutSecs === 'noTimeout')
264
+ return undefined;
265
+ const requestedMillis = typeof timeoutSecs === 'number' ? timeoutSecs * 1000 : this.timeoutMillis[timeoutSecs];
266
+ if (requestedMillis > this.timeoutMaxMillis) {
267
+ // `warningOnce` keys by message, so each requested value warns once.
268
+ this.logger.warningOnce(`The requested timeout of ${requestedMillis / 1000}s exceeds timeoutMaxSecs ` +
269
+ `(${this.timeoutMaxMillis / 1000}s) and is capped at it. ` +
270
+ 'Raise timeoutMaxSecs on the client to allow longer request timeouts.');
271
+ }
272
+ return Math.min(requestedMillis * 2 ** (attempt - 1), this.timeoutMaxMillis);
273
+ }
274
+ /**
275
+ * Retries `fn` with randomized exponential backoff until it resolves, `stopRetrying()` was called before it
276
+ * threw, the retries are exhausted, or `signal` aborts. The last attempt's error propagates as it is, and an
277
+ * abort throws the signal's `reason`, whether it lands during an attempt or during the wait before the next.
278
+ */
279
+ async #retryWithExpBackoff(fn, signal) {
280
+ let retry = true;
281
+ const stopRetrying = () => {
282
+ retry = false;
283
+ };
284
+ for (let attempt = 1; attempt <= this.maxRetries; attempt++) {
285
+ signal?.throwIfAborted();
286
+ try {
287
+ return await fn(stopRetrying, attempt);
288
+ }
289
+ catch (err) {
290
+ // A transport that does not honor the signal may still answer the aborted attempt with an error
291
+ // status, and the caller expects the reason for that too.
292
+ signal?.throwIfAborted();
293
+ if (!retry)
294
+ throw err;
295
+ this.#onRequestRetry(err, attempt);
296
+ }
297
+ // The delay doubles with every attempt and is spread over a random factor between 1 and 2, so that
298
+ // callers that failed together do not come back at the API in lockstep on every retry.
299
+ const delayMillis = this.minDelayBetweenRetriesMillis * 2 ** (attempt - 1) * (1 + Math.random());
300
+ await sleep(delayMillis, signal);
301
+ }
302
+ signal?.throwIfAborted();
303
+ return fn(stopRetrying, this.maxRetries + 1);
304
+ }
305
+ /**
306
+ * Executes one attempt: sends the request, decodes the body and turns an error status into {@link ApifyApiError}.
307
+ * Throws for anything that is not a success, and flags the failure as final through `stopRetrying()` when a
308
+ * retry could not fix it.
309
+ */
310
+ async #makeRequest(options) {
311
+ const { config, url, headers, body, attempt, stopRetrying } = options;
312
+ this.stats.requests++;
313
+ const requestIsStream = isStream(config.data);
314
+ // The transport reports a failure of the blob as an error of its own, which may pass as transient.
315
+ let sourceFailure;
316
+ const attemptBody = isBlob(body)
317
+ ? runtime.openBlobBody(body, (error) => {
318
+ sourceFailure = { error };
319
+ })
320
+ : body;
321
+ let response;
322
+ try {
323
+ response = await this.sendRequest({
324
+ method: config.method,
325
+ url,
326
+ headers,
327
+ body: attemptBody,
328
+ timeoutMillis: this.computeTimeoutMillis(attempt, config.timeoutSecs),
329
+ stream: config.responseType === 'stream',
330
+ signal: config.signal,
331
+ });
332
+ }
333
+ catch (err) {
334
+ // Every HTTP library reports an abort with an error of its own, so the caller gets the reason instead.
335
+ config.signal?.throwIfAborted();
336
+ if (sourceFailure) {
337
+ // Sending the body again cannot fix its source.
338
+ stopRetrying();
339
+ throw withCause(sourceFailure.error, err);
340
+ }
341
+ this.#handleRequestError(err, config, stopRetrying);
342
+ throw err;
343
+ }
344
+ finally {
345
+ // A transport may stop reading the body early, for example on an error response sent before the whole
346
+ // body arrived, which would leave the blob's file open.
347
+ if (attemptBody !== body && isStream(attemptBody))
348
+ attemptBody.destroy();
349
+ }
350
+ // A failed streaming request carries the API error body in the stream, so read it and let
351
+ // `ApifyApiError` parse it like any other error body. A body that cannot be read leaves the error
352
+ // without a message, which beats losing the status code to a stream error.
353
+ if (response.status >= 300 && isStream(response.body)) {
354
+ response.body = await concatStreamToBuffer(response.body).catch(() => undefined);
355
+ }
356
+ let data;
357
+ try {
358
+ data = this.#parseResponseBody(response, config);
359
+ }
360
+ catch (err) {
361
+ // The body of a success arrived in full, since a transfer cut short fails in `sendRequest()`, so a
362
+ // retry would get the same body back.
363
+ if (response.status < 300) {
364
+ stopRetrying();
365
+ throw err;
366
+ }
367
+ // An error status is reported by its code, and `ApifyApiError` makes what it can of the raw body.
368
+ data = response.body;
369
+ }
370
+ const apifyResponse = {
371
+ status: response.status,
372
+ headers: response.headers,
373
+ data: data,
374
+ config,
375
+ };
376
+ if (response.status < 300)
377
+ return apifyResponse;
378
+ if (response.status === RATE_LIMIT_EXCEEDED_STATUS_CODE) {
379
+ this.stats.addRateLimitError(attempt);
380
+ }
381
+ const apiError = ApifyApiError.fromResponse(apifyResponse, attempt);
382
+ if (!this.#isStatusCodeRetryable(response.status)) {
383
+ stopRetrying();
384
+ }
385
+ else if (requestIsStream) {
386
+ this.#informAboutStreamNoRetry();
387
+ stopRetrying();
388
+ }
389
+ throw apiError;
390
+ }
391
+ /**
392
+ * Decodes the response body according to the request's `responseType`. An empty body decodes to `undefined`.
393
+ *
394
+ * @throws {InvalidResponseBodyError} When the body does not parse as its content type claims.
395
+ */
396
+ #parseResponseBody(response, config) {
397
+ const { body } = response;
398
+ if (config.responseType === 'stream' || config.responseType === 'buffer')
399
+ return body;
400
+ if (body === undefined || isStream(body))
401
+ return body;
402
+ if (body.byteLength === 0)
403
+ return undefined;
404
+ try {
405
+ return maybeParseBody(body, headerValue(response.headers['content-type']) ?? '');
406
+ }
407
+ catch (err) {
408
+ throw new InvalidResponseBodyError(response, err);
409
+ }
410
+ }
411
+ /**
412
+ * Decides whether a transport error ends the call. Timeouts the request opted out of retrying and errors the
413
+ * transport does not classify as retryable are final, and so is any error on a request with a stream body.
414
+ */
415
+ #handleRequestError(err, config, stopRetrying) {
416
+ if (config.doNotRetryTimeouts && this.isTimeoutError(err)) {
417
+ stopRetrying();
418
+ return;
419
+ }
420
+ if (!this.isRetryableTransportError(err)) {
421
+ stopRetrying();
422
+ return;
423
+ }
424
+ if (isStream(config.data)) {
425
+ this.#informAboutStreamNoRetry();
426
+ stopRetrying();
427
+ }
428
+ }
429
+ /**
430
+ * Rate limits (429) and server errors (500+) are retried. Anything else in 300-499 is a redirect the client
431
+ * cannot follow or invalid input, which repeating the request cannot fix.
432
+ */
433
+ #isStatusCodeRetryable(statusCode) {
434
+ return statusCode === RATE_LIMIT_EXCEEDED_STATUS_CODE || statusCode >= 500;
435
+ }
436
+ #informAboutStreamNoRetry() {
437
+ this.logger.warningOnce('Request body was a stream - retrying will not work, as part of it was already consumed.');
438
+ this.logger.warningOnce('If you want Apify client to handle retries for you, collect the stream into a buffer before sending it.');
439
+ }
440
+ #onRequestRetry(error, attempt) {
441
+ if (attempt === Math.round(this.maxRetries / 2)) {
442
+ this.logger.warning(`API request failed ${attempt} times. Max attempts: ${this.maxRetries + 1}.\nCause:${error.stack}`);
443
+ }
444
+ }
445
+ }
446
+ /**
447
+ * Looks a header up by name, compared case-insensitively.
448
+ */
449
+ function getHeader(headers, name) {
450
+ const wanted = name.toLowerCase();
451
+ const key = Object.keys(headers).find((candidate) => candidate.toLowerCase() === wanted);
452
+ return key === undefined ? undefined : headers[key];
453
+ }
454
+ /**
455
+ * Merges two header records, header names compared case-insensitively. A header from `override` replaces a
456
+ * same-named header in `base` whatever the casing of either, and keeps the casing it was passed with.
457
+ */
458
+ function mergeHeaders(base, override) {
459
+ const merged = { ...base };
460
+ for (const [key, value] of Object.entries(override ?? {})) {
461
+ for (const existing of Object.keys(merged)) {
462
+ if (existing.toLowerCase() === key.toLowerCase())
463
+ delete merged[existing];
464
+ }
465
+ merged[key] = value;
466
+ }
467
+ return merged;
468
+ }
469
+ /**
470
+ * Removes a header by name, compared case-insensitively.
471
+ */
472
+ function deleteHeader(headers, name) {
473
+ const wanted = name.toLowerCase();
474
+ for (const key of Object.keys(headers)) {
475
+ if (key.toLowerCase() === wanted)
476
+ delete headers[key];
477
+ }
478
+ }
479
+ /**
480
+ * Views a serialized body as the bytes to compress, or `undefined` when compressing it would not pay off: the
481
+ * caller already labeled the body with a `Content-Encoding`, its content type carries its own compression, it is
482
+ * a stream or a blob, it is smaller than {@link MIN_COMPRESS_BYTES}, or the runtime has no compression to offer.
483
+ */
484
+ function compressibleBytes(body, headers) {
485
+ if (body === undefined || !runtime.isNode)
486
+ return undefined;
487
+ if (getHeader(headers, 'content-encoding') !== undefined)
488
+ return undefined;
489
+ if (!isCompressibleContentType(getHeader(headers, 'content-type')))
490
+ return undefined;
491
+ const bytes = toBytes(body);
492
+ return bytes && bytes.byteLength >= MIN_COMPRESS_BYTES ? bytes : undefined;
493
+ }
494
+ /**
495
+ * Sets `cause` on an error that has none, and returns the error. A `cause` that already wraps the error, as an axios
496
+ * error wraps the error of its request body, is left out, since linking it would make the chain circular.
497
+ */
498
+ function withCause(error, cause) {
499
+ if (!(error instanceof Error) || error.cause !== undefined)
500
+ return error;
501
+ const seen = new Set();
502
+ for (let link = cause; link instanceof Error && !seen.has(link); link = link.cause) {
503
+ if (link === error)
504
+ return error;
505
+ seen.add(link);
506
+ }
507
+ error.cause = cause;
508
+ return error;
509
+ }
510
+ function headerValue(value) {
511
+ return Array.isArray(value) ? value[0] : value;
512
+ }
513
+ function mediaType(contentType) {
514
+ return contentType?.split(';', 1)[0].trim().toLowerCase();
515
+ }
516
+ function isJsonContentType(contentType) {
517
+ return mediaType(contentType) === CONTENT_TYPE_JSON;
518
+ }
519
+ /**
520
+ * Serializes a request body and sets the `Content-Type` header a JSON body needs. Binary bodies become a `Buffer`
521
+ * in Node.js, sharing the memory of the value passed in, so the compressor can handle them.
522
+ */
523
+ function serializeBody(data, headers, stringifyFunctions) {
524
+ if (data === undefined || data === null)
525
+ return undefined;
526
+ if (isStream(data))
527
+ return data;
528
+ if (isBlob(data)) {
529
+ if (getHeader(headers, 'content-type') === undefined && data.type)
530
+ headers['Content-Type'] = data.type;
531
+ return data;
532
+ }
533
+ if (isBuffer(data)) {
534
+ // The axios Node.js adapter refuses a body that is neither a `Buffer`, an `ArrayBuffer` nor a string.
535
+ if (!runtime.isNode || Buffer.isBuffer(data))
536
+ return data;
537
+ return ArrayBuffer.isView(data)
538
+ ? Buffer.from(data.buffer, data.byteOffset, data.byteLength)
539
+ : Buffer.from(data);
540
+ }
541
+ // A string body is already serialized and goes out as it is. Parsing a JSON one to check that it is valid
542
+ // would cost about as much as serializing it did, for a body assembled from thousands of requests.
543
+ if (typeof data === 'string')
544
+ return data;
545
+ if (typeof data === 'object') {
546
+ if (data instanceof URLSearchParams) {
547
+ if (getHeader(headers, 'content-type') === undefined) {
548
+ headers['Content-Type'] = `${CONTENT_TYPE_FORM_URLENCODED};charset=utf-8`;
549
+ }
550
+ return data.toString();
551
+ }
552
+ // Refusing these is louder than the empty body the JSON fallback below would send.
553
+ const tag = objectTag(data);
554
+ if (UNSERIALIZABLE_OBJECT_TAGS.has(tag)) {
555
+ throw new TypeError(`Unsupported request body type: ${tag}`);
556
+ }
557
+ if (getHeader(headers, 'content-type') === undefined)
558
+ headers['Content-Type'] = CONTENT_TYPE_JSON;
559
+ const contentType = getHeader(headers, 'content-type');
560
+ if (mediaType(contentType) === CONTENT_TYPE_FORM_URLENCODED)
561
+ return toFormUrlEncoded(data);
562
+ const withFunctions = stringifyFunctions && isJsonContentType(contentType);
563
+ return JSON.stringify(data, withFunctions ? stringifyFunctionReplacer : undefined);
564
+ }
565
+ throw new TypeError(`Unsupported request body type: ${typeof data}`);
566
+ }
567
+ function objectTag(value) {
568
+ return Object.prototype.toString.call(value).slice('[object '.length, -1);
569
+ }
570
+ function stringifyFunctionReplacer(_key, value) {
571
+ return typeof value === 'function' ? value.toString() : value;
572
+ }
573
+ /**
574
+ * Form-encodes an object the way HTML forms and axios do: nested objects as `key[sub]`, arrays of plain values as
575
+ * repeated `key[]`, arrays holding objects or arrays as indexed `key[0]`, binary values base64-encoded, dates in
576
+ * ISO 8601, and `null` and `undefined` fields left out.
577
+ */
578
+ function toFormUrlEncoded(data) {
579
+ const params = new URLSearchParams();
580
+ const append = (key, value) => {
581
+ if (value === undefined || value === null)
582
+ return;
583
+ if (value instanceof Date)
584
+ params.append(key, value.toISOString());
585
+ else if (isBuffer(value))
586
+ params.append(key, binaryToBase64(value));
587
+ else if (Array.isArray(value)) {
588
+ // An item spanning several fields needs its own index, otherwise the items merge into one on the
589
+ // receiving side: `list[]=` twice reads back as a single object with an array-valued field.
590
+ const indexed = value.some(spansSeveralFields);
591
+ value.forEach((item, index) => append(indexed ? `${key}[${index}]` : `${key}[]`, item));
592
+ }
593
+ else if (typeof value === 'object') {
594
+ Object.entries(value).forEach(([sub, item]) => append(`${key}[${sub}]`, item));
595
+ }
596
+ else
597
+ params.append(key, String(value));
598
+ };
599
+ Object.entries(data).forEach(([key, value]) => append(key, value));
600
+ return params.toString();
601
+ }
602
+ function spansSeveralFields(value) {
603
+ if (Array.isArray(value))
604
+ return true;
605
+ return typeof value === 'object' && value !== null && !(value instanceof Date) && !isBuffer(value);
606
+ }
607
+ function binaryToBase64(value) {
608
+ const buffer = ArrayBuffer.isView(value)
609
+ ? Buffer.from(value.buffer, value.byteOffset, value.byteLength)
610
+ : Buffer.from(value);
611
+ return buffer.toString('base64');
612
+ }
@@ -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' });