apify-client 3.0.0-beta.9 → 3.0.0-beta.90

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