scrapely 1.2.15 → 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 +43 -19
- package/dist/charging.d.ts +57 -3
- package/dist/charging.js +253 -23
- package/dist/charging_client.js +5 -1
- package/dist/storage/api-storage-client.d.ts +56 -14
- package/dist/storage/api-storage-client.js +181 -167
- package/package.json +2 -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;
|
|
@@ -273,6 +279,8 @@ class Actor {
|
|
|
273
279
|
apifyClientInstance = this._apifyClient;
|
|
274
280
|
apifyClientAssigned = true;
|
|
275
281
|
await this.chargingManager.init();
|
|
282
|
+
// Storage clients refuse default-dataset writes past this run's limit.
|
|
283
|
+
(0, charging_js_1.setActiveChargingManager)(this.chargingManager);
|
|
276
284
|
// Flush pending PPE intents before hard exit so uncertain charges can still settle.
|
|
277
285
|
const gracefulSignalExit = (signal) => {
|
|
278
286
|
const doExit = () => {
|
|
@@ -517,6 +525,14 @@ class Actor {
|
|
|
517
525
|
const { Dataset } = await import('crawlee');
|
|
518
526
|
const dataset = await Dataset.open();
|
|
519
527
|
if (eventName === undefined && !this.usesPushDataInterception(dataset)) {
|
|
528
|
+
// Charge-limit safety net (also for local storage): this push is not
|
|
529
|
+
// charged, so past the run's spending limit it is not stored.
|
|
530
|
+
if (this.initialized &&
|
|
531
|
+
dataset.id === this.config.get('defaultDatasetId') &&
|
|
532
|
+
this.chargingManager.isChargeLimitReached()) {
|
|
533
|
+
this.chargingManager._noteRefusedDefaultDatasetItems(Array.isArray(item) ? item.length : 1);
|
|
534
|
+
return;
|
|
535
|
+
}
|
|
520
536
|
await dataset.pushData(item);
|
|
521
537
|
return;
|
|
522
538
|
}
|
|
@@ -938,7 +954,9 @@ class Actor {
|
|
|
938
954
|
}
|
|
939
955
|
if (cm && typeof cm.flushPending === 'function') {
|
|
940
956
|
await Promise.race([
|
|
941
|
-
|
|
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)),
|
|
942
960
|
new Promise((resolve) => setTimeout(resolve, flushDeadlineMs)),
|
|
943
961
|
]);
|
|
944
962
|
}
|
|
@@ -951,6 +969,7 @@ class Actor {
|
|
|
951
969
|
await eventManager.close();
|
|
952
970
|
(0, events_client_js_1.disconnectEvents)();
|
|
953
971
|
(0, patch_storage_js_1.disconnectStorage)();
|
|
972
|
+
(0, charging_js_1.setActiveChargingManager)(undefined);
|
|
954
973
|
// Let buffered stdout/stderr reach the log pipe before the process exits
|
|
955
974
|
// (pipe writes are async in Node). Storage was flushed above, so this
|
|
956
975
|
// replaces the old fixed 1 s sleep: it resolves as soon as both streams
|
|
@@ -968,6 +987,8 @@ class Actor {
|
|
|
968
987
|
Actor.setStatusMessage(message, {
|
|
969
988
|
isStatusMessageTerminal: true,
|
|
970
989
|
level: exitCode > 0 ? 'ERROR' : 'INFO',
|
|
990
|
+
// Stop retrying when the exit stops waiting for it.
|
|
991
|
+
retryWindowMs: timeoutMs,
|
|
971
992
|
}),
|
|
972
993
|
new Promise((_, reject) => {
|
|
973
994
|
timer = setTimeout(() => reject(new Error(`timed out after ${timeoutMs}ms`)), timeoutMs);
|
|
@@ -1023,7 +1044,9 @@ class Actor {
|
|
|
1023
1044
|
if (!apiUrl || !token || !runId) {
|
|
1024
1045
|
return {};
|
|
1025
1046
|
}
|
|
1026
|
-
|
|
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}`, {
|
|
1027
1050
|
method: 'PUT',
|
|
1028
1051
|
headers: {
|
|
1029
1052
|
'Content-Type': 'application/json',
|
|
@@ -1033,13 +1056,14 @@ class Actor {
|
|
|
1033
1056
|
statusMessage: message,
|
|
1034
1057
|
isStatusMessageTerminal,
|
|
1035
1058
|
}),
|
|
1036
|
-
|
|
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)}`);
|
|
1037
1066
|
});
|
|
1038
|
-
if (!response.ok) {
|
|
1039
|
-
const error = await response.text();
|
|
1040
|
-
throw new Error(`Failed to update status message: ${response.status} ${error}`);
|
|
1041
|
-
}
|
|
1042
|
-
const body = (await response.json());
|
|
1043
1067
|
return (body.data ?? {});
|
|
1044
1068
|
}
|
|
1045
1069
|
/**
|
|
@@ -1193,18 +1217,18 @@ class Actor {
|
|
|
1193
1217
|
console.log(`[Scrapely] Metamorphosing to actor ${targetActorId}`);
|
|
1194
1218
|
// Signal the server to perform the metamorph
|
|
1195
1219
|
try {
|
|
1196
|
-
|
|
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`, {
|
|
1197
1223
|
method: 'POST',
|
|
1198
1224
|
headers: {
|
|
1199
1225
|
'Content-Type': 'application/json',
|
|
1200
1226
|
'Authorization': `Bearer ${token}`,
|
|
1201
1227
|
},
|
|
1202
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)}`);
|
|
1203
1231
|
});
|
|
1204
|
-
if (!response.ok) {
|
|
1205
|
-
const error = await response.text();
|
|
1206
|
-
throw new Error(`Metamorph request failed: ${response.status} ${error}`);
|
|
1207
|
-
}
|
|
1208
1232
|
console.log('[Scrapely] Metamorph request accepted, exiting...');
|
|
1209
1233
|
}
|
|
1210
1234
|
catch (err) {
|
|
@@ -1291,18 +1315,18 @@ class Actor {
|
|
|
1291
1315
|
const token = config.get('token') || '';
|
|
1292
1316
|
const runId = config.get('actorRunId') || '';
|
|
1293
1317
|
try {
|
|
1294
|
-
|
|
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`, {
|
|
1295
1321
|
method: 'POST',
|
|
1296
1322
|
headers: {
|
|
1297
1323
|
'Authorization': `Bearer ${token}`,
|
|
1298
1324
|
'Content-Type': 'application/json',
|
|
1299
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)}`);
|
|
1300
1328
|
});
|
|
1301
|
-
|
|
1302
|
-
const error = await response.text();
|
|
1303
|
-
throw new Error(`Reboot request failed: ${response.status} ${error}`);
|
|
1304
|
-
}
|
|
1305
|
-
console.log(`[Scrapely] Reboot request accepted (${response.status})`);
|
|
1329
|
+
console.log(`[Scrapely] Reboot request accepted (${status})`);
|
|
1306
1330
|
// Wait briefly for the reboot signal to be processed, then exit
|
|
1307
1331
|
const sleepMillis = options?.customAfterSleepMillis ?? Actor._actorConfig().get('metamorphAfterSleepMillis', 1000);
|
|
1308
1332
|
await new Promise((resolve) => setTimeout(resolve, sleepMillis));
|
package/dist/charging.d.ts
CHANGED
|
@@ -29,6 +29,17 @@ export declare class ChargeRejectedError extends Error {
|
|
|
29
29
|
reason: string;
|
|
30
30
|
});
|
|
31
31
|
}
|
|
32
|
+
/** @internal Register the manager whose limit guards default-dataset writes. */
|
|
33
|
+
export declare function setActiveChargingManager(manager: ChargingManager | undefined): void;
|
|
34
|
+
/** @internal The run's default dataset ID, as the platform passes it. */
|
|
35
|
+
export declare function defaultDatasetIdFromEnv(): string | undefined;
|
|
36
|
+
/**
|
|
37
|
+
* @internal True when a write of `itemCount` rows to the default dataset must
|
|
38
|
+
* be dropped (pay-per-event run past its spending limit). Called by storage
|
|
39
|
+
* clients for writes that did not go through `pushDataAndCharge`
|
|
40
|
+
* (`Actor.openDataset().pushData()`, crawler `pushData`, ...).
|
|
41
|
+
*/
|
|
42
|
+
export declare function refuseDefaultDatasetPush(itemCount: number): boolean;
|
|
32
43
|
export declare const APIFY_DEFAULT_DATASET_ITEM_EVENT = "apify-default-dataset-item";
|
|
33
44
|
/** Scrapely-native synthetic event for default-dataset items. */
|
|
34
45
|
export declare const SCRAPELY_DEFAULT_DATASET_ITEM_EVENT = "scrapely-default-dataset-item";
|
|
@@ -62,8 +73,32 @@ export declare class ChargingManager {
|
|
|
62
73
|
private readonly apifyClient;
|
|
63
74
|
/** Set during actor shutdown; rejects new charge operations after the final flush. */
|
|
64
75
|
private _closed;
|
|
76
|
+
/** Sticky: the platform refused or capped a charge (HTTP 402 / fewer accepted) or the local gate trimmed one. */
|
|
77
|
+
private chargeLimitHit;
|
|
78
|
+
/** Default-dataset rows refused after the limit (one warning, then a count). */
|
|
79
|
+
private refusedDefaultDatasetItems;
|
|
65
80
|
/** @internal Called by Actor shutdown before the final flush. */
|
|
66
81
|
close(): void;
|
|
82
|
+
/**
|
|
83
|
+
* Whether this pay-per-event run has used up its spending limit.
|
|
84
|
+
*
|
|
85
|
+
* True once a charge was refused or capped by the limit (the platform's
|
|
86
|
+
* HTTP 402 or a partial acceptance, or the local budget gate), or once the
|
|
87
|
+
* remaining budget cannot pay for one event of the cheapest priced type.
|
|
88
|
+
* From then on, pushes to the default dataset that are not paid for are
|
|
89
|
+
* refused. Always false for actors without pay-per-event pricing, without
|
|
90
|
+
* a limit, or with only free events.
|
|
91
|
+
*/
|
|
92
|
+
isChargeLimitReached(): boolean;
|
|
93
|
+
private markChargeLimitHit;
|
|
94
|
+
/** @internal Count rows dropped because of the limit; warn on the first one only. */
|
|
95
|
+
_noteRefusedDefaultDatasetItems(count: number): void;
|
|
96
|
+
/** @internal */
|
|
97
|
+
_markChargeLimitHit(): void;
|
|
98
|
+
/** @internal Whether every row of a push with these events is charged a positive price. */
|
|
99
|
+
_isPaidPerItem(eventName: string | undefined, isDefaultDataset: boolean): boolean;
|
|
100
|
+
/** @internal */
|
|
101
|
+
_refuseDefaultDatasetPush(itemCount: number): boolean;
|
|
67
102
|
/** @internal Whether shutdown has closed this manager to new charges. */
|
|
68
103
|
isClosed(): boolean;
|
|
69
104
|
private readonly LOCAL_CHARGING_LOG_DATASET_NAME;
|
|
@@ -126,10 +161,29 @@ export declare class ChargingManager {
|
|
|
126
161
|
_chargeableWithinLimit(): Record<string, number>;
|
|
127
162
|
private getChargeableWithinLimit;
|
|
128
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;
|
|
129
182
|
/**
|
|
130
183
|
* POST a charge intent. Returns acceptedCount on success.
|
|
131
184
|
* Throws ChargeRejectedError for definitive platform rejections (no retry).
|
|
132
|
-
* 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.
|
|
133
187
|
*/
|
|
134
188
|
private postChargeWithRetries;
|
|
135
189
|
/** @internal Idempotency key of the charge accepted by the last _chargeExclusive call, if any. */
|
|
@@ -146,12 +200,12 @@ export declare class ChargingManager {
|
|
|
146
200
|
* means it never applied.
|
|
147
201
|
*/
|
|
148
202
|
_cancelUncertainCharge(eventName: string, operationId: string, reservedCount: number): Promise<boolean>;
|
|
149
|
-
_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>;
|
|
150
204
|
/**
|
|
151
205
|
* @internal Retry unconfirmed voids (caller holds the operation lock).
|
|
152
206
|
* Returns false while any earlier void is still unconfirmed.
|
|
153
207
|
*/
|
|
154
|
-
_retryPendingVoids(): Promise<boolean>;
|
|
208
|
+
_retryPendingVoids(deadline?: number): Promise<boolean>;
|
|
155
209
|
/** Clamp local reservation down when the server accepted fewer events. */
|
|
156
210
|
/** @internal Used by pushDataAndCharge dual-event reconciliation. */
|
|
157
211
|
applyAcceptedCount(eventName: string, reservedCount: number, acceptedCount: number, eventPrice?: number): number;
|
package/dist/charging.js
CHANGED
|
@@ -1,6 +1,9 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
3
|
exports.ChargingManager = exports.DEFAULT_DATASET_ITEM_EVENT = exports.SCRAPELY_DEFAULT_DATASET_ITEM_EVENT = exports.APIFY_DEFAULT_DATASET_ITEM_EVENT = exports.ChargeRejectedError = exports.ChargeUncertainError = exports.CHARGE_IDEMPOTENCY_SAFE_AGE_MS = void 0;
|
|
4
|
+
exports.setActiveChargingManager = setActiveChargingManager;
|
|
5
|
+
exports.defaultDatasetIdFromEnv = defaultDatasetIdFromEnv;
|
|
6
|
+
exports.refuseDefaultDatasetPush = refuseDefaultDatasetPush;
|
|
4
7
|
exports.isDefaultDatasetItemEvent = isDefaultDatasetItemEvent;
|
|
5
8
|
exports.resolveDefaultDatasetItemEventName = resolveDefaultDatasetItemEventName;
|
|
6
9
|
exports.hasDefaultDatasetItemEventInPricing = hasDefaultDatasetItemEventInPricing;
|
|
@@ -11,11 +14,25 @@ exports.pushCertainlyFailed = pushCertainlyFailed;
|
|
|
11
14
|
// Derived from apify-sdk-js (src/charging.ts),
|
|
12
15
|
// Copyright Apify Technologies s.r.o., licensed under the Apache License 2.0.
|
|
13
16
|
// Modified by TeraShift GmbH. See LICENSE-APACHE and NOTICE.
|
|
17
|
+
const node_async_hooks_1 = require("node:async_hooks");
|
|
14
18
|
const node_crypto_1 = require("node:crypto");
|
|
19
|
+
const api_storage_client_js_1 = require("./storage/api-storage-client.js");
|
|
15
20
|
/** Client safe-replay window; server TTL is longer (35d). */
|
|
16
21
|
exports.CHARGE_IDEMPOTENCY_SAFE_AGE_MS = 30 * 24 * 60 * 60 * 1000;
|
|
17
22
|
const PENDING_MAX_ENTRIES = 1024;
|
|
18
|
-
|
|
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
|
+
}
|
|
19
36
|
class ChargeUncertainError extends Error {
|
|
20
37
|
operationId;
|
|
21
38
|
eventName;
|
|
@@ -48,6 +65,44 @@ class ChargeRejectedError extends Error {
|
|
|
48
65
|
}
|
|
49
66
|
}
|
|
50
67
|
exports.ChargeRejectedError = ChargeRejectedError;
|
|
68
|
+
/** True when a ChargeRejectedError means the run's spending limit is used up. */
|
|
69
|
+
function isChargeLimitRejection(err) {
|
|
70
|
+
return (err.statusCode === 402 ||
|
|
71
|
+
err.reason === 'charge-limit-exceeded' ||
|
|
72
|
+
/charge-limit-exceeded/i.test(err.message));
|
|
73
|
+
}
|
|
74
|
+
/*
|
|
75
|
+
* Charge-limit safety net for the default dataset.
|
|
76
|
+
*
|
|
77
|
+
* Once a pay-per-event run has used up its spending limit (maxTotalChargeUsd),
|
|
78
|
+
* rows written to the run's default dataset would go out unbilled. The SDK then
|
|
79
|
+
* refuses such writes: nothing is stored, one warning is logged and
|
|
80
|
+
* `ChargingManager.isChargeLimitReached()` turns true. Pushes made by
|
|
81
|
+
* `pushDataAndCharge` are decided there and run inside `chargeGatedPush`, so
|
|
82
|
+
* the storage client does not check them twice.
|
|
83
|
+
*/
|
|
84
|
+
const chargeGatedPush = new node_async_hooks_1.AsyncLocalStorage();
|
|
85
|
+
/** The charging manager of the running Actor; storage clients consult it. */
|
|
86
|
+
let activeChargingManager;
|
|
87
|
+
/** @internal Register the manager whose limit guards default-dataset writes. */
|
|
88
|
+
function setActiveChargingManager(manager) {
|
|
89
|
+
activeChargingManager = manager;
|
|
90
|
+
}
|
|
91
|
+
/** @internal The run's default dataset ID, as the platform passes it. */
|
|
92
|
+
function defaultDatasetIdFromEnv() {
|
|
93
|
+
return process.env.ACTOR_DEFAULT_DATASET_ID || process.env.APIFY_DEFAULT_DATASET_ID || undefined;
|
|
94
|
+
}
|
|
95
|
+
/**
|
|
96
|
+
* @internal True when a write of `itemCount` rows to the default dataset must
|
|
97
|
+
* be dropped (pay-per-event run past its spending limit). Called by storage
|
|
98
|
+
* clients for writes that did not go through `pushDataAndCharge`
|
|
99
|
+
* (`Actor.openDataset().pushData()`, crawler `pushData`, ...).
|
|
100
|
+
*/
|
|
101
|
+
function refuseDefaultDatasetPush(itemCount) {
|
|
102
|
+
if (chargeGatedPush.getStore())
|
|
103
|
+
return false;
|
|
104
|
+
return activeChargingManager?._refuseDefaultDatasetPush(itemCount) ?? false;
|
|
105
|
+
}
|
|
51
106
|
exports.APIFY_DEFAULT_DATASET_ITEM_EVENT = 'apify-default-dataset-item';
|
|
52
107
|
/** Scrapely-native synthetic event for default-dataset items. */
|
|
53
108
|
exports.SCRAPELY_DEFAULT_DATASET_ITEM_EVENT = 'scrapely-default-dataset-item';
|
|
@@ -95,9 +150,77 @@ class ChargingManager {
|
|
|
95
150
|
apifyClient;
|
|
96
151
|
/** Set during actor shutdown; rejects new charge operations after the final flush. */
|
|
97
152
|
_closed = false;
|
|
153
|
+
/** Sticky: the platform refused or capped a charge (HTTP 402 / fewer accepted) or the local gate trimmed one. */
|
|
154
|
+
chargeLimitHit = false;
|
|
155
|
+
/** Default-dataset rows refused after the limit (one warning, then a count). */
|
|
156
|
+
refusedDefaultDatasetItems = 0;
|
|
98
157
|
/** @internal Called by Actor shutdown before the final flush. */
|
|
99
158
|
close() {
|
|
100
159
|
this._closed = true;
|
|
160
|
+
if (this.refusedDefaultDatasetItems > 0) {
|
|
161
|
+
console.warn(`[Scrapely] ${this.refusedDefaultDatasetItems} result(s) pushed to the default dataset after the spending limit was reached were not saved`);
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
/**
|
|
165
|
+
* Whether this pay-per-event run has used up its spending limit.
|
|
166
|
+
*
|
|
167
|
+
* True once a charge was refused or capped by the limit (the platform's
|
|
168
|
+
* HTTP 402 or a partial acceptance, or the local budget gate), or once the
|
|
169
|
+
* remaining budget cannot pay for one event of the cheapest priced type.
|
|
170
|
+
* From then on, pushes to the default dataset that are not paid for are
|
|
171
|
+
* refused. Always false for actors without pay-per-event pricing, without
|
|
172
|
+
* a limit, or with only free events.
|
|
173
|
+
*/
|
|
174
|
+
isChargeLimitReached() {
|
|
175
|
+
if (this.chargingState === undefined || !this.isPayPerEvent)
|
|
176
|
+
return false;
|
|
177
|
+
if (this.chargeLimitHit)
|
|
178
|
+
return true;
|
|
179
|
+
if (!Number.isFinite(this.maxTotalChargeUsd))
|
|
180
|
+
return false;
|
|
181
|
+
const prices = Object.values(this.pricingInfo)
|
|
182
|
+
.map(({ price }) => price)
|
|
183
|
+
.filter((price) => price > 0);
|
|
184
|
+
if (prices.length === 0)
|
|
185
|
+
return false;
|
|
186
|
+
// Same rounding as the budget gate, so 0.03 - 0.02 still pays a $0.01 event.
|
|
187
|
+
return this.calculateMaxChargesByPrice(Math.min(...prices)) <= 0;
|
|
188
|
+
}
|
|
189
|
+
markChargeLimitHit() {
|
|
190
|
+
if (this.isPayPerEvent)
|
|
191
|
+
this.chargeLimitHit = true;
|
|
192
|
+
}
|
|
193
|
+
/** @internal Count rows dropped because of the limit; warn on the first one only. */
|
|
194
|
+
_noteRefusedDefaultDatasetItems(count) {
|
|
195
|
+
if (count <= 0)
|
|
196
|
+
return;
|
|
197
|
+
if (this.refusedDefaultDatasetItems === 0) {
|
|
198
|
+
const limit = Number.isFinite(this.maxTotalChargeUsd) ? `$${this.maxTotalChargeUsd}` : 'the limit';
|
|
199
|
+
console.warn(`[Scrapely] The spending limit of this run (maxTotalChargeUsd = ${limit}) has been reached: ` +
|
|
200
|
+
'results pushed to the default dataset are no longer saved. The Actor should stop now ' +
|
|
201
|
+
'(check ChargeResult.eventChargeLimitReached or Actor.getChargingManager().isChargeLimitReached()).');
|
|
202
|
+
}
|
|
203
|
+
this.refusedDefaultDatasetItems += count;
|
|
204
|
+
}
|
|
205
|
+
/** @internal */
|
|
206
|
+
_markChargeLimitHit() {
|
|
207
|
+
this.markChargeLimitHit();
|
|
208
|
+
}
|
|
209
|
+
/** @internal Whether every row of a push with these events is charged a positive price. */
|
|
210
|
+
_isPaidPerItem(eventName, isDefaultDataset) {
|
|
211
|
+
const names = [];
|
|
212
|
+
if (eventName !== undefined)
|
|
213
|
+
names.push(eventName);
|
|
214
|
+
if (isDefaultDataset)
|
|
215
|
+
names.push(resolveDefaultDatasetItemEventName(this.pricingInfo));
|
|
216
|
+
return names.some((name) => (this.calculateEventPrice(name) ?? 0) > 0);
|
|
217
|
+
}
|
|
218
|
+
/** @internal */
|
|
219
|
+
_refuseDefaultDatasetPush(itemCount) {
|
|
220
|
+
if (itemCount <= 0 || !this.isChargeLimitReached())
|
|
221
|
+
return false;
|
|
222
|
+
this._noteRefusedDefaultDatasetItems(itemCount);
|
|
223
|
+
return true;
|
|
101
224
|
}
|
|
102
225
|
/** @internal Whether shutdown has closed this manager to new charges. */
|
|
103
226
|
isClosed() {
|
|
@@ -377,16 +500,76 @@ class ChargingManager {
|
|
|
377
500
|
}
|
|
378
501
|
}
|
|
379
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
|
+
}
|
|
380
560
|
/**
|
|
381
561
|
* POST a charge intent. Returns acceptedCount on success.
|
|
382
562
|
* Throws ChargeRejectedError for definitive platform rejections (no retry).
|
|
383
|
-
* 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.
|
|
384
565
|
*/
|
|
385
|
-
async postChargeWithRetries(intent) {
|
|
566
|
+
async postChargeWithRetries(intent, deadline) {
|
|
386
567
|
let lastError;
|
|
568
|
+
const started = Date.now();
|
|
387
569
|
for (let attempt = 0; attempt < this.postAttempts; attempt++) {
|
|
570
|
+
const { run, meta } = this.runClientOnce(ChargingManager.attemptTimeout(deadline));
|
|
388
571
|
try {
|
|
389
|
-
const response = await
|
|
572
|
+
const response = await run.charge({
|
|
390
573
|
eventName: intent.eventName,
|
|
391
574
|
count: intent.count,
|
|
392
575
|
idempotencyKey: intent.idempotencyKey,
|
|
@@ -401,9 +584,8 @@ class ChargingManager {
|
|
|
401
584
|
if (rejected) {
|
|
402
585
|
throw rejected;
|
|
403
586
|
}
|
|
404
|
-
if (attempt
|
|
405
|
-
|
|
406
|
-
}
|
|
587
|
+
if (!(await this.backoffBeforeRetry(attempt, started, err, deadline, meta.retryAfter)))
|
|
588
|
+
break;
|
|
407
589
|
}
|
|
408
590
|
}
|
|
409
591
|
console.warn(`[Scrapely] Failed to record charge for event ${intent.eventName} ` +
|
|
@@ -432,11 +614,13 @@ class ChargingManager {
|
|
|
432
614
|
this.pendingCharges.delete(operationId);
|
|
433
615
|
return this._voidCharge(eventName, operationId, reservedCount, true);
|
|
434
616
|
}
|
|
435
|
-
async _voidCharge(eventName, operationId, reservedCount, notFoundMeansVoided = false) {
|
|
617
|
+
async _voidCharge(eventName, operationId, reservedCount, notFoundMeansVoided = false, deadline) {
|
|
436
618
|
if (!this.isAtHome || !this.actorRunId)
|
|
437
619
|
return false;
|
|
438
|
-
const
|
|
620
|
+
const started = Date.now();
|
|
439
621
|
for (let attempt = 0; attempt < this.postAttempts; attempt++) {
|
|
622
|
+
const { run, meta } = this.runClientOnce(ChargingManager.attemptTimeout(deadline));
|
|
623
|
+
const runClient = run;
|
|
440
624
|
try {
|
|
441
625
|
await runClient.httpClient.call({
|
|
442
626
|
url: runClient._url('charge/void'),
|
|
@@ -458,10 +642,11 @@ class ChargingManager {
|
|
|
458
642
|
}
|
|
459
643
|
// 404: nothing to void; 409: already settled; 4xx auth: permanent.
|
|
460
644
|
// The charge stands; keep the local reservation that matches it.
|
|
461
|
-
|
|
645
|
+
// 408/429 (rate limited) and 5xx are not answers: re-queue below.
|
|
646
|
+
if (status !== undefined && status >= 400 && status < 500 && !isTransientStatus(status))
|
|
462
647
|
return false;
|
|
463
|
-
if (attempt
|
|
464
|
-
|
|
648
|
+
if (!(await this.backoffBeforeRetry(attempt, started, err, deadline, meta.retryAfter)))
|
|
649
|
+
break;
|
|
465
650
|
}
|
|
466
651
|
}
|
|
467
652
|
// Outcome unknown: keep the reservation and retry before the next push / at exit.
|
|
@@ -473,13 +658,21 @@ class ChargingManager {
|
|
|
473
658
|
* @internal Retry unconfirmed voids (caller holds the operation lock).
|
|
474
659
|
* Returns false while any earlier void is still unconfirmed.
|
|
475
660
|
*/
|
|
476
|
-
async _retryPendingVoids() {
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
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));
|
|
481
675
|
}
|
|
482
|
-
return this.pendingVoids.length === 0;
|
|
483
676
|
}
|
|
484
677
|
/** Clamp local reservation down when the server accepted fewer events. */
|
|
485
678
|
/** @internal Used by pushDataAndCharge dual-event reconciliation. */
|
|
@@ -514,11 +707,13 @@ class ChargingManager {
|
|
|
514
707
|
async _flushPendingExclusive(maxIntents = 32, deadlineMs) {
|
|
515
708
|
if (!this.actorRunId)
|
|
516
709
|
return 0;
|
|
517
|
-
|
|
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);
|
|
518
714
|
if (this.flushInFlight) {
|
|
519
715
|
return this.flushInFlight;
|
|
520
716
|
}
|
|
521
|
-
const deadline = deadlineMs !== undefined ? Date.now() + Math.max(0, deadlineMs) : undefined;
|
|
522
717
|
this.flushInFlight = (async () => {
|
|
523
718
|
let confirmedTotal = 0;
|
|
524
719
|
// Drain loop: keep flushing batches until empty or deadline.
|
|
@@ -536,8 +731,10 @@ class ChargingManager {
|
|
|
536
731
|
if (deadline !== undefined && Date.now() >= deadline)
|
|
537
732
|
break;
|
|
538
733
|
try {
|
|
539
|
-
const accepted = await this.postChargeWithRetries(intent);
|
|
734
|
+
const accepted = await this.postChargeWithRetries(intent, deadline);
|
|
540
735
|
if (accepted !== null) {
|
|
736
|
+
if (accepted < intent.count)
|
|
737
|
+
this.markChargeLimitHit();
|
|
541
738
|
const eventPrice = this.getEventPricing(intent.eventName).price;
|
|
542
739
|
this.applyAcceptedCount(intent.eventName, intent.count, accepted, eventPrice);
|
|
543
740
|
this.pendingCharges.delete(intent.idempotencyKey);
|
|
@@ -546,6 +743,8 @@ class ChargingManager {
|
|
|
546
743
|
}
|
|
547
744
|
catch (err) {
|
|
548
745
|
if (err instanceof ChargeRejectedError) {
|
|
746
|
+
if (isChargeLimitRejection(err))
|
|
747
|
+
this.markChargeLimitHit();
|
|
549
748
|
const eventPrice = this.getEventPricing(intent.eventName).price;
|
|
550
749
|
this.applyAcceptedCount(intent.eventName, intent.count, 0, eventPrice);
|
|
551
750
|
this.pendingCharges.delete(intent.idempotencyKey);
|
|
@@ -613,6 +812,8 @@ class ChargingManager {
|
|
|
613
812
|
}
|
|
614
813
|
return 0;
|
|
615
814
|
})();
|
|
815
|
+
if (chargedCount < count)
|
|
816
|
+
this.markChargeLimitHit();
|
|
616
817
|
if (chargedCount === 0) {
|
|
617
818
|
return {
|
|
618
819
|
eventChargeLimitReached: count > 0,
|
|
@@ -655,6 +856,8 @@ class ChargingManager {
|
|
|
655
856
|
}
|
|
656
857
|
catch (err) {
|
|
657
858
|
if (err instanceof ChargeRejectedError) {
|
|
859
|
+
if (isChargeLimitRejection(err))
|
|
860
|
+
this.markChargeLimitHit();
|
|
658
861
|
// Server definitively rejected: unwind local reservation.
|
|
659
862
|
this.pendingCharges.delete(intent.idempotencyKey);
|
|
660
863
|
chargedCount = this.applyAcceptedCount(normalizedEventName, chargedCount, 0, eventPricing.price);
|
|
@@ -670,6 +873,9 @@ class ChargingManager {
|
|
|
670
873
|
this.pendingCharges.delete(intent.idempotencyKey);
|
|
671
874
|
if (accepted > 0)
|
|
672
875
|
this.lastAcceptedOperationId = intent.idempotencyKey;
|
|
876
|
+
// The platform capped the charge at the run's limit.
|
|
877
|
+
if (accepted < chargedCount)
|
|
878
|
+
this.markChargeLimitHit();
|
|
673
879
|
chargedCount = this.applyAcceptedCount(normalizedEventName, chargedCount, accepted, eventPricing.price);
|
|
674
880
|
}
|
|
675
881
|
else {
|
|
@@ -772,6 +978,22 @@ async function pushDataAndCharge({ chargingManager, items, eventName, isDefaultD
|
|
|
772
978
|
// A void that did not go through for an earlier failed push must land
|
|
773
979
|
// before this push charges again, or one unsaved result is billed twice.
|
|
774
980
|
const voidsConfirmed = (await chargingManager._retryPendingVoids?.()) !== false;
|
|
981
|
+
const requested = Array.isArray(items) ? items.length : 1;
|
|
982
|
+
// Charge-limit safety net: once the run's spending limit is used up, a
|
|
983
|
+
// default-dataset push that does not pay per row (no event, or only free
|
|
984
|
+
// events) stores nothing. Paid pushes go on below, where the budget cap
|
|
985
|
+
// stores only the rows that are actually charged.
|
|
986
|
+
if (isDefaultDataset &&
|
|
987
|
+
requested > 0 &&
|
|
988
|
+
chargingManager.isChargeLimitReached?.() &&
|
|
989
|
+
!chargingManager._isPaidPerItem(eventName, isDefaultDataset)) {
|
|
990
|
+
chargingManager._noteRefusedDefaultDatasetItems(requested);
|
|
991
|
+
return {
|
|
992
|
+
eventChargeLimitReached: true,
|
|
993
|
+
chargedCount: 0,
|
|
994
|
+
chargeableWithinLimit: chargingManager._chargeableWithinLimit?.() ?? {},
|
|
995
|
+
};
|
|
996
|
+
}
|
|
775
997
|
const { limitedItems, eventsToCharge } = chargingManager.calculatePushDataLimits({
|
|
776
998
|
items,
|
|
777
999
|
eventName,
|
|
@@ -787,6 +1009,11 @@ async function pushDataAndCharge({ chargingManager, items, eventName, isDefaultD
|
|
|
787
1009
|
}
|
|
788
1010
|
const itemsArray = Array.isArray(items) ? items : [items];
|
|
789
1011
|
const allItemsTrimmed = itemsArray.length > 0 && limitedItems.length === 0;
|
|
1012
|
+
if (isDefaultDataset && limitedItems.length < itemsArray.length) {
|
|
1013
|
+
// Rows beyond what the budget can pay for are dropped.
|
|
1014
|
+
chargingManager._markChargeLimitHit?.();
|
|
1015
|
+
chargingManager._noteRefusedDefaultDatasetItems?.(itemsArray.length - limitedItems.length);
|
|
1016
|
+
}
|
|
790
1017
|
if (limitedItems.length === 0) {
|
|
791
1018
|
return {
|
|
792
1019
|
eventChargeLimitReached: allItemsTrimmed,
|
|
@@ -875,6 +1102,9 @@ async function pushDataAndCharge({ chargingManager, items, eventName, isDefaultD
|
|
|
875
1102
|
// Report rows charged, as Apify does for the pushed event: summing the
|
|
876
1103
|
// explicit and default-dataset events would count every row twice.
|
|
877
1104
|
chargeResult = { ...results.reduce(mergeChargeResults), chargedCount: acceptedForItems };
|
|
1105
|
+
if (isDefaultDataset && acceptedForItems < limitedItems.length) {
|
|
1106
|
+
chargingManager._noteRefusedDefaultDatasetItems?.(limitedItems.length - acceptedForItems);
|
|
1107
|
+
}
|
|
878
1108
|
if (acceptedForItems <= 0) {
|
|
879
1109
|
// Nothing will be stored (another event of this push was capped to
|
|
880
1110
|
// zero): undo every platform charge this operation made, so no
|
|
@@ -898,7 +1128,7 @@ async function pushDataAndCharge({ chargingManager, items, eventName, isDefaultD
|
|
|
898
1128
|
}
|
|
899
1129
|
const toPersist = limitedItems.slice(0, acceptedForItems);
|
|
900
1130
|
try {
|
|
901
|
-
await pushFn(Array.isArray(items) ? toPersist : toPersist[0]);
|
|
1131
|
+
await chargeGatedPush.run(true, () => pushFn(Array.isArray(items) ? toPersist : toPersist[0]));
|
|
902
1132
|
}
|
|
903
1133
|
catch (pushErr) {
|
|
904
1134
|
// Storage failed after the charge was accepted: a result is billed
|
|
@@ -934,7 +1164,7 @@ async function pushDataAndCharge({ chargingManager, items, eventName, isDefaultD
|
|
|
934
1164
|
return chargeResult;
|
|
935
1165
|
}
|
|
936
1166
|
// No billable events — still persist the budget-limited items.
|
|
937
|
-
await pushFn(Array.isArray(items) ? limitedItems : limitedItems[0]);
|
|
1167
|
+
await chargeGatedPush.run(true, () => pushFn(Array.isArray(items) ? limitedItems : limitedItems[0]));
|
|
938
1168
|
return {
|
|
939
1169
|
eventChargeLimitReached: false,
|
|
940
1170
|
chargedCount: 0,
|
package/dist/charging_client.js
CHANGED
|
@@ -17,7 +17,11 @@ function createChargingHooks(actor) {
|
|
|
17
17
|
// Called by the client at `dataset()` time, so pricing is consulted
|
|
18
18
|
// lazily — same timing as the former subclass override.
|
|
19
19
|
shouldChargeDatasetPushes() {
|
|
20
|
-
|
|
20
|
+
const chargingManager = actor.getChargingManager();
|
|
21
|
+
// Past the spending limit, route default-dataset pushes through
|
|
22
|
+
// pushDataAndCharge too, which refuses rows nobody pays for.
|
|
23
|
+
return ((0, charging_js_1.hasDefaultDatasetItemEventInPricing)(chargingManager.getPricingInfo().perEventPrices) ||
|
|
24
|
+
(chargingManager.isChargeLimitReached?.() ?? false));
|
|
21
25
|
},
|
|
22
26
|
async pushAndCharge({ items, eventName, pushFn }) {
|
|
23
27
|
return (0, charging_js_1.pushDataAndCharge)({
|
|
@@ -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,10 +1,13 @@
|
|
|
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");
|
|
10
|
+
const charging_js_1 = require("../charging.js");
|
|
8
11
|
const kvs_iteration_js_1 = require("./kvs-iteration.js");
|
|
9
12
|
function buildRqQueryParams(baseClientKey, options) {
|
|
10
13
|
const params = new URLSearchParams();
|
|
@@ -93,32 +96,6 @@ class AdaptiveRateLimiter {
|
|
|
93
96
|
}
|
|
94
97
|
this.lastRequestTime = Date.now();
|
|
95
98
|
}
|
|
96
|
-
/**
|
|
97
|
-
* Handle a 429 rate limit response.
|
|
98
|
-
* Parses Retry-After header and waits the appropriate time.
|
|
99
|
-
*/
|
|
100
|
-
async handleRateLimited(response) {
|
|
101
|
-
const retryAfter = response.headers.get('Retry-After');
|
|
102
|
-
let waitMs = 1000; // Default 1 second
|
|
103
|
-
if (retryAfter) {
|
|
104
|
-
// Retry-After can be seconds or a date
|
|
105
|
-
const seconds = parseInt(retryAfter, 10);
|
|
106
|
-
if (!isNaN(seconds)) {
|
|
107
|
-
waitMs = seconds * 1000;
|
|
108
|
-
}
|
|
109
|
-
else {
|
|
110
|
-
// It's a date string
|
|
111
|
-
const retryDate = new Date(retryAfter).getTime();
|
|
112
|
-
if (!isNaN(retryDate)) {
|
|
113
|
-
waitMs = Math.max(0, retryDate - Date.now());
|
|
114
|
-
}
|
|
115
|
-
}
|
|
116
|
-
}
|
|
117
|
-
// Add jitter to prevent thundering herd
|
|
118
|
-
waitMs = waitMs + Math.random() * 100;
|
|
119
|
-
console.warn(`[RateLimiter] Rate limited, waiting ${Math.round(waitMs)}ms before retry`);
|
|
120
|
-
await new Promise(resolve => setTimeout(resolve, waitMs));
|
|
121
|
-
}
|
|
122
99
|
}
|
|
123
100
|
/**
|
|
124
101
|
* Unwrap Apify-style v2 response envelopes ({ data: ... }).
|
|
@@ -165,32 +142,62 @@ const IDEMPOTENT_METHODS = new Set(['GET', 'HEAD', 'DELETE', 'OPTIONS']);
|
|
|
165
142
|
const NO_BACKEND_MARKER = 'No server is available to handle this request';
|
|
166
143
|
const CONNECT_FAILURE_CODES = new Set(['ECONNREFUSED', 'ENOTFOUND', 'EAI_AGAIN', 'EHOSTUNREACH', 'ENETUNREACH', 'UND_ERR_CONNECT_TIMEOUT']);
|
|
167
144
|
/**
|
|
168
|
-
* Whether a non-2xx answer is retried.
|
|
169
|
-
*
|
|
170
|
-
*
|
|
171
|
-
*
|
|
172
|
-
*
|
|
173
|
-
*
|
|
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.
|
|
174
153
|
*/
|
|
175
154
|
function isRetryableResponse(status, idempotent, errorType, bodyText) {
|
|
155
|
+
if (status === 429)
|
|
156
|
+
return true;
|
|
176
157
|
if (idempotent)
|
|
177
158
|
return status === 500 || status === 502 || status === 503 || status === 504;
|
|
178
159
|
if (status !== 503)
|
|
179
160
|
return false;
|
|
180
161
|
return errorType === 'rate-limit-unavailable' || (bodyText ?? '').includes(NO_BACKEND_MARKER);
|
|
181
162
|
}
|
|
182
|
-
/**
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
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);
|
|
194
201
|
}
|
|
195
202
|
function fetchErrorCode(err) {
|
|
196
203
|
const e = err;
|
|
@@ -205,6 +212,109 @@ function describeFetchError(err) {
|
|
|
205
212
|
return fetchErrorCode(err) ?? err?.message ?? String(err);
|
|
206
213
|
}
|
|
207
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
|
+
}
|
|
208
318
|
/** Map GET storage errors: rethrow 403, return undefined for 404 (triggers getOrCreate for names only). */
|
|
209
319
|
function handleStorageGetError(err) {
|
|
210
320
|
if (isApiStorageClientError(err)) {
|
|
@@ -265,15 +375,10 @@ function makeAsyncIterableResult(promiseFn) {
|
|
|
265
375
|
class BaseApiClient {
|
|
266
376
|
config;
|
|
267
377
|
headers;
|
|
268
|
-
//
|
|
269
|
-
//
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
static maxRetries = 3;
|
|
273
|
-
// Retries while the API is briefly unavailable (503/5xx, connection
|
|
274
|
-
// refused): backoff 0.5 s doubling to 8 s, about 45 s in total, which
|
|
275
|
-
// covers a main redeploy.
|
|
276
|
-
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;
|
|
277
382
|
// Default timeout for requests (30 seconds)
|
|
278
383
|
static defaultTimeoutMs = 30000;
|
|
279
384
|
constructor(config) {
|
|
@@ -286,12 +391,9 @@ class BaseApiClient {
|
|
|
286
391
|
}
|
|
287
392
|
/**
|
|
288
393
|
* Sends the request and returns the successful (2xx) response, with the
|
|
289
|
-
* retry policy
|
|
290
|
-
* unavailable (503, 5xx for idempotent methods, connection failures).
|
|
291
|
-
* Non-2xx answers that are not retried throw ApiStorageClientError.
|
|
394
|
+
* retry policy of {@link fetchWithRetries}.
|
|
292
395
|
*/
|
|
293
396
|
async send(method, path, body, contentType, options, decode) {
|
|
294
|
-
const url = `${this.config.apiUrl}/v2${path}`;
|
|
295
397
|
const headers = { ...this.headers };
|
|
296
398
|
if (contentType) {
|
|
297
399
|
headers['Content-Type'] = contentType;
|
|
@@ -299,114 +401,19 @@ class BaseApiClient {
|
|
|
299
401
|
const timeoutMs = options?.timeoutSecs
|
|
300
402
|
? options.timeoutSecs * 1000
|
|
301
403
|
: BaseApiClient.defaultTimeoutMs;
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
headers,
|
|
316
|
-
// Binary bodies (KV records) go out as the raw bytes, never JSON/base64.
|
|
317
|
-
body: body === undefined ? undefined
|
|
318
|
-
: typeof body === 'string' || body instanceof Uint8Array ? body
|
|
319
|
-
: JSON.stringify(body),
|
|
320
|
-
signal: controller.signal,
|
|
321
|
-
});
|
|
322
|
-
// Update rate limiter state from response headers
|
|
323
|
-
BaseApiClient.rateLimiter.updateFromHeaders(response.headers);
|
|
324
|
-
// Handle rate limiting (429)
|
|
325
|
-
if (response.status === 429) {
|
|
326
|
-
if (attempt < BaseApiClient.maxRetries) {
|
|
327
|
-
attempt++;
|
|
328
|
-
await BaseApiClient.rateLimiter.handleRateLimited(response);
|
|
329
|
-
continue; // Retry
|
|
330
|
-
}
|
|
331
|
-
const error = await response.json().catch(() => ({ error: { message: 'Rate limit exceeded' } }));
|
|
332
|
-
throw new ApiStorageClientError(error.error?.message || 'Rate limit exceeded', 429);
|
|
333
|
-
}
|
|
334
|
-
if (response.ok) {
|
|
335
|
-
try {
|
|
336
|
-
// Decoded inside the retry loop: a body cut off after
|
|
337
|
-
// the headers is a transport failure like any other.
|
|
338
|
-
return await decode(response);
|
|
339
|
-
}
|
|
340
|
-
catch (bodyErr) {
|
|
341
|
-
// The request was applied (2xx); a write whose answer
|
|
342
|
-
// timed out while being read must not be sent again.
|
|
343
|
-
if (bodyErr.name === 'AbortError' && !idempotent) {
|
|
344
|
-
const timeoutError = new Error(`Request timed out after ${timeoutMs}ms (response body; the request was applied)`);
|
|
345
|
-
timeoutError.isTimeout = true;
|
|
346
|
-
throw timeoutError;
|
|
347
|
-
}
|
|
348
|
-
if (idempotent && bodyErr.name !== 'AbortError' && serverAttempt < BaseApiClient.maxServerRetries) {
|
|
349
|
-
const waitMs = serverRetryDelayMs(serverAttempt, null);
|
|
350
|
-
serverAttempt++;
|
|
351
|
-
console.warn(`[Scrapely] ${method} ${path} response body failed (${describeFetchError(bodyErr)}); retry ${serverAttempt}/${BaseApiClient.maxServerRetries} in ${Math.round(waitMs)}ms`);
|
|
352
|
-
await sleep(waitMs);
|
|
353
|
-
continue;
|
|
354
|
-
}
|
|
355
|
-
throw bodyErr;
|
|
356
|
-
}
|
|
357
|
-
}
|
|
358
|
-
// API briefly unavailable (main redeploy, limiter store blip).
|
|
359
|
-
const text = await response.text().catch(() => '');
|
|
360
|
-
let errorBody;
|
|
361
|
-
try {
|
|
362
|
-
errorBody = JSON.parse(text);
|
|
363
|
-
}
|
|
364
|
-
catch {
|
|
365
|
-
errorBody = undefined;
|
|
366
|
-
}
|
|
367
|
-
if (isRetryableResponse(response.status, idempotent, errorBody?.error?.type, text)
|
|
368
|
-
&& serverAttempt < BaseApiClient.maxServerRetries) {
|
|
369
|
-
const waitMs = serverRetryDelayMs(serverAttempt, response.headers.get('retry-after'));
|
|
370
|
-
serverAttempt++;
|
|
371
|
-
console.warn(`[Scrapely] ${method} ${path} answered ${response.status}; retry ${serverAttempt}/${BaseApiClient.maxServerRetries} in ${Math.round(waitMs)}ms`);
|
|
372
|
-
await sleep(waitMs);
|
|
373
|
-
continue;
|
|
374
|
-
}
|
|
375
|
-
throw new ApiStorageClientError(errorBody?.error?.message || response.statusText || `API request failed: ${response.status}`, response.status);
|
|
376
|
-
}
|
|
377
|
-
catch (err) {
|
|
378
|
-
// Handle timeout/abort errors
|
|
379
|
-
if (err.name === 'AbortError') {
|
|
380
|
-
const timeoutError = new Error(`Request timed out after ${timeoutMs}ms`);
|
|
381
|
-
timeoutError.isTimeout = true;
|
|
382
|
-
// Don't retry if doNotRetryTimeouts is true
|
|
383
|
-
if (!shouldRetryTimeouts || attempt >= BaseApiClient.maxRetries) {
|
|
384
|
-
throw timeoutError;
|
|
385
|
-
}
|
|
386
|
-
attempt++;
|
|
387
|
-
// Wait a bit before retrying
|
|
388
|
-
await sleep(100 * attempt);
|
|
389
|
-
continue;
|
|
390
|
-
}
|
|
391
|
-
// HTTP errors (already decided above) are final.
|
|
392
|
-
if (isApiStorageClientError(err))
|
|
393
|
-
throw err;
|
|
394
|
-
// fetch() failed without a response. A refused/unresolvable
|
|
395
|
-
// connection never reached the API, so it is safe for every
|
|
396
|
-
// method; anything else (reset mid-request) only for idempotent ones.
|
|
397
|
-
if ((idempotent || isConnectFailure(err)) && serverAttempt < BaseApiClient.maxServerRetries) {
|
|
398
|
-
const waitMs = serverRetryDelayMs(serverAttempt, null);
|
|
399
|
-
serverAttempt++;
|
|
400
|
-
console.warn(`[Scrapely] ${method} ${path} failed (${describeFetchError(err)}); retry ${serverAttempt}/${BaseApiClient.maxServerRetries} in ${Math.round(waitMs)}ms`);
|
|
401
|
-
await sleep(waitMs);
|
|
402
|
-
continue;
|
|
403
|
-
}
|
|
404
|
-
throw err;
|
|
405
|
-
}
|
|
406
|
-
finally {
|
|
407
|
-
clearTimeout(timeoutId);
|
|
408
|
-
}
|
|
409
|
-
}
|
|
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);
|
|
410
417
|
}
|
|
411
418
|
async request(method, path, body, contentType, options) {
|
|
412
419
|
return this.send(method, path, body, contentType, options, async (response) => {
|
|
@@ -559,6 +566,10 @@ class ApiDatasetClient extends BaseApiClient {
|
|
|
559
566
|
else {
|
|
560
567
|
parsed = [items];
|
|
561
568
|
}
|
|
569
|
+
// Charge-limit safety net: a pay-per-event run past its spending limit
|
|
570
|
+
// must not store unbilled rows in its default dataset.
|
|
571
|
+
if (this.id === (0, charging_js_1.defaultDatasetIdFromEnv)() && (0, charging_js_1.refuseDefaultDatasetPush)(parsed.length))
|
|
572
|
+
return;
|
|
562
573
|
await this.request('POST', `/datasets/${this.id}/items`, parsed);
|
|
563
574
|
}
|
|
564
575
|
}
|
|
@@ -723,7 +734,10 @@ class ApiKeyValueStoreClient extends BaseApiClient {
|
|
|
723
734
|
* @returns A public URL for accessing the record
|
|
724
735
|
*/
|
|
725
736
|
getPublicUrl(key) {
|
|
726
|
-
|
|
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}`;
|
|
727
741
|
}
|
|
728
742
|
}
|
|
729
743
|
exports.ApiKeyValueStoreClient = ApiKeyValueStoreClient;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "scrapely",
|
|
3
|
-
"version": "1.2.
|
|
3
|
+
"version": "1.2.17",
|
|
4
4
|
"description": "Scrapely SDK for building JavaScript and TypeScript Actors",
|
|
5
5
|
"main": "dist/index.js",
|
|
6
6
|
"types": "dist/index.d.ts",
|
|
@@ -22,6 +22,7 @@
|
|
|
22
22
|
"scripts": {
|
|
23
23
|
"test": "npx tsx tests/test-mvp.ts",
|
|
24
24
|
"test:charging": "npx tsx --test tests/charging-pending.test.ts",
|
|
25
|
+
"test:charge-limit-guard": "npx tsx --test tests/charge-limit-guard.test.ts",
|
|
25
26
|
"test:storage-retries": "npx tsx --test tests/storage-retries.test.ts",
|
|
26
27
|
"test:stdio-drain": "npx tsx --test tests/stdio-drain.test.ts",
|
|
27
28
|
"test:charging-client": "npx tsx --test tests/charging-client.test.ts",
|