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 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
- cm.flushPending(64, flushDeadlineMs),
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
- const response = await fetch(`${apiUrl}/v2/actor-runs/${runId}`, {
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
- signal: AbortSignal.timeout(10_000),
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
- const response = await fetch(`${apiUrl}/v2/actor-runs/${runId}/metamorph`, {
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
- const response = await fetch(`${apiUrl}/v2/actor-runs/${runId}/reboot`, {
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
- if (!response.ok) {
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));
@@ -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
- const POST_ATTEMPTS = 3;
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 this.apifyClient.run(this.actorRunId).charge({
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 + 1 < this.postAttempts) {
405
- await new Promise((r) => setTimeout(r, 250 * (attempt + 1)));
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 runClient = this.apifyClient.run(this.actorRunId);
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
- if (status !== undefined && status >= 400 && status < 500)
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 + 1 < this.postAttempts)
464
- await new Promise((r) => setTimeout(r, 250 * (attempt + 1)));
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
- const pending = this.pendingVoids;
478
- this.pendingVoids = [];
479
- for (const v of pending) {
480
- await this._voidCharge(v.eventName, v.operationId, v.reservedCount, v.notFoundMeansVoided);
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
- await this._retryPendingVoids();
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,
@@ -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
- return (0, charging_js_1.hasDefaultDatasetItemEventInPricing)(actor.getChargingManager().getPricingInfo().perEventPrices);
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. Idempotent methods: 500/502/503/504.
17
- * Others (dataset push, KV PUT) only on a 503 that certainly was not
18
- * processed: the rate limiter refused it ("rate-limit-unavailable", before the
19
- * handler), or HAProxy had no backend (its own page, nothing forwarded). Any
20
- * other 503 may mean "outcome unknown" (e.g. a direct dataset write whose
21
- * commit was not confirmed), and re-sending could store the items twice.
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
- /** Exponential backoff with jitter; honours a Retry-After (seconds or HTTP date) of up to 30 s. */
25
- export declare function serverRetryDelayMs(serverAttempt: number, retryAfter: string | null, random?: () => number, now?: number): number;
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
- private static rateLimiter;
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: 429 (rate limiter), timeouts, and the API being briefly
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. Idempotent methods: 500/502/503/504.
169
- * Others (dataset push, KV PUT) only on a 503 that certainly was not
170
- * processed: the rate limiter refused it ("rate-limit-unavailable", before the
171
- * handler), or HAProxy had no backend (its own page, nothing forwarded). Any
172
- * other 503 may mean "outcome unknown" (e.g. a direct dataset write whose
173
- * commit was not confirmed), and re-sending could store the items twice.
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
- /** Exponential backoff with jitter; honours a Retry-After (seconds or HTTP date) of up to 30 s. */
183
- function serverRetryDelayMs(serverAttempt, retryAfter, random = Math.random, now = Date.now()) {
184
- if (retryAfter !== null && retryAfter.trim() !== '') {
185
- let secs = Number(retryAfter);
186
- if (!Number.isFinite(secs)) {
187
- const at = Date.parse(retryAfter);
188
- secs = Number.isFinite(at) ? Math.max(0, (at - now) / 1000) : NaN;
189
- }
190
- if (Number.isFinite(secs) && secs >= 0)
191
- return Math.min(secs, 30) * 1000 + random() * 100;
192
- }
193
- return Math.min(8000, 500 * 2 ** serverAttempt) + random() * 250;
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
- // Shared rate limiter instance - coordinates across all API clients
269
- // This is static so it's shared across all storage clients for this run
270
- static rateLimiter = new AdaptiveRateLimiter();
271
- // Maximum retries for rate-limited requests
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: 429 (rate limiter), timeouts, and the API being briefly
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
- const shouldRetryTimeouts = options?.doNotRetryTimeouts !== true;
303
- const idempotent = options?.idempotent ?? IDEMPOTENT_METHODS.has(method.toUpperCase());
304
- let attempt = 0; // timeouts and 429s
305
- let serverAttempt = 0; // 503/5xx and connection failures (API briefly unavailable)
306
- for (;;) {
307
- // Wait for rate limiter slot before making request
308
- await BaseApiClient.rateLimiter.waitForSlot();
309
- // Create AbortController for timeout
310
- const controller = new AbortController();
311
- const timeoutId = setTimeout(() => controller.abort(), timeoutMs);
312
- try {
313
- const response = await fetch(url, {
314
- method,
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
- return `${this.config.apiUrl}/v2/key-value-stores/${this.id}/records/${key}`;
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.15",
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",