scrapely 1.2.16 → 1.2.17
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/dist/actor.js +32 -19
- package/dist/charging.d.ts +22 -3
- package/dist/charging.js +106 -21
- package/dist/storage/api-storage-client.d.ts +56 -14
- package/dist/storage/api-storage-client.js +176 -167
- package/package.json +1 -1
package/dist/actor.js
CHANGED
|
@@ -33,6 +33,12 @@ const charging_client_js_1 = require("./charging_client.js");
|
|
|
33
33
|
const proxy_configuration_js_1 = require("./proxy-configuration.js");
|
|
34
34
|
const browser_configuration_js_1 = require("./browser-configuration.js");
|
|
35
35
|
const utils_js_1 = require("./utils.js");
|
|
36
|
+
/** "429 Run API rate limit exceeded ..." for an API error, the message otherwise. */
|
|
37
|
+
function describeRunApiError(err) {
|
|
38
|
+
const status = err.statusCode;
|
|
39
|
+
const message = err?.message ?? String(err);
|
|
40
|
+
return typeof status === 'number' ? `${status} ${message}` : message;
|
|
41
|
+
}
|
|
36
42
|
/** Pre-init fallback; after init, {@link Actor.events} returns the EventManager emitter. */
|
|
37
43
|
const fallbackEvents = new node_events_1.EventEmitter();
|
|
38
44
|
let apifyClientInstance;
|
|
@@ -948,7 +954,9 @@ class Actor {
|
|
|
948
954
|
}
|
|
949
955
|
if (cm && typeof cm.flushPending === 'function') {
|
|
950
956
|
await Promise.race([
|
|
951
|
-
|
|
957
|
+
// The flush's own deadline ends its retries a little before
|
|
958
|
+
// the race gives up on it, so none is cut off mid-request.
|
|
959
|
+
cm.flushPending(64, Math.max(500, flushDeadlineMs - 1000)),
|
|
952
960
|
new Promise((resolve) => setTimeout(resolve, flushDeadlineMs)),
|
|
953
961
|
]);
|
|
954
962
|
}
|
|
@@ -979,6 +987,8 @@ class Actor {
|
|
|
979
987
|
Actor.setStatusMessage(message, {
|
|
980
988
|
isStatusMessageTerminal: true,
|
|
981
989
|
level: exitCode > 0 ? 'ERROR' : 'INFO',
|
|
990
|
+
// Stop retrying when the exit stops waiting for it.
|
|
991
|
+
retryWindowMs: timeoutMs,
|
|
982
992
|
}),
|
|
983
993
|
new Promise((_, reject) => {
|
|
984
994
|
timer = setTimeout(() => reject(new Error(`timed out after ${timeoutMs}ms`)), timeoutMs);
|
|
@@ -1034,7 +1044,9 @@ class Actor {
|
|
|
1034
1044
|
if (!apiUrl || !token || !runId) {
|
|
1035
1045
|
return {};
|
|
1036
1046
|
}
|
|
1037
|
-
|
|
1047
|
+
// Setting the message is idempotent: a 429 (run API rate limit), 5xx or
|
|
1048
|
+
// dropped connection is retried with backoff instead of failing the run.
|
|
1049
|
+
const body = await (0, api_storage_client_js_1.fetchWithRetries)(`${apiUrl}/v2/actor-runs/${runId}`, {
|
|
1038
1050
|
method: 'PUT',
|
|
1039
1051
|
headers: {
|
|
1040
1052
|
'Content-Type': 'application/json',
|
|
@@ -1044,13 +1056,14 @@ class Actor {
|
|
|
1044
1056
|
statusMessage: message,
|
|
1045
1057
|
isStatusMessageTerminal,
|
|
1046
1058
|
}),
|
|
1047
|
-
|
|
1059
|
+
}, {
|
|
1060
|
+
idempotent: true,
|
|
1061
|
+
timeoutMs: 10_000,
|
|
1062
|
+
label: 'PUT status message',
|
|
1063
|
+
retryWindowMs: options?.retryWindowMs,
|
|
1064
|
+
}, async (response) => (await response.json().catch(() => ({})))).catch((err) => {
|
|
1065
|
+
throw new Error(`Failed to update status message: ${describeRunApiError(err)}`);
|
|
1048
1066
|
});
|
|
1049
|
-
if (!response.ok) {
|
|
1050
|
-
const error = await response.text();
|
|
1051
|
-
throw new Error(`Failed to update status message: ${response.status} ${error}`);
|
|
1052
|
-
}
|
|
1053
|
-
const body = (await response.json());
|
|
1054
1067
|
return (body.data ?? {});
|
|
1055
1068
|
}
|
|
1056
1069
|
/**
|
|
@@ -1204,18 +1217,18 @@ class Actor {
|
|
|
1204
1217
|
console.log(`[Scrapely] Metamorphosing to actor ${targetActorId}`);
|
|
1205
1218
|
// Signal the server to perform the metamorph
|
|
1206
1219
|
try {
|
|
1207
|
-
|
|
1220
|
+
// Retried only while certainly not applied (429, limiter/HAProxy 503,
|
|
1221
|
+
// refused connection): a second metamorph must never be started.
|
|
1222
|
+
await (0, api_storage_client_js_1.fetchWithRetries)(`${apiUrl}/v2/actor-runs/${runId}/metamorph`, {
|
|
1208
1223
|
method: 'POST',
|
|
1209
1224
|
headers: {
|
|
1210
1225
|
'Content-Type': 'application/json',
|
|
1211
1226
|
'Authorization': `Bearer ${token}`,
|
|
1212
1227
|
},
|
|
1213
1228
|
body: JSON.stringify(body),
|
|
1229
|
+
}, { idempotent: false, timeoutMs: 30_000, label: 'POST metamorph' }, async () => undefined).catch((err) => {
|
|
1230
|
+
throw new Error(`Metamorph request failed: ${describeRunApiError(err)}`);
|
|
1214
1231
|
});
|
|
1215
|
-
if (!response.ok) {
|
|
1216
|
-
const error = await response.text();
|
|
1217
|
-
throw new Error(`Metamorph request failed: ${response.status} ${error}`);
|
|
1218
|
-
}
|
|
1219
1232
|
console.log('[Scrapely] Metamorph request accepted, exiting...');
|
|
1220
1233
|
}
|
|
1221
1234
|
catch (err) {
|
|
@@ -1302,18 +1315,18 @@ class Actor {
|
|
|
1302
1315
|
const token = config.get('token') || '';
|
|
1303
1316
|
const runId = config.get('actorRunId') || '';
|
|
1304
1317
|
try {
|
|
1305
|
-
|
|
1318
|
+
// Retried only while certainly not applied (429, limiter/HAProxy 503,
|
|
1319
|
+
// refused connection).
|
|
1320
|
+
const status = await (0, api_storage_client_js_1.fetchWithRetries)(`${apiUrl}/v2/actor-runs/${runId}/reboot`, {
|
|
1306
1321
|
method: 'POST',
|
|
1307
1322
|
headers: {
|
|
1308
1323
|
'Authorization': `Bearer ${token}`,
|
|
1309
1324
|
'Content-Type': 'application/json',
|
|
1310
1325
|
},
|
|
1326
|
+
}, { idempotent: false, timeoutMs: 30_000, label: 'POST reboot' }, async (response) => response.status).catch((err) => {
|
|
1327
|
+
throw new Error(`Reboot request failed: ${describeRunApiError(err)}`);
|
|
1311
1328
|
});
|
|
1312
|
-
|
|
1313
|
-
const error = await response.text();
|
|
1314
|
-
throw new Error(`Reboot request failed: ${response.status} ${error}`);
|
|
1315
|
-
}
|
|
1316
|
-
console.log(`[Scrapely] Reboot request accepted (${response.status})`);
|
|
1329
|
+
console.log(`[Scrapely] Reboot request accepted (${status})`);
|
|
1317
1330
|
// Wait briefly for the reboot signal to be processed, then exit
|
|
1318
1331
|
const sleepMillis = options?.customAfterSleepMillis ?? Actor._actorConfig().get('metamorphAfterSleepMillis', 1000);
|
|
1319
1332
|
await new Promise((resolve) => setTimeout(resolve, sleepMillis));
|
package/dist/charging.d.ts
CHANGED
|
@@ -161,10 +161,29 @@ export declare class ChargingManager {
|
|
|
161
161
|
_chargeableWithinLimit(): Record<string, number>;
|
|
162
162
|
private getChargeableWithinLimit;
|
|
163
163
|
private quarantineStalePending;
|
|
164
|
+
/**
|
|
165
|
+
* The run client with scrapely-client's internal retries off (one request
|
|
166
|
+
* per call) and its timeout capped at `timeoutMs`, so the retry loops here
|
|
167
|
+
* decide when to retry and never outlive their deadline. `meta.retryAfter`
|
|
168
|
+
* receives the Retry-After of the last answer (ApifyApiError drops the
|
|
169
|
+
* headers). A client without that shape (tests, custom clients) is used as
|
|
170
|
+
* it is.
|
|
171
|
+
*/
|
|
172
|
+
private runClientOnce;
|
|
173
|
+
/**
|
|
174
|
+
* Sleep before the next same-key attempt; false when none is left (attempts,
|
|
175
|
+
* the API retry window from `started`, or `deadline` would be passed).
|
|
176
|
+
* Never sooner than the server's Retry-After, and at least 1 s after any
|
|
177
|
+
* 429 (the server's minimum, also when the header is missing or malformed).
|
|
178
|
+
*/
|
|
179
|
+
private backoffBeforeRetry;
|
|
180
|
+
/** Time left for one attempt before `deadline`, if any. */
|
|
181
|
+
private static attemptTimeout;
|
|
164
182
|
/**
|
|
165
183
|
* POST a charge intent. Returns acceptedCount on success.
|
|
166
184
|
* Throws ChargeRejectedError for definitive platform rejections (no retry).
|
|
167
|
-
* Returns null when the outcome is uncertain after retries
|
|
185
|
+
* Returns null when the outcome is uncertain after retries (the intent stays
|
|
186
|
+
* pending); `deadline` (epoch ms) ends the retries earlier, e.g. at exit.
|
|
168
187
|
*/
|
|
169
188
|
private postChargeWithRetries;
|
|
170
189
|
/** @internal Idempotency key of the charge accepted by the last _chargeExclusive call, if any. */
|
|
@@ -181,12 +200,12 @@ export declare class ChargingManager {
|
|
|
181
200
|
* means it never applied.
|
|
182
201
|
*/
|
|
183
202
|
_cancelUncertainCharge(eventName: string, operationId: string, reservedCount: number): Promise<boolean>;
|
|
184
|
-
_voidCharge(eventName: string, operationId: string, reservedCount: number, notFoundMeansVoided?: boolean): Promise<boolean>;
|
|
203
|
+
_voidCharge(eventName: string, operationId: string, reservedCount: number, notFoundMeansVoided?: boolean, deadline?: number): Promise<boolean>;
|
|
185
204
|
/**
|
|
186
205
|
* @internal Retry unconfirmed voids (caller holds the operation lock).
|
|
187
206
|
* Returns false while any earlier void is still unconfirmed.
|
|
188
207
|
*/
|
|
189
|
-
_retryPendingVoids(): Promise<boolean>;
|
|
208
|
+
_retryPendingVoids(deadline?: number): Promise<boolean>;
|
|
190
209
|
/** Clamp local reservation down when the server accepted fewer events. */
|
|
191
210
|
/** @internal Used by pushDataAndCharge dual-event reconciliation. */
|
|
192
211
|
applyAcceptedCount(eventName: string, reservedCount: number, acceptedCount: number, eventPrice?: number): number;
|
package/dist/charging.js
CHANGED
|
@@ -16,10 +16,23 @@ exports.pushCertainlyFailed = pushCertainlyFailed;
|
|
|
16
16
|
// Modified by TeraShift GmbH. See LICENSE-APACHE and NOTICE.
|
|
17
17
|
const node_async_hooks_1 = require("node:async_hooks");
|
|
18
18
|
const node_crypto_1 = require("node:crypto");
|
|
19
|
+
const api_storage_client_js_1 = require("./storage/api-storage-client.js");
|
|
19
20
|
/** Client safe-replay window; server TTL is longer (35d). */
|
|
20
21
|
exports.CHARGE_IDEMPOTENCY_SAFE_AGE_MS = 30 * 24 * 60 * 60 * 1000;
|
|
21
22
|
const PENDING_MAX_ENTRIES = 1024;
|
|
22
|
-
|
|
23
|
+
// Same-key attempts per charge / void. Each attempt is a single request
|
|
24
|
+
// (scrapely-client's own retries are switched off for it, see runClientOnce),
|
|
25
|
+
// so the retries here can honour deadlines: 429, 408, 5xx, timeouts and
|
|
26
|
+
// network errors are re-sent with the same idempotency key, with exponential
|
|
27
|
+
// backoff and jitter, never sooner than the server's Retry-After, within the API
|
|
28
|
+
// retry window (2 minutes, SCRAPELY_API_RETRY_WINDOW_SECS) or the caller's
|
|
29
|
+
// deadline. Then the charge stays pending / the void is re-queued and retried
|
|
30
|
+
// before the next push and at exit, the same as the Python SDK.
|
|
31
|
+
const POST_ATTEMPTS = 64;
|
|
32
|
+
/** 408/429 and 5xx: the platform could not take the request now; try again later. */
|
|
33
|
+
function isTransientStatus(status) {
|
|
34
|
+
return status === 408 || status === 429 || (status !== undefined && status >= 500);
|
|
35
|
+
}
|
|
23
36
|
class ChargeUncertainError extends Error {
|
|
24
37
|
operationId;
|
|
25
38
|
eventName;
|
|
@@ -487,16 +500,76 @@ class ChargingManager {
|
|
|
487
500
|
}
|
|
488
501
|
}
|
|
489
502
|
}
|
|
503
|
+
/**
|
|
504
|
+
* The run client with scrapely-client's internal retries off (one request
|
|
505
|
+
* per call) and its timeout capped at `timeoutMs`, so the retry loops here
|
|
506
|
+
* decide when to retry and never outlive their deadline. `meta.retryAfter`
|
|
507
|
+
* receives the Retry-After of the last answer (ApifyApiError drops the
|
|
508
|
+
* headers). A client without that shape (tests, custom clients) is used as
|
|
509
|
+
* it is.
|
|
510
|
+
*/
|
|
511
|
+
runClientOnce(timeoutMs) {
|
|
512
|
+
const meta = { retryAfter: null };
|
|
513
|
+
const run = this.apifyClient.run(this.actorRunId);
|
|
514
|
+
const http = run.httpClient;
|
|
515
|
+
if (http && typeof http === 'object' && typeof http.maxRetries === 'number') {
|
|
516
|
+
const once = Object.create(http);
|
|
517
|
+
once.maxRetries = 0;
|
|
518
|
+
if (timeoutMs !== undefined && typeof http.timeoutMillis === 'number') {
|
|
519
|
+
once.timeoutMillis = Math.max(1000, Math.min(http.timeoutMillis, timeoutMs));
|
|
520
|
+
}
|
|
521
|
+
const axios = http.axios;
|
|
522
|
+
if (axios && typeof axios.request === 'function') {
|
|
523
|
+
const request = axios.request.bind(axios);
|
|
524
|
+
once.axios = Object.assign(Object.create(axios), {
|
|
525
|
+
request: async (cfg) => {
|
|
526
|
+
const response = (await request(cfg));
|
|
527
|
+
const h = response?.headers;
|
|
528
|
+
const value = typeof h?.get === 'function' ? h.get('retry-after') : h?.['retry-after'];
|
|
529
|
+
meta.retryAfter = value === undefined || value === null ? null : String(value);
|
|
530
|
+
return response;
|
|
531
|
+
},
|
|
532
|
+
});
|
|
533
|
+
}
|
|
534
|
+
run.httpClient = once;
|
|
535
|
+
}
|
|
536
|
+
return { run, meta };
|
|
537
|
+
}
|
|
538
|
+
/**
|
|
539
|
+
* Sleep before the next same-key attempt; false when none is left (attempts,
|
|
540
|
+
* the API retry window from `started`, or `deadline` would be passed).
|
|
541
|
+
* Never sooner than the server's Retry-After, and at least 1 s after any
|
|
542
|
+
* 429 (the server's minimum, also when the header is missing or malformed).
|
|
543
|
+
*/
|
|
544
|
+
async backoffBeforeRetry(attempt, started, err, deadline, retryAfter = null) {
|
|
545
|
+
if (attempt + 1 >= this.postAttempts)
|
|
546
|
+
return false;
|
|
547
|
+
let delay = (0, api_storage_client_js_1.serverRetryDelayMs)(attempt, retryAfter);
|
|
548
|
+
if (httpStatusOf(err) === 429)
|
|
549
|
+
delay = Math.max(delay, 1000 + Math.random() * 1000);
|
|
550
|
+
const end = Math.min(started + (0, api_storage_client_js_1.apiRetryWindowMs)(), deadline ?? Number.POSITIVE_INFINITY);
|
|
551
|
+
if (Date.now() + delay >= end)
|
|
552
|
+
return false;
|
|
553
|
+
await new Promise((r) => setTimeout(r, delay));
|
|
554
|
+
return true;
|
|
555
|
+
}
|
|
556
|
+
/** Time left for one attempt before `deadline`, if any. */
|
|
557
|
+
static attemptTimeout(deadline) {
|
|
558
|
+
return deadline === undefined ? undefined : Math.max(0, deadline - Date.now());
|
|
559
|
+
}
|
|
490
560
|
/**
|
|
491
561
|
* POST a charge intent. Returns acceptedCount on success.
|
|
492
562
|
* Throws ChargeRejectedError for definitive platform rejections (no retry).
|
|
493
|
-
* Returns null when the outcome is uncertain after retries
|
|
563
|
+
* Returns null when the outcome is uncertain after retries (the intent stays
|
|
564
|
+
* pending); `deadline` (epoch ms) ends the retries earlier, e.g. at exit.
|
|
494
565
|
*/
|
|
495
|
-
async postChargeWithRetries(intent) {
|
|
566
|
+
async postChargeWithRetries(intent, deadline) {
|
|
496
567
|
let lastError;
|
|
568
|
+
const started = Date.now();
|
|
497
569
|
for (let attempt = 0; attempt < this.postAttempts; attempt++) {
|
|
570
|
+
const { run, meta } = this.runClientOnce(ChargingManager.attemptTimeout(deadline));
|
|
498
571
|
try {
|
|
499
|
-
const response = await
|
|
572
|
+
const response = await run.charge({
|
|
500
573
|
eventName: intent.eventName,
|
|
501
574
|
count: intent.count,
|
|
502
575
|
idempotencyKey: intent.idempotencyKey,
|
|
@@ -511,9 +584,8 @@ class ChargingManager {
|
|
|
511
584
|
if (rejected) {
|
|
512
585
|
throw rejected;
|
|
513
586
|
}
|
|
514
|
-
if (attempt
|
|
515
|
-
|
|
516
|
-
}
|
|
587
|
+
if (!(await this.backoffBeforeRetry(attempt, started, err, deadline, meta.retryAfter)))
|
|
588
|
+
break;
|
|
517
589
|
}
|
|
518
590
|
}
|
|
519
591
|
console.warn(`[Scrapely] Failed to record charge for event ${intent.eventName} ` +
|
|
@@ -542,11 +614,13 @@ class ChargingManager {
|
|
|
542
614
|
this.pendingCharges.delete(operationId);
|
|
543
615
|
return this._voidCharge(eventName, operationId, reservedCount, true);
|
|
544
616
|
}
|
|
545
|
-
async _voidCharge(eventName, operationId, reservedCount, notFoundMeansVoided = false) {
|
|
617
|
+
async _voidCharge(eventName, operationId, reservedCount, notFoundMeansVoided = false, deadline) {
|
|
546
618
|
if (!this.isAtHome || !this.actorRunId)
|
|
547
619
|
return false;
|
|
548
|
-
const
|
|
620
|
+
const started = Date.now();
|
|
549
621
|
for (let attempt = 0; attempt < this.postAttempts; attempt++) {
|
|
622
|
+
const { run, meta } = this.runClientOnce(ChargingManager.attemptTimeout(deadline));
|
|
623
|
+
const runClient = run;
|
|
550
624
|
try {
|
|
551
625
|
await runClient.httpClient.call({
|
|
552
626
|
url: runClient._url('charge/void'),
|
|
@@ -568,10 +642,11 @@ class ChargingManager {
|
|
|
568
642
|
}
|
|
569
643
|
// 404: nothing to void; 409: already settled; 4xx auth: permanent.
|
|
570
644
|
// The charge stands; keep the local reservation that matches it.
|
|
571
|
-
|
|
645
|
+
// 408/429 (rate limited) and 5xx are not answers: re-queue below.
|
|
646
|
+
if (status !== undefined && status >= 400 && status < 500 && !isTransientStatus(status))
|
|
572
647
|
return false;
|
|
573
|
-
if (attempt
|
|
574
|
-
|
|
648
|
+
if (!(await this.backoffBeforeRetry(attempt, started, err, deadline, meta.retryAfter)))
|
|
649
|
+
break;
|
|
575
650
|
}
|
|
576
651
|
}
|
|
577
652
|
// Outcome unknown: keep the reservation and retry before the next push / at exit.
|
|
@@ -583,13 +658,21 @@ class ChargingManager {
|
|
|
583
658
|
* @internal Retry unconfirmed voids (caller holds the operation lock).
|
|
584
659
|
* Returns false while any earlier void is still unconfirmed.
|
|
585
660
|
*/
|
|
586
|
-
async _retryPendingVoids() {
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
661
|
+
async _retryPendingVoids(deadline) {
|
|
662
|
+
for (let round = 0;; round++) {
|
|
663
|
+
const pending = this.pendingVoids;
|
|
664
|
+
this.pendingVoids = [];
|
|
665
|
+
for (const v of pending) {
|
|
666
|
+
await this._voidCharge(v.eventName, v.operationId, v.reservedCount, v.notFoundMeansVoided, deadline);
|
|
667
|
+
}
|
|
668
|
+
if (this.pendingVoids.length === 0)
|
|
669
|
+
return true;
|
|
670
|
+
// With a deadline (exit flush): keep retrying with backoff while time is left.
|
|
671
|
+
const delay = (0, api_storage_client_js_1.serverRetryDelayMs)(round, null);
|
|
672
|
+
if (deadline === undefined || Date.now() + delay >= deadline)
|
|
673
|
+
return false;
|
|
674
|
+
await new Promise((r) => setTimeout(r, delay));
|
|
591
675
|
}
|
|
592
|
-
return this.pendingVoids.length === 0;
|
|
593
676
|
}
|
|
594
677
|
/** Clamp local reservation down when the server accepted fewer events. */
|
|
595
678
|
/** @internal Used by pushDataAndCharge dual-event reconciliation. */
|
|
@@ -624,11 +707,13 @@ class ChargingManager {
|
|
|
624
707
|
async _flushPendingExclusive(maxIntents = 32, deadlineMs) {
|
|
625
708
|
if (!this.actorRunId)
|
|
626
709
|
return 0;
|
|
627
|
-
|
|
710
|
+
const deadline = deadlineMs !== undefined ? Date.now() + Math.max(0, deadlineMs) : undefined;
|
|
711
|
+
// Voids first: an unconfirmed one means the user may be billed for items
|
|
712
|
+
// that were never stored. At exit (with a deadline) keep retrying them.
|
|
713
|
+
await this._retryPendingVoids(deadline);
|
|
628
714
|
if (this.flushInFlight) {
|
|
629
715
|
return this.flushInFlight;
|
|
630
716
|
}
|
|
631
|
-
const deadline = deadlineMs !== undefined ? Date.now() + Math.max(0, deadlineMs) : undefined;
|
|
632
717
|
this.flushInFlight = (async () => {
|
|
633
718
|
let confirmedTotal = 0;
|
|
634
719
|
// Drain loop: keep flushing batches until empty or deadline.
|
|
@@ -646,7 +731,7 @@ class ChargingManager {
|
|
|
646
731
|
if (deadline !== undefined && Date.now() >= deadline)
|
|
647
732
|
break;
|
|
648
733
|
try {
|
|
649
|
-
const accepted = await this.postChargeWithRetries(intent);
|
|
734
|
+
const accepted = await this.postChargeWithRetries(intent, deadline);
|
|
650
735
|
if (accepted !== null) {
|
|
651
736
|
if (accepted < intent.count)
|
|
652
737
|
this.markChargeLimitHit();
|
|
@@ -13,18 +13,64 @@ export declare class ApiStorageClientError extends Error {
|
|
|
13
13
|
constructor(message: string, statusCode: number);
|
|
14
14
|
}
|
|
15
15
|
/**
|
|
16
|
-
* Whether a non-2xx answer is retried.
|
|
17
|
-
*
|
|
18
|
-
*
|
|
19
|
-
*
|
|
20
|
-
*
|
|
21
|
-
*
|
|
16
|
+
* Whether a non-2xx answer is retried. 429 always: the run API limiter and
|
|
17
|
+
* dataset backpressure refuse it before anything is written. Idempotent
|
|
18
|
+
* methods: also 500/502/503/504. Others (dataset push, KV PUT) only on a 503
|
|
19
|
+
* that certainly was not processed: the rate limiter refused it
|
|
20
|
+
* ("rate-limit-unavailable", before the handler), or HAProxy had no backend
|
|
21
|
+
* (its own page, nothing forwarded). Any other 503 may mean "outcome unknown"
|
|
22
|
+
* (e.g. a direct dataset write whose commit was not confirmed), and re-sending
|
|
23
|
+
* could store the items twice.
|
|
22
24
|
*/
|
|
23
25
|
export declare function isRetryableResponse(status: number, idempotent: boolean, errorType?: string, bodyText?: string): boolean;
|
|
24
|
-
/**
|
|
25
|
-
export declare
|
|
26
|
+
/** Default time budget for retrying one API call (429, 503, network errors). */
|
|
27
|
+
export declare const DEFAULT_API_RETRY_WINDOW_MS = 120000;
|
|
28
|
+
/**
|
|
29
|
+
* How long one API call keeps retrying before it gives up:
|
|
30
|
+
* SCRAPELY_API_RETRY_WINDOW_SECS when set, else {@link BaseApiClient.retryWindowMs}
|
|
31
|
+
* (2 minutes by default).
|
|
32
|
+
*/
|
|
33
|
+
export declare function apiRetryWindowMs(): number;
|
|
34
|
+
/**
|
|
35
|
+
* Delay before retry number `attempt + 1`: exponential backoff (0.5 s doubling,
|
|
36
|
+
* capped at 30 s) with equal jitter, and never earlier than the server's
|
|
37
|
+
* Retry-After (seconds or HTTP date) plus a little jitter so
|
|
38
|
+
* throttled clients don't all come back in the same instant.
|
|
39
|
+
*/
|
|
40
|
+
export declare function serverRetryDelayMs(attempt: number, retryAfter: string | null, random?: () => number, now?: number): number;
|
|
26
41
|
/** fetch() failed before the request reached the server. */
|
|
27
42
|
export declare function isConnectFailure(err: unknown): boolean;
|
|
43
|
+
export interface RetryingFetchOptions {
|
|
44
|
+
/** Safe to re-send when the outcome is unknown (5xx, timeout, reset). */
|
|
45
|
+
idempotent: boolean;
|
|
46
|
+
/** Per-attempt timeout. */
|
|
47
|
+
timeoutMs: number;
|
|
48
|
+
/** Retry timeouts (idempotent calls only; a timed-out write may have been applied). */
|
|
49
|
+
retryTimeouts?: boolean;
|
|
50
|
+
/** Total time budget for retries; defaults to {@link apiRetryWindowMs}. */
|
|
51
|
+
retryWindowMs?: number;
|
|
52
|
+
/** Short label for log lines, e.g. "POST /datasets/x/items". */
|
|
53
|
+
label: string;
|
|
54
|
+
}
|
|
55
|
+
export interface RetryingFetchInit {
|
|
56
|
+
method: string;
|
|
57
|
+
headers: Record<string, string>;
|
|
58
|
+
body?: BodyInit;
|
|
59
|
+
}
|
|
60
|
+
/**
|
|
61
|
+
* fetch() with the API retry policy. Retries, with exponential backoff and
|
|
62
|
+
* jitter that honours Retry-After, until the retry window (2 minutes by
|
|
63
|
+
* default) runs out:
|
|
64
|
+
* - 429 (run API rate limit, dataset backpressure): always; nothing was applied.
|
|
65
|
+
* - 503 from the limiter or HAProxy: always; nothing was forwarded.
|
|
66
|
+
* - 500/502/503/504, timeouts and dropped connections: idempotent calls only.
|
|
67
|
+
* - Refused/unresolvable connections: always; the request never left.
|
|
68
|
+
* A write (dataset push, KV PUT, metamorph) whose request may have reached the
|
|
69
|
+
* API is never re-sent: there is no idempotency key for it, so a re-send could
|
|
70
|
+
* store the same rows twice. Such failures surface to the caller instead.
|
|
71
|
+
* Non-2xx answers that are not retried throw ApiStorageClientError.
|
|
72
|
+
*/
|
|
73
|
+
export declare function fetchWithRetries<R>(url: string, init: RetryingFetchInit, options: RetryingFetchOptions, decode: (response: Response) => Promise<R>): Promise<R>;
|
|
28
74
|
/**
|
|
29
75
|
* Options for API requests (passed through from RecordOptions)
|
|
30
76
|
*/
|
|
@@ -44,16 +90,12 @@ interface ApiRequestOptions {
|
|
|
44
90
|
export declare abstract class BaseApiClient {
|
|
45
91
|
protected config: ApiStorageConfig;
|
|
46
92
|
protected headers: Record<string, string>;
|
|
47
|
-
|
|
48
|
-
private static maxRetries;
|
|
49
|
-
static maxServerRetries: number;
|
|
93
|
+
static retryWindowMs: number;
|
|
50
94
|
private static defaultTimeoutMs;
|
|
51
95
|
constructor(config: ApiStorageConfig);
|
|
52
96
|
/**
|
|
53
97
|
* Sends the request and returns the successful (2xx) response, with the
|
|
54
|
-
* retry policy
|
|
55
|
-
* unavailable (503, 5xx for idempotent methods, connection failures).
|
|
56
|
-
* Non-2xx answers that are not retried throw ApiStorageClientError.
|
|
98
|
+
* retry policy of {@link fetchWithRetries}.
|
|
57
99
|
*/
|
|
58
100
|
private send;
|
|
59
101
|
protected request<T>(method: string, path: string, body?: any, contentType?: string, options?: ApiRequestOptions): Promise<T>;
|
|
@@ -1,9 +1,11 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
-
exports.ApiStorageClientFactory = exports.ApiRequestQueueClient = exports.ApiKeyValueStoreClient = exports.ApiDatasetClient = exports.BaseApiClient = exports.ApiStorageClientError = void 0;
|
|
3
|
+
exports.ApiStorageClientFactory = exports.ApiRequestQueueClient = exports.ApiKeyValueStoreClient = exports.ApiDatasetClient = exports.BaseApiClient = exports.DEFAULT_API_RETRY_WINDOW_MS = exports.ApiStorageClientError = void 0;
|
|
4
4
|
exports.isRetryableResponse = isRetryableResponse;
|
|
5
|
+
exports.apiRetryWindowMs = apiRetryWindowMs;
|
|
5
6
|
exports.serverRetryDelayMs = serverRetryDelayMs;
|
|
6
7
|
exports.isConnectFailure = isConnectFailure;
|
|
8
|
+
exports.fetchWithRetries = fetchWithRetries;
|
|
7
9
|
const node_crypto_1 = require("node:crypto");
|
|
8
10
|
const charging_js_1 = require("../charging.js");
|
|
9
11
|
const kvs_iteration_js_1 = require("./kvs-iteration.js");
|
|
@@ -94,32 +96,6 @@ class AdaptiveRateLimiter {
|
|
|
94
96
|
}
|
|
95
97
|
this.lastRequestTime = Date.now();
|
|
96
98
|
}
|
|
97
|
-
/**
|
|
98
|
-
* Handle a 429 rate limit response.
|
|
99
|
-
* Parses Retry-After header and waits the appropriate time.
|
|
100
|
-
*/
|
|
101
|
-
async handleRateLimited(response) {
|
|
102
|
-
const retryAfter = response.headers.get('Retry-After');
|
|
103
|
-
let waitMs = 1000; // Default 1 second
|
|
104
|
-
if (retryAfter) {
|
|
105
|
-
// Retry-After can be seconds or a date
|
|
106
|
-
const seconds = parseInt(retryAfter, 10);
|
|
107
|
-
if (!isNaN(seconds)) {
|
|
108
|
-
waitMs = seconds * 1000;
|
|
109
|
-
}
|
|
110
|
-
else {
|
|
111
|
-
// It's a date string
|
|
112
|
-
const retryDate = new Date(retryAfter).getTime();
|
|
113
|
-
if (!isNaN(retryDate)) {
|
|
114
|
-
waitMs = Math.max(0, retryDate - Date.now());
|
|
115
|
-
}
|
|
116
|
-
}
|
|
117
|
-
}
|
|
118
|
-
// Add jitter to prevent thundering herd
|
|
119
|
-
waitMs = waitMs + Math.random() * 100;
|
|
120
|
-
console.warn(`[RateLimiter] Rate limited, waiting ${Math.round(waitMs)}ms before retry`);
|
|
121
|
-
await new Promise(resolve => setTimeout(resolve, waitMs));
|
|
122
|
-
}
|
|
123
99
|
}
|
|
124
100
|
/**
|
|
125
101
|
* Unwrap Apify-style v2 response envelopes ({ data: ... }).
|
|
@@ -166,32 +142,62 @@ const IDEMPOTENT_METHODS = new Set(['GET', 'HEAD', 'DELETE', 'OPTIONS']);
|
|
|
166
142
|
const NO_BACKEND_MARKER = 'No server is available to handle this request';
|
|
167
143
|
const CONNECT_FAILURE_CODES = new Set(['ECONNREFUSED', 'ENOTFOUND', 'EAI_AGAIN', 'EHOSTUNREACH', 'ENETUNREACH', 'UND_ERR_CONNECT_TIMEOUT']);
|
|
168
144
|
/**
|
|
169
|
-
* Whether a non-2xx answer is retried.
|
|
170
|
-
*
|
|
171
|
-
*
|
|
172
|
-
*
|
|
173
|
-
*
|
|
174
|
-
*
|
|
145
|
+
* Whether a non-2xx answer is retried. 429 always: the run API limiter and
|
|
146
|
+
* dataset backpressure refuse it before anything is written. Idempotent
|
|
147
|
+
* methods: also 500/502/503/504. Others (dataset push, KV PUT) only on a 503
|
|
148
|
+
* that certainly was not processed: the rate limiter refused it
|
|
149
|
+
* ("rate-limit-unavailable", before the handler), or HAProxy had no backend
|
|
150
|
+
* (its own page, nothing forwarded). Any other 503 may mean "outcome unknown"
|
|
151
|
+
* (e.g. a direct dataset write whose commit was not confirmed), and re-sending
|
|
152
|
+
* could store the items twice.
|
|
175
153
|
*/
|
|
176
154
|
function isRetryableResponse(status, idempotent, errorType, bodyText) {
|
|
155
|
+
if (status === 429)
|
|
156
|
+
return true;
|
|
177
157
|
if (idempotent)
|
|
178
158
|
return status === 500 || status === 502 || status === 503 || status === 504;
|
|
179
159
|
if (status !== 503)
|
|
180
160
|
return false;
|
|
181
161
|
return errorType === 'rate-limit-unavailable' || (bodyText ?? '').includes(NO_BACKEND_MARKER);
|
|
182
162
|
}
|
|
183
|
-
/**
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
163
|
+
/** Default time budget for retrying one API call (429, 503, network errors). */
|
|
164
|
+
exports.DEFAULT_API_RETRY_WINDOW_MS = 120_000;
|
|
165
|
+
const MAX_BACKOFF_MS = 30_000;
|
|
166
|
+
/**
|
|
167
|
+
* How long one API call keeps retrying before it gives up:
|
|
168
|
+
* SCRAPELY_API_RETRY_WINDOW_SECS when set, else {@link BaseApiClient.retryWindowMs}
|
|
169
|
+
* (2 minutes by default).
|
|
170
|
+
*/
|
|
171
|
+
function apiRetryWindowMs() {
|
|
172
|
+
const env = Number(process.env.SCRAPELY_API_RETRY_WINDOW_SECS);
|
|
173
|
+
if (process.env.SCRAPELY_API_RETRY_WINDOW_SECS && Number.isFinite(env) && env >= 0)
|
|
174
|
+
return env * 1000;
|
|
175
|
+
return BaseApiClient.retryWindowMs;
|
|
176
|
+
}
|
|
177
|
+
function parseRetryAfterMs(retryAfter, now) {
|
|
178
|
+
if (retryAfter === null || retryAfter === undefined || retryAfter.trim() === '')
|
|
179
|
+
return undefined;
|
|
180
|
+
let secs = Number(retryAfter);
|
|
181
|
+
if (!Number.isFinite(secs)) {
|
|
182
|
+
const at = Date.parse(retryAfter);
|
|
183
|
+
secs = Number.isFinite(at) ? Math.max(0, (at - now) / 1000) : NaN;
|
|
184
|
+
}
|
|
185
|
+
// Not capped: a Retry-After past the retry window ends the retries instead.
|
|
186
|
+
return Number.isFinite(secs) && secs >= 0 ? secs * 1000 : undefined;
|
|
187
|
+
}
|
|
188
|
+
/**
|
|
189
|
+
* Delay before retry number `attempt + 1`: exponential backoff (0.5 s doubling,
|
|
190
|
+
* capped at 30 s) with equal jitter, and never earlier than the server's
|
|
191
|
+
* Retry-After (seconds or HTTP date) plus a little jitter so
|
|
192
|
+
* throttled clients don't all come back in the same instant.
|
|
193
|
+
*/
|
|
194
|
+
function serverRetryDelayMs(attempt, retryAfter, random = Math.random, now = Date.now()) {
|
|
195
|
+
const exp = Math.min(MAX_BACKOFF_MS, 500 * 2 ** Math.min(attempt, 16));
|
|
196
|
+
const backoff = exp / 2 + random() * (exp / 2);
|
|
197
|
+
const ra = parseRetryAfterMs(retryAfter, now);
|
|
198
|
+
if (ra === undefined)
|
|
199
|
+
return backoff;
|
|
200
|
+
return Math.max(ra + random() * Math.min(1000, ra * 0.2 + 100), backoff);
|
|
195
201
|
}
|
|
196
202
|
function fetchErrorCode(err) {
|
|
197
203
|
const e = err;
|
|
@@ -206,6 +212,109 @@ function describeFetchError(err) {
|
|
|
206
212
|
return fetchErrorCode(err) ?? err?.message ?? String(err);
|
|
207
213
|
}
|
|
208
214
|
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
|
215
|
+
// Shared by every API call of this process (storage clients, status message,
|
|
216
|
+
// metamorph, reboot), so throttling seen on one slows the others too.
|
|
217
|
+
const sharedRateLimiter = new AdaptiveRateLimiter();
|
|
218
|
+
/**
|
|
219
|
+
* fetch() with the API retry policy. Retries, with exponential backoff and
|
|
220
|
+
* jitter that honours Retry-After, until the retry window (2 minutes by
|
|
221
|
+
* default) runs out:
|
|
222
|
+
* - 429 (run API rate limit, dataset backpressure): always; nothing was applied.
|
|
223
|
+
* - 503 from the limiter or HAProxy: always; nothing was forwarded.
|
|
224
|
+
* - 500/502/503/504, timeouts and dropped connections: idempotent calls only.
|
|
225
|
+
* - Refused/unresolvable connections: always; the request never left.
|
|
226
|
+
* A write (dataset push, KV PUT, metamorph) whose request may have reached the
|
|
227
|
+
* API is never re-sent: there is no idempotency key for it, so a re-send could
|
|
228
|
+
* store the same rows twice. Such failures surface to the caller instead.
|
|
229
|
+
* Non-2xx answers that are not retried throw ApiStorageClientError.
|
|
230
|
+
*/
|
|
231
|
+
async function fetchWithRetries(url, init, options, decode) {
|
|
232
|
+
const { idempotent, timeoutMs, label } = options;
|
|
233
|
+
const retryTimeouts = options.retryTimeouts !== false && idempotent;
|
|
234
|
+
const deadline = Date.now() + Math.max(0, options.retryWindowMs ?? apiRetryWindowMs());
|
|
235
|
+
let attempt = 0;
|
|
236
|
+
/** Sleeps before the next attempt; false once the retry window is used up. */
|
|
237
|
+
const backoff = async (why, retryAfter) => {
|
|
238
|
+
// Retrying sooner than asked would only be refused again: give up when
|
|
239
|
+
// the next try would land past the window.
|
|
240
|
+
const waitMs = serverRetryDelayMs(attempt, retryAfter);
|
|
241
|
+
if (Date.now() + waitMs > deadline)
|
|
242
|
+
return false;
|
|
243
|
+
attempt++;
|
|
244
|
+
console.warn(`[Scrapely] ${label} ${why}; retry ${attempt} in ${Math.round(waitMs)}ms`);
|
|
245
|
+
await sleep(waitMs);
|
|
246
|
+
return true;
|
|
247
|
+
};
|
|
248
|
+
for (;;) {
|
|
249
|
+
await sharedRateLimiter.waitForSlot();
|
|
250
|
+
const controller = new AbortController();
|
|
251
|
+
const timeoutId = setTimeout(() => controller.abort(), timeoutMs);
|
|
252
|
+
try {
|
|
253
|
+
const response = await fetch(url, { method: init.method, headers: init.headers, body: init.body, signal: controller.signal });
|
|
254
|
+
sharedRateLimiter.updateFromHeaders(response.headers);
|
|
255
|
+
if (response.ok) {
|
|
256
|
+
try {
|
|
257
|
+
// Decoded inside the retry loop: a body cut off after
|
|
258
|
+
// the headers is a transport failure like any other.
|
|
259
|
+
return await decode(response);
|
|
260
|
+
}
|
|
261
|
+
catch (bodyErr) {
|
|
262
|
+
// The request was applied (2xx); a write whose answer
|
|
263
|
+
// timed out while being read must not be sent again.
|
|
264
|
+
if (bodyErr.name === 'AbortError') {
|
|
265
|
+
if (!idempotent || !retryTimeouts) {
|
|
266
|
+
const timeoutError = new Error(`Request timed out after ${timeoutMs}ms (response body${idempotent ? '' : '; the request was applied'})`);
|
|
267
|
+
timeoutError.isTimeout = true;
|
|
268
|
+
throw timeoutError;
|
|
269
|
+
}
|
|
270
|
+
if (await backoff(`response body timed out after ${timeoutMs}ms`, null))
|
|
271
|
+
continue;
|
|
272
|
+
throw bodyErr;
|
|
273
|
+
}
|
|
274
|
+
if (idempotent && await backoff(`response body failed (${describeFetchError(bodyErr)})`, null))
|
|
275
|
+
continue;
|
|
276
|
+
throw bodyErr;
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
const text = await response.text().catch(() => '');
|
|
280
|
+
let errorBody;
|
|
281
|
+
try {
|
|
282
|
+
errorBody = JSON.parse(text);
|
|
283
|
+
}
|
|
284
|
+
catch {
|
|
285
|
+
errorBody = undefined;
|
|
286
|
+
}
|
|
287
|
+
if (isRetryableResponse(response.status, idempotent, errorBody?.error?.type, text)
|
|
288
|
+
&& await backoff(`answered ${response.status}`, response.headers.get('retry-after'))) {
|
|
289
|
+
continue;
|
|
290
|
+
}
|
|
291
|
+
throw new ApiStorageClientError(errorBody?.error?.message || response.statusText || `API request failed: ${response.status}`, response.status);
|
|
292
|
+
}
|
|
293
|
+
catch (err) {
|
|
294
|
+
// HTTP errors (already decided above) are final.
|
|
295
|
+
if (isApiStorageClientError(err))
|
|
296
|
+
throw err;
|
|
297
|
+
if (err?.isTimeout)
|
|
298
|
+
throw err;
|
|
299
|
+
if (err.name === 'AbortError') {
|
|
300
|
+
const timeoutError = new Error(`Request timed out after ${timeoutMs}ms`);
|
|
301
|
+
timeoutError.isTimeout = true;
|
|
302
|
+
if (retryTimeouts && await backoff(`timed out after ${timeoutMs}ms`, null))
|
|
303
|
+
continue;
|
|
304
|
+
throw timeoutError;
|
|
305
|
+
}
|
|
306
|
+
// fetch() failed without a response. A refused/unresolvable
|
|
307
|
+
// connection never reached the API, so it is safe for every
|
|
308
|
+
// method; anything else (reset mid-request) only for idempotent ones.
|
|
309
|
+
if ((idempotent || isConnectFailure(err)) && await backoff(`failed (${describeFetchError(err)})`, null))
|
|
310
|
+
continue;
|
|
311
|
+
throw err;
|
|
312
|
+
}
|
|
313
|
+
finally {
|
|
314
|
+
clearTimeout(timeoutId);
|
|
315
|
+
}
|
|
316
|
+
}
|
|
317
|
+
}
|
|
209
318
|
/** Map GET storage errors: rethrow 403, return undefined for 404 (triggers getOrCreate for names only). */
|
|
210
319
|
function handleStorageGetError(err) {
|
|
211
320
|
if (isApiStorageClientError(err)) {
|
|
@@ -266,15 +375,10 @@ function makeAsyncIterableResult(promiseFn) {
|
|
|
266
375
|
class BaseApiClient {
|
|
267
376
|
config;
|
|
268
377
|
headers;
|
|
269
|
-
//
|
|
270
|
-
//
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
static maxRetries = 3;
|
|
274
|
-
// Retries while the API is briefly unavailable (503/5xx, connection
|
|
275
|
-
// refused): backoff 0.5 s doubling to 8 s, about 45 s in total, which
|
|
276
|
-
// covers a main redeploy.
|
|
277
|
-
static maxServerRetries = 8;
|
|
378
|
+
// How long one call keeps retrying 429/503/network errors before it gives
|
|
379
|
+
// up (SCRAPELY_API_RETRY_WINDOW_SECS overrides it). Long enough that a
|
|
380
|
+
// throttled run slows down instead of failing, and covers a main redeploy.
|
|
381
|
+
static retryWindowMs = exports.DEFAULT_API_RETRY_WINDOW_MS;
|
|
278
382
|
// Default timeout for requests (30 seconds)
|
|
279
383
|
static defaultTimeoutMs = 30000;
|
|
280
384
|
constructor(config) {
|
|
@@ -287,12 +391,9 @@ class BaseApiClient {
|
|
|
287
391
|
}
|
|
288
392
|
/**
|
|
289
393
|
* Sends the request and returns the successful (2xx) response, with the
|
|
290
|
-
* retry policy
|
|
291
|
-
* unavailable (503, 5xx for idempotent methods, connection failures).
|
|
292
|
-
* Non-2xx answers that are not retried throw ApiStorageClientError.
|
|
394
|
+
* retry policy of {@link fetchWithRetries}.
|
|
293
395
|
*/
|
|
294
396
|
async send(method, path, body, contentType, options, decode) {
|
|
295
|
-
const url = `${this.config.apiUrl}/v2${path}`;
|
|
296
397
|
const headers = { ...this.headers };
|
|
297
398
|
if (contentType) {
|
|
298
399
|
headers['Content-Type'] = contentType;
|
|
@@ -300,114 +401,19 @@ class BaseApiClient {
|
|
|
300
401
|
const timeoutMs = options?.timeoutSecs
|
|
301
402
|
? options.timeoutSecs * 1000
|
|
302
403
|
: BaseApiClient.defaultTimeoutMs;
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
headers,
|
|
317
|
-
// Binary bodies (KV records) go out as the raw bytes, never JSON/base64.
|
|
318
|
-
body: body === undefined ? undefined
|
|
319
|
-
: typeof body === 'string' || body instanceof Uint8Array ? body
|
|
320
|
-
: JSON.stringify(body),
|
|
321
|
-
signal: controller.signal,
|
|
322
|
-
});
|
|
323
|
-
// Update rate limiter state from response headers
|
|
324
|
-
BaseApiClient.rateLimiter.updateFromHeaders(response.headers);
|
|
325
|
-
// Handle rate limiting (429)
|
|
326
|
-
if (response.status === 429) {
|
|
327
|
-
if (attempt < BaseApiClient.maxRetries) {
|
|
328
|
-
attempt++;
|
|
329
|
-
await BaseApiClient.rateLimiter.handleRateLimited(response);
|
|
330
|
-
continue; // Retry
|
|
331
|
-
}
|
|
332
|
-
const error = await response.json().catch(() => ({ error: { message: 'Rate limit exceeded' } }));
|
|
333
|
-
throw new ApiStorageClientError(error.error?.message || 'Rate limit exceeded', 429);
|
|
334
|
-
}
|
|
335
|
-
if (response.ok) {
|
|
336
|
-
try {
|
|
337
|
-
// Decoded inside the retry loop: a body cut off after
|
|
338
|
-
// the headers is a transport failure like any other.
|
|
339
|
-
return await decode(response);
|
|
340
|
-
}
|
|
341
|
-
catch (bodyErr) {
|
|
342
|
-
// The request was applied (2xx); a write whose answer
|
|
343
|
-
// timed out while being read must not be sent again.
|
|
344
|
-
if (bodyErr.name === 'AbortError' && !idempotent) {
|
|
345
|
-
const timeoutError = new Error(`Request timed out after ${timeoutMs}ms (response body; the request was applied)`);
|
|
346
|
-
timeoutError.isTimeout = true;
|
|
347
|
-
throw timeoutError;
|
|
348
|
-
}
|
|
349
|
-
if (idempotent && bodyErr.name !== 'AbortError' && serverAttempt < BaseApiClient.maxServerRetries) {
|
|
350
|
-
const waitMs = serverRetryDelayMs(serverAttempt, null);
|
|
351
|
-
serverAttempt++;
|
|
352
|
-
console.warn(`[Scrapely] ${method} ${path} response body failed (${describeFetchError(bodyErr)}); retry ${serverAttempt}/${BaseApiClient.maxServerRetries} in ${Math.round(waitMs)}ms`);
|
|
353
|
-
await sleep(waitMs);
|
|
354
|
-
continue;
|
|
355
|
-
}
|
|
356
|
-
throw bodyErr;
|
|
357
|
-
}
|
|
358
|
-
}
|
|
359
|
-
// API briefly unavailable (main redeploy, limiter store blip).
|
|
360
|
-
const text = await response.text().catch(() => '');
|
|
361
|
-
let errorBody;
|
|
362
|
-
try {
|
|
363
|
-
errorBody = JSON.parse(text);
|
|
364
|
-
}
|
|
365
|
-
catch {
|
|
366
|
-
errorBody = undefined;
|
|
367
|
-
}
|
|
368
|
-
if (isRetryableResponse(response.status, idempotent, errorBody?.error?.type, text)
|
|
369
|
-
&& serverAttempt < BaseApiClient.maxServerRetries) {
|
|
370
|
-
const waitMs = serverRetryDelayMs(serverAttempt, response.headers.get('retry-after'));
|
|
371
|
-
serverAttempt++;
|
|
372
|
-
console.warn(`[Scrapely] ${method} ${path} answered ${response.status}; retry ${serverAttempt}/${BaseApiClient.maxServerRetries} in ${Math.round(waitMs)}ms`);
|
|
373
|
-
await sleep(waitMs);
|
|
374
|
-
continue;
|
|
375
|
-
}
|
|
376
|
-
throw new ApiStorageClientError(errorBody?.error?.message || response.statusText || `API request failed: ${response.status}`, response.status);
|
|
377
|
-
}
|
|
378
|
-
catch (err) {
|
|
379
|
-
// Handle timeout/abort errors
|
|
380
|
-
if (err.name === 'AbortError') {
|
|
381
|
-
const timeoutError = new Error(`Request timed out after ${timeoutMs}ms`);
|
|
382
|
-
timeoutError.isTimeout = true;
|
|
383
|
-
// Don't retry if doNotRetryTimeouts is true
|
|
384
|
-
if (!shouldRetryTimeouts || attempt >= BaseApiClient.maxRetries) {
|
|
385
|
-
throw timeoutError;
|
|
386
|
-
}
|
|
387
|
-
attempt++;
|
|
388
|
-
// Wait a bit before retrying
|
|
389
|
-
await sleep(100 * attempt);
|
|
390
|
-
continue;
|
|
391
|
-
}
|
|
392
|
-
// HTTP errors (already decided above) are final.
|
|
393
|
-
if (isApiStorageClientError(err))
|
|
394
|
-
throw err;
|
|
395
|
-
// fetch() failed without a response. A refused/unresolvable
|
|
396
|
-
// connection never reached the API, so it is safe for every
|
|
397
|
-
// method; anything else (reset mid-request) only for idempotent ones.
|
|
398
|
-
if ((idempotent || isConnectFailure(err)) && serverAttempt < BaseApiClient.maxServerRetries) {
|
|
399
|
-
const waitMs = serverRetryDelayMs(serverAttempt, null);
|
|
400
|
-
serverAttempt++;
|
|
401
|
-
console.warn(`[Scrapely] ${method} ${path} failed (${describeFetchError(err)}); retry ${serverAttempt}/${BaseApiClient.maxServerRetries} in ${Math.round(waitMs)}ms`);
|
|
402
|
-
await sleep(waitMs);
|
|
403
|
-
continue;
|
|
404
|
-
}
|
|
405
|
-
throw err;
|
|
406
|
-
}
|
|
407
|
-
finally {
|
|
408
|
-
clearTimeout(timeoutId);
|
|
409
|
-
}
|
|
410
|
-
}
|
|
404
|
+
return fetchWithRetries(`${this.config.apiUrl}/v2${path}`, {
|
|
405
|
+
method,
|
|
406
|
+
headers,
|
|
407
|
+
// Binary bodies (KV records) go out as the raw bytes, never JSON/base64.
|
|
408
|
+
body: body === undefined ? undefined
|
|
409
|
+
: typeof body === 'string' || body instanceof Uint8Array ? body
|
|
410
|
+
: JSON.stringify(body),
|
|
411
|
+
}, {
|
|
412
|
+
idempotent: options?.idempotent ?? IDEMPOTENT_METHODS.has(method.toUpperCase()),
|
|
413
|
+
timeoutMs,
|
|
414
|
+
retryTimeouts: options?.doNotRetryTimeouts !== true,
|
|
415
|
+
label: `${method} ${path}`,
|
|
416
|
+
}, decode);
|
|
411
417
|
}
|
|
412
418
|
async request(method, path, body, contentType, options) {
|
|
413
419
|
return this.send(method, path, body, contentType, options, async (response) => {
|
|
@@ -728,7 +734,10 @@ class ApiKeyValueStoreClient extends BaseApiClient {
|
|
|
728
734
|
* @returns A public URL for accessing the record
|
|
729
735
|
*/
|
|
730
736
|
getPublicUrl(key) {
|
|
731
|
-
|
|
737
|
+
// apiUrl is the container-internal API address; links handed to users
|
|
738
|
+
// must use the public base URL the platform injects.
|
|
739
|
+
const publicBase = (process.env.APIFY_API_PUBLIC_BASE_URL || process.env.ACTOR_API_PUBLIC_BASE_URL || this.config.apiUrl).replace(/\/+$/, '');
|
|
740
|
+
return `${publicBase}/v2/key-value-stores/${this.id}/records/${key}`;
|
|
732
741
|
}
|
|
733
742
|
}
|
|
734
743
|
exports.ApiKeyValueStoreClient = ApiKeyValueStoreClient;
|