scrapely 1.2.16 → 1.2.17

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