apify-client 3.0.0-beta.9 → 3.0.0-rc.0
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.
- package/README.md +10 -2
- package/dist/apify_api_error.d.ts +11 -26
- package/dist/apify_api_error.js +31 -22
- package/dist/apify_client.d.ts +125 -10
- package/dist/apify_client.js +133 -48
- package/dist/base/api_client.d.ts +7 -7
- package/dist/base/api_client.js +36 -24
- package/dist/base/resource_client.d.ts +21 -8
- package/dist/base/resource_client.js +67 -33
- package/dist/base/resource_collection_client.d.ts +12 -4
- package/dist/base/resource_collection_client.js +34 -11
- package/dist/body_parser.d.ts +3 -4
- package/dist/body_parser.js +19 -26
- package/dist/bundle.js +27 -22
- package/dist/bundle.js.map +1 -1
- package/dist/generated/api.d.ts +785 -290
- package/dist/generated/schemas.d.ts +720 -747
- package/dist/generated/schemas.js +166 -162
- package/dist/http_clients/axios.d.ts +60 -0
- package/dist/http_clients/axios.js +117 -0
- package/dist/http_clients/base.d.ts +331 -0
- package/dist/http_clients/base.js +563 -0
- package/dist/http_clients/index.d.ts +4 -0
- package/dist/http_clients/index.js +2 -0
- package/dist/http_compressors/base.d.ts +40 -0
- package/dist/http_compressors/base.js +9 -0
- package/dist/http_compressors/brotli.d.ts +33 -0
- package/dist/http_compressors/brotli.js +39 -0
- package/dist/http_compressors/gzip.d.ts +33 -0
- package/dist/http_compressors/gzip.js +39 -0
- package/dist/http_compressors/index.d.ts +6 -0
- package/dist/http_compressors/index.js +2 -0
- package/dist/http_compressors/resolve.d.ts +23 -0
- package/dist/http_compressors/resolve.js +27 -0
- package/dist/index.d.ts +4 -1
- package/dist/index.js +3 -1
- package/dist/invalid_response_body_error.d.ts +13 -0
- package/dist/invalid_response_body_error.js +17 -0
- package/dist/models.d.ts +162 -194
- package/dist/models.js +6 -4
- package/dist/resource_clients/actor.d.ts +74 -34
- package/dist/resource_clients/actor.js +87 -56
- package/dist/resource_clients/actor_collection.d.ts +18 -12
- package/dist/resource_clients/actor_collection.js +11 -3
- package/dist/resource_clients/actor_env_var.d.ts +11 -4
- package/dist/resource_clients/actor_env_var.js +16 -6
- package/dist/resource_clients/actor_env_var_collection.d.ts +9 -4
- package/dist/resource_clients/actor_env_var_collection.js +11 -4
- package/dist/resource_clients/actor_version.d.ts +20 -5
- package/dist/resource_clients/actor_version.js +19 -9
- package/dist/resource_clients/actor_version_collection.d.ts +11 -4
- package/dist/resource_clients/actor_version_collection.js +13 -4
- package/dist/resource_clients/build.d.ts +20 -7
- package/dist/resource_clients/build.js +35 -15
- package/dist/resource_clients/build_collection.d.ts +7 -5
- package/dist/resource_clients/build_collection.js +4 -1
- package/dist/resource_clients/dataset.d.ts +53 -35
- package/dist/resource_clients/dataset.js +105 -80
- package/dist/resource_clients/dataset_collection.d.ts +10 -6
- package/dist/resource_clients/dataset_collection.js +13 -2
- package/dist/resource_clients/key_value_store.d.ts +62 -20
- package/dist/resource_clients/key_value_store.js +78 -40
- package/dist/resource_clients/key_value_store_collection.d.ts +10 -6
- package/dist/resource_clients/key_value_store_collection.js +13 -2
- package/dist/resource_clients/log.d.ts +13 -20
- package/dist/resource_clients/log.js +141 -59
- package/dist/resource_clients/request_queue.d.ts +68 -31
- package/dist/resource_clients/request_queue.js +173 -91
- package/dist/resource_clients/request_queue_collection.d.ts +9 -5
- package/dist/resource_clients/request_queue_collection.js +9 -3
- package/dist/resource_clients/run.d.ts +119 -28
- package/dist/resource_clients/run.js +190 -49
- package/dist/resource_clients/run_collection.d.ts +5 -3
- package/dist/resource_clients/run_collection.js +4 -1
- package/dist/resource_clients/schedule.d.ts +16 -7
- package/dist/resource_clients/schedule.js +30 -22
- package/dist/resource_clients/schedule_collection.d.ts +6 -2
- package/dist/resource_clients/schedule_collection.js +9 -3
- package/dist/resource_clients/store_collection.d.ts +6 -4
- package/dist/resource_clients/store_collection.js +4 -1
- package/dist/resource_clients/task.d.ts +36 -13
- package/dist/resource_clients/task.js +79 -46
- package/dist/resource_clients/task_collection.d.ts +9 -5
- package/dist/resource_clients/task_collection.js +9 -3
- package/dist/resource_clients/user.d.ts +19 -10
- package/dist/resource_clients/user.js +42 -38
- package/dist/resource_clients/webhook.d.ts +17 -8
- package/dist/resource_clients/webhook.js +30 -22
- package/dist/resource_clients/webhook_collection.d.ts +8 -4
- package/dist/resource_clients/webhook_collection.js +9 -3
- package/dist/resource_clients/webhook_dispatch.d.ts +4 -1
- package/dist/resource_clients/webhook_dispatch.js +7 -2
- package/dist/resource_clients/webhook_dispatch_collection.d.ts +3 -1
- package/dist/resource_clients/webhook_dispatch_collection.js +4 -1
- package/dist/runtime/node.d.ts +6 -0
- package/dist/runtime/node.js +49 -0
- package/dist/runtime/types.d.ts +52 -0
- package/dist/runtime/types.js +1 -0
- package/dist/runtime/web.d.ts +5 -0
- package/dist/runtime/web.js +18 -0
- package/dist/schemas.d.ts +1 -1
- package/dist/schemas.js +1 -1
- package/dist/statistics.d.ts +1 -6
- package/dist/statistics.js +2 -2
- package/dist/timeouts.d.ts +84 -0
- package/dist/timeouts.js +37 -0
- package/dist/timezones.d.ts +1 -1
- package/dist/utils.d.ts +82 -45
- package/dist/utils.js +197 -146
- package/dist/wait_for_resources.d.ts +8 -0
- package/dist/wait_for_resources.js +39 -0
- package/package.json +32 -29
- package/dist/http_client.d.ts +0 -88
- package/dist/http_client.js +0 -255
- package/dist/interceptors.d.ts +0 -19
- 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,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
|
+
}
|