scrapely 1.2.10 → 1.2.12

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.d.ts CHANGED
@@ -517,6 +517,11 @@ export declare class Actor {
517
517
  /** One-shot bounded reconciliation on fatal process errors. */
518
518
  private static installFatalChargeHandlers;
519
519
  private static _shutdownActor;
520
+ /**
521
+ * Store the exit/fail status message on the run as terminal. Best effort and
522
+ * bounded: a failing or slow API is logged and never blocks the exit.
523
+ */
524
+ private static _persistTerminalStatusMessage;
520
525
  /**
521
526
  * Sets the status message for the current Actor run.
522
527
  *
package/dist/actor.js CHANGED
@@ -382,9 +382,11 @@ class Actor {
382
382
  const code = opts.exitCode ?? 0;
383
383
  const timeoutSecs = opts.timeoutSecs ?? 30;
384
384
  const shouldExit = opts.exit ?? true;
385
- if (opts.statusMessage)
386
- console.log(`[Scrapely] ${opts.statusMessage}`);
387
385
  Actor._ensureActorInit('exit');
386
+ // Apify parity: the exit/fail message becomes the run's terminal status
387
+ // message. Stored before teardown, while the run and process are alive.
388
+ if (opts.statusMessage)
389
+ await Actor._persistTerminalStatusMessage(opts.statusMessage, code);
388
390
  await Actor._shutdownActor(timeoutSecs);
389
391
  if (shouldExit) {
390
392
  await new Promise((resolve) => process.stdout.write('', () => resolve()));
@@ -949,6 +951,31 @@ class Actor {
949
951
  // drain, usually within milliseconds, and is still bounded.
950
952
  await waitForStdioDrain(Math.min(1000, flushDeadlineMs));
951
953
  }
954
+ /**
955
+ * Store the exit/fail status message on the run as terminal. Best effort and
956
+ * bounded: a failing or slow API is logged and never blocks the exit.
957
+ */
958
+ static async _persistTerminalStatusMessage(message, exitCode, timeoutMs = 10_000) {
959
+ let timer;
960
+ try {
961
+ await Promise.race([
962
+ Actor.setStatusMessage(message, {
963
+ isStatusMessageTerminal: true,
964
+ level: exitCode > 0 ? 'ERROR' : 'INFO',
965
+ }),
966
+ new Promise((_, reject) => {
967
+ timer = setTimeout(() => reject(new Error(`timed out after ${timeoutMs}ms`)), timeoutMs);
968
+ }),
969
+ ]);
970
+ }
971
+ catch (err) {
972
+ console.warn(`[Scrapely] Failed to store the final status message on the run: ${err.message}`);
973
+ }
974
+ finally {
975
+ if (timer)
976
+ clearTimeout(timer);
977
+ }
978
+ }
952
979
  /**
953
980
  * Sets the status message for the current Actor run.
954
981
  *
@@ -1000,6 +1027,7 @@ class Actor {
1000
1027
  statusMessage: message,
1001
1028
  isStatusMessageTerminal,
1002
1029
  }),
1030
+ signal: AbortSignal.timeout(10_000),
1003
1031
  });
1004
1032
  if (!response.ok) {
1005
1033
  const error = await response.text();
@@ -0,0 +1,43 @@
1
+ /**
2
+ * Retry remote-browser (CDP) connects that fail for transient reasons.
3
+ *
4
+ * The remote browser answers 429 when it is at capacity and 503 when it cannot
5
+ * start a browser or validate the session right now, both with a Retry-After
6
+ * delay (repeated in the response body, because Playwright only puts the
7
+ * status line and body into its error message). Connection errors and
8
+ * timeouts are transient too. Those are retried with exponential backoff,
9
+ * waiting at least the Retry-After delay when the error carries one. Any other
10
+ * status (400/401/402/403/404) fails at once.
11
+ *
12
+ * Tuning (environment):
13
+ * - SCRAPELY_CDP_CONNECT_ATTEMPTS: total attempts, default 5 (1 disables retries).
14
+ * - SCRAPELY_CDP_RETRY_BASE_SECS: first backoff delay, default 1; doubles per attempt.
15
+ * - SCRAPELY_CDP_RETRY_MAX_SECS: cap for one backoff delay, default 30.
16
+ * - SCRAPELY_CDP_RETRY_AFTER_MAX_SECS: longest Retry-After the SDK waits out,
17
+ * default 120. A longer Retry-After ends the retries (the SDK never retries
18
+ * earlier than the server asked).
19
+ */
20
+ /** @internal Exported for tests. */
21
+ export declare function classifyConnectError(err: unknown): {
22
+ retryable: boolean;
23
+ status?: number;
24
+ retryAfterSecs?: number;
25
+ };
26
+ /** @internal Delay in ms before retry number `attempt` (1-based), honoring Retry-After. */
27
+ export declare function backoffDelayMs(attempt: number, retryAfterSecs?: number): number;
28
+ /** @internal Overridable for tests. */
29
+ export declare const retryTiming: {
30
+ sleep: (ms: number) => Promise<void>;
31
+ };
32
+ /** Run a CDP connect, retrying transient failures with backoff. */
33
+ export declare function connectWithRetry<T>(connect: () => Promise<T>): Promise<T>;
34
+ /** True when `url` points at the same host as the platform's CDP_URL. */
35
+ export declare function isPlatformCdpUrl(url: unknown, platformCdpUrl: string | undefined): boolean;
36
+ export declare function isRetryWrapped(fn: unknown): boolean;
37
+ /**
38
+ * Make `target[method]` (Playwright `connectOverCDP` or Puppeteer `connect`)
39
+ * retry transient failures when it connects to the platform CDP host. Covers
40
+ * actor code that connects to CDP_URL directly as well as the SDK's own
41
+ * redirected launches. Other hosts are left alone. Idempotent.
42
+ */
43
+ export declare function wrapConnectWithRetry(target: any, method: 'connectOverCDP' | 'connect', getPlatformCdpUrl: () => string | undefined): void;
@@ -0,0 +1,171 @@
1
+ "use strict";
2
+ /**
3
+ * Retry remote-browser (CDP) connects that fail for transient reasons.
4
+ *
5
+ * The remote browser answers 429 when it is at capacity and 503 when it cannot
6
+ * start a browser or validate the session right now, both with a Retry-After
7
+ * delay (repeated in the response body, because Playwright only puts the
8
+ * status line and body into its error message). Connection errors and
9
+ * timeouts are transient too. Those are retried with exponential backoff,
10
+ * waiting at least the Retry-After delay when the error carries one. Any other
11
+ * status (400/401/402/403/404) fails at once.
12
+ *
13
+ * Tuning (environment):
14
+ * - SCRAPELY_CDP_CONNECT_ATTEMPTS: total attempts, default 5 (1 disables retries).
15
+ * - SCRAPELY_CDP_RETRY_BASE_SECS: first backoff delay, default 1; doubles per attempt.
16
+ * - SCRAPELY_CDP_RETRY_MAX_SECS: cap for one backoff delay, default 30.
17
+ * - SCRAPELY_CDP_RETRY_AFTER_MAX_SECS: longest Retry-After the SDK waits out,
18
+ * default 120. A longer Retry-After ends the retries (the SDK never retries
19
+ * earlier than the server asked).
20
+ */
21
+ Object.defineProperty(exports, "__esModule", { value: true });
22
+ exports.retryTiming = void 0;
23
+ exports.classifyConnectError = classifyConnectError;
24
+ exports.backoffDelayMs = backoffDelayMs;
25
+ exports.connectWithRetry = connectWithRetry;
26
+ exports.isPlatformCdpUrl = isPlatformCdpUrl;
27
+ exports.isRetryWrapped = isRetryWrapped;
28
+ exports.wrapConnectWithRetry = wrapConnectWithRetry;
29
+ const RETRY_WRAPPED = Symbol.for('scrapely.cdpRetryWrapped');
30
+ const REASONS = 'Too Many Requests|Internal Server Error|Bad Gateway|Service Unavailable|Gateway Timeout|' +
31
+ 'Request Timeout|Bad Request|Unauthorized|Payment Required|Forbidden|Not Found';
32
+ const STATUS_RE = new RegExp(`\\b([1-5]\\d\\d) (?:${REASONS})\\b`);
33
+ const WS_STATUS_RE = /Unexpected server response:?\s*(\d{3})/i;
34
+ const RETRY_AFTER_RE = /Retry-After:?\s*(\d+(?:\.\d+)?)/i;
35
+ const TRANSIENT_MARKERS = [
36
+ 'econnrefused',
37
+ 'econnreset',
38
+ 'etimedout',
39
+ 'epipe',
40
+ 'eai_again',
41
+ 'ehostunreach',
42
+ 'enetunreach',
43
+ 'socket hang up',
44
+ 'timeout',
45
+ 'timed out',
46
+ 'connection closed',
47
+ 'connection refused',
48
+ 'connection reset',
49
+ 'websocket error',
50
+ 'net::err_',
51
+ 'target closed',
52
+ ];
53
+ function envNumber(name, fallback) {
54
+ const value = Number(process.env[name]);
55
+ return Number.isFinite(value) && value > 0 ? value : fallback;
56
+ }
57
+ function maxAttempts() {
58
+ return Math.max(1, Math.floor(envNumber('SCRAPELY_CDP_CONNECT_ATTEMPTS', 5)));
59
+ }
60
+ function errorText(err) {
61
+ if (err instanceof Error) {
62
+ const code = err.code;
63
+ return `${err.name}: ${err.message}${code ? ` ${code}` : ''}`;
64
+ }
65
+ return String(err);
66
+ }
67
+ /** @internal Exported for tests. */
68
+ function classifyConnectError(err) {
69
+ const text = errorText(err);
70
+ const statusMatch = WS_STATUS_RE.exec(text) ?? STATUS_RE.exec(text);
71
+ const retryAfterMatch = RETRY_AFTER_RE.exec(text);
72
+ const retryAfterSecs = retryAfterMatch ? Number(retryAfterMatch[1]) : undefined;
73
+ if (statusMatch) {
74
+ const status = Number(statusMatch[1]);
75
+ return { retryable: status === 408 || status === 429 || status >= 500, status, retryAfterSecs };
76
+ }
77
+ const lowered = text.toLowerCase();
78
+ return { retryable: TRANSIENT_MARKERS.some((m) => lowered.includes(m)), retryAfterSecs };
79
+ }
80
+ /** @internal Delay in ms before retry number `attempt` (1-based), honoring Retry-After. */
81
+ function backoffDelayMs(attempt, retryAfterSecs) {
82
+ const base = envNumber('SCRAPELY_CDP_RETRY_BASE_SECS', 1);
83
+ const cap = envNumber('SCRAPELY_CDP_RETRY_MAX_SECS', 30);
84
+ let delay = Math.min(cap, base * 2 ** (attempt - 1)) * (0.75 + Math.random() * 0.5);
85
+ if (retryAfterSecs !== undefined)
86
+ delay = Math.max(delay, retryAfterSecs);
87
+ return Math.round(delay * 1000);
88
+ }
89
+ function describe(err, status) {
90
+ if (status !== undefined)
91
+ return `HTTP ${status}`;
92
+ // First line only, URL masked: connect errors can carry the session URL with ids.
93
+ const first = (err instanceof Error ? err.message : String(err)).trim().split('\n')[0] || 'error';
94
+ return first.replace(/(wss?|https?):\/\/\S+/g, '<cdp-url>').slice(0, 200);
95
+ }
96
+ /** @internal Overridable for tests. */
97
+ exports.retryTiming = {
98
+ sleep: (ms) => new Promise((resolve) => setTimeout(resolve, ms)),
99
+ };
100
+ /** Run a CDP connect, retrying transient failures with backoff. */
101
+ async function connectWithRetry(connect) {
102
+ const attempts = maxAttempts();
103
+ for (let attempt = 1;; attempt++) {
104
+ try {
105
+ return await connect();
106
+ }
107
+ catch (err) {
108
+ const { retryable, status, retryAfterSecs } = classifyConnectError(err);
109
+ if (!retryable || attempt >= attempts)
110
+ throw err;
111
+ if (retryAfterSecs !== undefined && retryAfterSecs > envNumber('SCRAPELY_CDP_RETRY_AFTER_MAX_SECS', 120)) {
112
+ // Never retry earlier than the server asked; give up instead of waiting that long.
113
+ throw err;
114
+ }
115
+ const delayMs = backoffDelayMs(attempt, retryAfterSecs);
116
+ console.warn(`[scrapely] Remote browser connect failed (${describe(err, status)}); ` +
117
+ `retrying in ${(delayMs / 1000).toFixed(1)}s (attempt ${attempt + 1}/${attempts})`);
118
+ await exports.retryTiming.sleep(delayMs);
119
+ }
120
+ }
121
+ }
122
+ /** True when `url` points at the same host as the platform's CDP_URL. */
123
+ function isPlatformCdpUrl(url, platformCdpUrl) {
124
+ if (typeof url !== 'string' || !platformCdpUrl)
125
+ return false;
126
+ try {
127
+ const target = new URL(url);
128
+ const platform = new URL(platformCdpUrl);
129
+ return !!target.host && target.host.toLowerCase() === platform.host.toLowerCase();
130
+ }
131
+ catch {
132
+ return false;
133
+ }
134
+ }
135
+ function connectTarget(arg) {
136
+ if (typeof arg === 'string')
137
+ return arg;
138
+ if (arg && typeof arg === 'object') {
139
+ const o = arg;
140
+ return o.endpointURL ?? o.wsEndpoint ?? o.browserWSEndpoint ?? o.browserURL;
141
+ }
142
+ return undefined;
143
+ }
144
+ function isRetryWrapped(fn) {
145
+ return typeof fn === 'function' && fn[RETRY_WRAPPED] === true;
146
+ }
147
+ /**
148
+ * Make `target[method]` (Playwright `connectOverCDP` or Puppeteer `connect`)
149
+ * retry transient failures when it connects to the platform CDP host. Covers
150
+ * actor code that connects to CDP_URL directly as well as the SDK's own
151
+ * redirected launches. Other hosts are left alone. Idempotent.
152
+ */
153
+ function wrapConnectWithRetry(target, method, getPlatformCdpUrl) {
154
+ const original = target?.[method];
155
+ if (typeof original !== 'function' || isRetryWrapped(original))
156
+ return;
157
+ const wrapped = function (...args) {
158
+ const self = this ?? target;
159
+ if (!isPlatformCdpUrl(connectTarget(args[0]), getPlatformCdpUrl())) {
160
+ return original.apply(self, args);
161
+ }
162
+ return connectWithRetry(() => original.apply(self, args));
163
+ };
164
+ wrapped[RETRY_WRAPPED] = true;
165
+ try {
166
+ target[method] = wrapped;
167
+ }
168
+ catch {
169
+ // frozen object: leave it unwrapped
170
+ }
171
+ }
@@ -18,6 +18,7 @@ exports.patchRawBrowserType = patchRawBrowserType;
18
18
  */
19
19
  const browser_configuration_js_1 = require("./browser-configuration.js");
20
20
  const browser_locations_js_1 = require("./browser-locations.js");
21
+ const cdp_retry_js_1 = require("./cdp-retry.js");
21
22
  let _cdpUrl;
22
23
  /**
23
24
  * Lazily get CDP URL from environment or cached value.
@@ -321,6 +322,7 @@ function patchPlaywrightPluginClass(PlaywrightPlugin) {
321
322
  }
322
323
  const cdpUrl = applyProxyInfoToCdpUrl(requireCdpUrl(), proxyInfoFromLaunchContext(launchContext), launchContext?.browserConfiguration);
323
324
  const chromium = getPlaywrightChromium(this.library);
325
+ (0, cdp_retry_js_1.wrapConnectWithRetry)(chromium, 'connectOverCDP', getCdpUrl);
324
326
  return chromium.connectOverCDP(cdpUrl);
325
327
  };
326
328
  proto._scrapelyCdpPatched = true;
@@ -344,6 +346,7 @@ function patchPuppeteerPluginClass(PuppeteerPlugin) {
344
346
  if (!launcher || typeof launcher.connect !== 'function') {
345
347
  throw new Error('Puppeteer library is missing connect(). Install puppeteer or puppeteer-core.');
346
348
  }
349
+ (0, cdp_retry_js_1.wrapConnectWithRetry)(launcher, 'connect', getCdpUrl);
347
350
  return launcher.connect({ browserWSEndpoint: cdpUrl });
348
351
  };
349
352
  proto._scrapelyCdpPatched = true;
@@ -669,6 +672,10 @@ function patchRawBrowserType(chromium, browserType, name) {
669
672
  function patchRawPlaywrightModule(pw) {
670
673
  if (!pw?.chromium || typeof pw.chromium.connectOverCDP !== 'function')
671
674
  return;
675
+ // Retry transient connect failures (429/5xx/network) for the platform CDP
676
+ // host: covers direct chromium.connectOverCDP(CDP_URL) calls in actor code
677
+ // and the redirected launches, which call this same property.
678
+ (0, cdp_retry_js_1.wrapConnectWithRetry)(pw.chromium, 'connectOverCDP', getCdpUrl);
672
679
  for (const name of ['chromium', 'firefox', 'webkit']) {
673
680
  patchRawBrowserType(pw.chromium, pw[name], name);
674
681
  }
@@ -683,6 +690,7 @@ function patchRawPuppeteerModule(mod, moduleName) {
683
690
  if (typeof launcher?.launch !== 'function' || launcher._scrapelyRawCdpPatched) {
684
691
  return;
685
692
  }
693
+ (0, cdp_retry_js_1.wrapConnectWithRetry)(launcher, 'connect', getCdpUrl);
686
694
  const origLaunch = launcher.launch.bind(launcher);
687
695
  launcher.launch = async function rawPuppeteerLaunchPatched(launchOptions = {}) {
688
696
  const { options, browserConfiguration } = takeBrowserConfiguration(launchOptions);
@@ -12,6 +12,19 @@ export declare class ApiStorageClientError extends Error {
12
12
  readonly statusCode: number;
13
13
  constructor(message: string, statusCode: number);
14
14
  }
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.
22
+ */
23
+ 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
+ /** fetch() failed before the request reached the server. */
27
+ export declare function isConnectFailure(err: unknown): boolean;
15
28
  /**
16
29
  * Options for API requests (passed through from RecordOptions)
17
30
  */
@@ -20,19 +33,29 @@ interface ApiRequestOptions {
20
33
  timeoutSecs?: number;
21
34
  /** If true, don't retry on timeout errors */
22
35
  doNotRetryTimeouts?: boolean;
36
+ /** Override the method-based idempotency (e.g. get-or-create by name is safe to repeat). */
37
+ idempotent?: boolean;
23
38
  }
24
39
  /**
25
40
  * Base class for API-based storage clients.
26
41
  * Communicates with internal storage API instead of direct DB access.
27
42
  * No sensitive credentials are stored - only the user's API key.
28
43
  */
29
- declare abstract class BaseApiClient {
44
+ export declare abstract class BaseApiClient {
30
45
  protected config: ApiStorageConfig;
31
46
  protected headers: Record<string, string>;
32
47
  private static rateLimiter;
33
48
  private static maxRetries;
49
+ static maxServerRetries: number;
34
50
  private static defaultTimeoutMs;
35
51
  constructor(config: ApiStorageConfig);
52
+ /**
53
+ * 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.
57
+ */
58
+ private send;
36
59
  protected request<T>(method: string, path: string, body?: any, contentType?: string, options?: ApiRequestOptions): Promise<T>;
37
60
  protected requestWithHeaders<T>(method: string, path: string, body?: any, contentType?: string): Promise<{
38
61
  data: T;
@@ -1,6 +1,9 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
- exports.ApiStorageClientFactory = exports.ApiRequestQueueClient = exports.ApiKeyValueStoreClient = exports.ApiDatasetClient = exports.ApiStorageClientError = void 0;
3
+ exports.ApiStorageClientFactory = exports.ApiRequestQueueClient = exports.ApiKeyValueStoreClient = exports.ApiDatasetClient = exports.BaseApiClient = exports.ApiStorageClientError = void 0;
4
+ exports.isRetryableResponse = isRetryableResponse;
5
+ exports.serverRetryDelayMs = serverRetryDelayMs;
6
+ exports.isConnectFailure = isConnectFailure;
4
7
  const node_crypto_1 = require("node:crypto");
5
8
  const kvs_iteration_js_1 = require("./kvs-iteration.js");
6
9
  function buildRqQueryParams(baseClientKey, options) {
@@ -155,6 +158,53 @@ exports.ApiStorageClientError = ApiStorageClientError;
155
158
  function isApiStorageClientError(err) {
156
159
  return err instanceof ApiStorageClientError;
157
160
  }
161
+ // PUT is left out: a KV record PUT is counted (and billed) as a write each
162
+ // time it is applied, so a lost response must not be re-sent blindly.
163
+ const IDEMPOTENT_METHODS = new Set(['GET', 'HEAD', 'DELETE', 'OPTIONS']);
164
+ // HAProxy's own 503 page: no backend was available, so nothing was forwarded.
165
+ const NO_BACKEND_MARKER = 'No server is available to handle this request';
166
+ const CONNECT_FAILURE_CODES = new Set(['ECONNREFUSED', 'ENOTFOUND', 'EAI_AGAIN', 'EHOSTUNREACH', 'ENETUNREACH', 'UND_ERR_CONNECT_TIMEOUT']);
167
+ /**
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.
174
+ */
175
+ function isRetryableResponse(status, idempotent, errorType, bodyText) {
176
+ if (idempotent)
177
+ return status === 500 || status === 502 || status === 503 || status === 504;
178
+ if (status !== 503)
179
+ return false;
180
+ return errorType === 'rate-limit-unavailable' || (bodyText ?? '').includes(NO_BACKEND_MARKER);
181
+ }
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;
194
+ }
195
+ function fetchErrorCode(err) {
196
+ const e = err;
197
+ return e?.cause?.code ?? e?.cause?.cause?.code ?? e?.code;
198
+ }
199
+ /** fetch() failed before the request reached the server. */
200
+ function isConnectFailure(err) {
201
+ const code = fetchErrorCode(err);
202
+ return code !== undefined && CONNECT_FAILURE_CODES.has(code);
203
+ }
204
+ function describeFetchError(err) {
205
+ return fetchErrorCode(err) ?? err?.message ?? String(err);
206
+ }
207
+ const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
158
208
  /** Map GET storage errors: rethrow 403, return undefined for 404 (triggers getOrCreate for names only). */
159
209
  function handleStorageGetError(err) {
160
210
  if (isApiStorageClientError(err)) {
@@ -175,17 +225,11 @@ function storageCollectionHeaders(config) {
175
225
  /** POST /v2/{datasets|key-value-stores|request-queues} to create or get-by-name. */
176
226
  async function postStorageCollection(config, path, name) {
177
227
  const query = name ? `?name=${encodeURIComponent(name)}` : '';
178
- const url = `${config.apiUrl}/v2${path}${query}`;
179
- const response = await fetch(url, {
180
- method: 'POST',
181
- headers: storageCollectionHeaders(config),
182
- });
183
- if (!response.ok) {
184
- const error = await response.json().catch(() => ({ error: { message: response.statusText } }));
185
- throw new Error(error.error?.message || `Failed to create storage: ${response.status}`);
186
- }
187
- const body = await response.json();
188
- return unwrapData(body);
228
+ // Not marked repeatable even by name: a 500 after the create (e.g. scoped
229
+ // token registration failed) must surface, since a re-send would take the
230
+ // existing-name path and hide it. POST rules apply (limiter/HAProxy 503,
231
+ // refused connection).
232
+ return new CollectionApiClient(config).call('POST', `${path}${query}`);
189
233
  }
190
234
  /**
191
235
  * Helper to create AsyncIterable + Promise result (matches Crawlee pattern)
@@ -226,6 +270,10 @@ class BaseApiClient {
226
270
  static rateLimiter = new AdaptiveRateLimiter();
227
271
  // Maximum retries for rate-limited requests
228
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;
229
277
  // Default timeout for requests (30 seconds)
230
278
  static defaultTimeoutMs = 30000;
231
279
  constructor(config) {
@@ -236,7 +284,13 @@ class BaseApiClient {
236
284
  'Content-Type': 'application/json',
237
285
  };
238
286
  }
239
- async request(method, path, body, contentType, options) {
287
+ /**
288
+ * 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.
292
+ */
293
+ async send(method, path, body, contentType, options, decode) {
240
294
  const url = `${this.config.apiUrl}/v2${path}`;
241
295
  const headers = { ...this.headers };
242
296
  if (contentType) {
@@ -246,8 +300,10 @@ class BaseApiClient {
246
300
  ? options.timeoutSecs * 1000
247
301
  : BaseApiClient.defaultTimeoutMs;
248
302
  const shouldRetryTimeouts = options?.doNotRetryTimeouts !== true;
249
- let lastError = null;
250
- for (let attempt = 0; attempt <= BaseApiClient.maxRetries; attempt++) {
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 (;;) {
251
307
  // Wait for rate limiter slot before making request
252
308
  await BaseApiClient.rateLimiter.waitForSlot();
253
309
  // Create AbortController for timeout
@@ -265,89 +321,110 @@ class BaseApiClient {
265
321
  // Handle rate limiting (429)
266
322
  if (response.status === 429) {
267
323
  if (attempt < BaseApiClient.maxRetries) {
324
+ attempt++;
268
325
  await BaseApiClient.rateLimiter.handleRateLimited(response);
269
326
  continue; // Retry
270
327
  }
271
- // Max retries exceeded
272
328
  const error = await response.json().catch(() => ({ error: { message: 'Rate limit exceeded' } }));
273
- throw new Error(error.error?.message || 'Rate limit exceeded');
329
+ throw new ApiStorageClientError(error.error?.message || 'Rate limit exceeded', 429);
274
330
  }
275
- if (!response.ok) {
276
- const error = await response.json().catch(() => ({ error: { message: response.statusText } }));
277
- throw new ApiStorageClientError(error.error?.message || `API request failed: ${response.status}`, response.status);
331
+ if (response.ok) {
332
+ try {
333
+ // Decoded inside the retry loop: a body cut off after
334
+ // the headers is a transport failure like any other.
335
+ return await decode(response);
336
+ }
337
+ catch (bodyErr) {
338
+ // The request was applied (2xx); a write whose answer
339
+ // timed out while being read must not be sent again.
340
+ if (bodyErr.name === 'AbortError' && !idempotent) {
341
+ const timeoutError = new Error(`Request timed out after ${timeoutMs}ms (response body; the request was applied)`);
342
+ timeoutError.isTimeout = true;
343
+ throw timeoutError;
344
+ }
345
+ if (idempotent && bodyErr.name !== 'AbortError' && serverAttempt < BaseApiClient.maxServerRetries) {
346
+ const waitMs = serverRetryDelayMs(serverAttempt, null);
347
+ serverAttempt++;
348
+ console.warn(`[Scrapely] ${method} ${path} response body failed (${describeFetchError(bodyErr)}); retry ${serverAttempt}/${BaseApiClient.maxServerRetries} in ${Math.round(waitMs)}ms`);
349
+ await sleep(waitMs);
350
+ continue;
351
+ }
352
+ throw bodyErr;
353
+ }
278
354
  }
279
- // Handle empty responses (204 No Content)
280
- if (response.status === 204) {
281
- return undefined;
355
+ // API briefly unavailable (main redeploy, limiter store blip).
356
+ const text = await response.text().catch(() => '');
357
+ let errorBody;
358
+ try {
359
+ errorBody = JSON.parse(text);
282
360
  }
283
- // Handle text/plain responses
284
- const responseContentType = response.headers.get('content-type') || '';
285
- if (responseContentType.includes('text/plain')) {
286
- return response.text();
361
+ catch {
362
+ errorBody = undefined;
287
363
  }
288
- // Handle binary responses
289
- if (responseContentType.includes('application/octet-stream')) {
290
- const buffer = Buffer.from(await response.arrayBuffer());
291
- return buffer;
364
+ if (isRetryableResponse(response.status, idempotent, errorBody?.error?.type, text)
365
+ && serverAttempt < BaseApiClient.maxServerRetries) {
366
+ const waitMs = serverRetryDelayMs(serverAttempt, response.headers.get('retry-after'));
367
+ serverAttempt++;
368
+ console.warn(`[Scrapely] ${method} ${path} answered ${response.status}; retry ${serverAttempt}/${BaseApiClient.maxServerRetries} in ${Math.round(waitMs)}ms`);
369
+ await sleep(waitMs);
370
+ continue;
292
371
  }
293
- const json = await response.json();
294
- return unwrapData(json);
372
+ throw new ApiStorageClientError(errorBody?.error?.message || response.statusText || `API request failed: ${response.status}`, response.status);
295
373
  }
296
374
  catch (err) {
297
375
  // Handle timeout/abort errors
298
376
  if (err.name === 'AbortError') {
299
377
  const timeoutError = new Error(`Request timed out after ${timeoutMs}ms`);
300
378
  timeoutError.isTimeout = true;
301
- lastError = timeoutError;
302
379
  // Don't retry if doNotRetryTimeouts is true
303
380
  if (!shouldRetryTimeouts || attempt >= BaseApiClient.maxRetries) {
304
381
  throw timeoutError;
305
382
  }
383
+ attempt++;
306
384
  // Wait a bit before retrying
307
- await new Promise(resolve => setTimeout(resolve, 100 * (attempt + 1)));
385
+ await sleep(100 * attempt);
308
386
  continue;
309
387
  }
310
- lastError = err;
311
- // Don't retry non-timeout errors unless it's a network issue
312
- const isNetworkError = !err.response && !err.status;
313
- if (!isNetworkError || attempt >= BaseApiClient.maxRetries) {
388
+ // HTTP errors (already decided above) are final.
389
+ if (isApiStorageClientError(err))
314
390
  throw err;
391
+ // fetch() failed without a response. A refused/unresolvable
392
+ // connection never reached the API, so it is safe for every
393
+ // method; anything else (reset mid-request) only for idempotent ones.
394
+ if ((idempotent || isConnectFailure(err)) && serverAttempt < BaseApiClient.maxServerRetries) {
395
+ const waitMs = serverRetryDelayMs(serverAttempt, null);
396
+ serverAttempt++;
397
+ console.warn(`[Scrapely] ${method} ${path} failed (${describeFetchError(err)}); retry ${serverAttempt}/${BaseApiClient.maxServerRetries} in ${Math.round(waitMs)}ms`);
398
+ await sleep(waitMs);
399
+ continue;
315
400
  }
316
- // Wait before retrying network errors
317
- await new Promise(resolve => setTimeout(resolve, 100 * (attempt + 1)));
401
+ throw err;
318
402
  }
319
403
  finally {
320
404
  clearTimeout(timeoutId);
321
405
  }
322
406
  }
323
- throw lastError || new Error('Request failed after retries');
324
407
  }
325
- async requestWithHeaders(method, path, body, contentType) {
326
- const url = `${this.config.apiUrl}/v2${path}`;
327
- const headers = { ...this.headers };
328
- if (contentType) {
329
- headers['Content-Type'] = contentType;
330
- }
331
- for (let attempt = 0; attempt <= BaseApiClient.maxRetries; attempt++) {
332
- await BaseApiClient.rateLimiter.waitForSlot();
333
- const response = await fetch(url, {
334
- method,
335
- headers,
336
- body: body !== undefined ? (typeof body === 'string' ? body : JSON.stringify(body)) : undefined,
337
- });
338
- BaseApiClient.rateLimiter.updateFromHeaders(response.headers);
339
- if (response.status === 429) {
340
- if (attempt < BaseApiClient.maxRetries) {
341
- await BaseApiClient.rateLimiter.handleRateLimited(response);
342
- continue;
343
- }
344
- const error = await response.json().catch(() => ({ error: { message: 'Rate limit exceeded' } }));
345
- throw new Error(error.error?.message || 'Rate limit exceeded');
408
+ async request(method, path, body, contentType, options) {
409
+ return this.send(method, path, body, contentType, options, async (response) => {
410
+ // Handle empty responses (204 No Content)
411
+ if (response.status === 204) {
412
+ return undefined;
346
413
  }
347
- if (!response.ok) {
348
- const error = await response.json().catch(() => ({ error: { message: response.statusText } }));
349
- throw new Error(error.error?.message || `API request failed: ${response.status}`);
414
+ // Handle text/plain responses
415
+ const responseContentType = response.headers.get('content-type') || '';
416
+ if (responseContentType.includes('text/plain')) {
417
+ return (await response.text());
350
418
  }
419
+ // Handle binary responses
420
+ if (responseContentType.includes('application/octet-stream')) {
421
+ return Buffer.from(await response.arrayBuffer());
422
+ }
423
+ return unwrapData(await response.json());
424
+ });
425
+ }
426
+ async requestWithHeaders(method, path, body, contentType) {
427
+ return this.send(method, path, body, contentType, undefined, async (response) => {
351
428
  if (response.status === 204) {
352
429
  return { data: undefined, contentType: '' };
353
430
  }
@@ -356,13 +433,18 @@ class BaseApiClient {
356
433
  return { data: await response.text(), contentType: 'text/plain' };
357
434
  }
358
435
  if (responseContentType.includes('application/octet-stream')) {
359
- const buffer = Buffer.from(await response.arrayBuffer());
360
- return { data: buffer, contentType: 'application/octet-stream' };
436
+ return { data: Buffer.from(await response.arrayBuffer()), contentType: 'application/octet-stream' };
361
437
  }
362
438
  const json = await response.json();
363
439
  return { data: json, contentType: responseContentType.split(';')[0].trim() || 'application/json' };
364
- }
365
- throw new Error('Request failed after retries');
440
+ });
441
+ }
442
+ }
443
+ exports.BaseApiClient = BaseApiClient;
444
+ /** Collection-level calls (create/get by name, list) with the same retry policy. */
445
+ class CollectionApiClient extends BaseApiClient {
446
+ call(method, path, options) {
447
+ return this.request(method, path, undefined, undefined, options);
366
448
  }
367
449
  }
368
450
  /**
@@ -531,8 +613,12 @@ class ApiKeyValueStoreClient extends BaseApiClient {
531
613
  }
532
614
  return { key, value, contentType: contentType || 'application/json' };
533
615
  }
534
- catch {
535
- return undefined;
616
+ catch (err) {
617
+ // Missing record only; anything else (API unavailable after the
618
+ // retries) must not look like "no record" (lost state, restarts).
619
+ if (isApiStorageClientError(err) && err.statusCode === 404)
620
+ return undefined;
621
+ throw err;
536
622
  }
537
623
  }
538
624
  async setRecord(record, options) {
@@ -850,17 +936,7 @@ class ApiRequestQueueCollectionClient {
850
936
  };
851
937
  }
852
938
  async list() {
853
- const url = `${this.config.apiUrl}/v2/request-queues`;
854
- const response = await fetch(url, {
855
- headers: storageCollectionHeaders(this.config),
856
- });
857
- if (!response.ok) {
858
- const error = await response.json().catch(() => ({ error: { message: response.statusText } }));
859
- throw new Error(error.error?.message
860
- || `Failed to list request queues: ${response.status}`);
861
- }
862
- const body = await response.json();
863
- const data = unwrapData(body);
939
+ const data = await new CollectionApiClient(this.config).call('GET', '/request-queues');
864
940
  return {
865
941
  total: data.total,
866
942
  count: data.count,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "scrapely",
3
- "version": "1.2.10",
3
+ "version": "1.2.12",
4
4
  "description": "Scrapely SDK for building JavaScript and TypeScript Actors",
5
5
  "main": "dist/index.js",
6
6
  "types": "dist/index.d.ts",
@@ -19,6 +19,7 @@
19
19
  "scripts": {
20
20
  "test": "npx tsx tests/test-mvp.ts",
21
21
  "test:charging": "npx tsx --test tests/charging-pending.test.ts",
22
+ "test:storage-retries": "npx tsx --test tests/storage-retries.test.ts",
22
23
  "test:stdio-drain": "npx tsx --test tests/stdio-drain.test.ts",
23
24
  "test:charging-client": "npx tsx --test tests/charging-client.test.ts",
24
25
  "test:proxy": "npx tsx --test tests/proxy-configuration.test.ts",
@@ -35,7 +36,9 @@
35
36
  "test:init-rollback": "npx tsx --test tests/init-rollback.test.ts",
36
37
  "test:apify-parity": "npx tsx --test tests/apify-parity.test.ts",
37
38
  "test:run-control": "npx tsx --test tests/run-control.test.ts",
38
- "test:rq-list": "npx tsx --test tests/rq-list-requests.test.ts"
39
+ "test:rq-list": "npx tsx --test tests/rq-list-requests.test.ts",
40
+ "test:terminal-status": "npx tsx --test tests/terminal-status-message.test.ts",
41
+ "test:cdp-retry": "npx tsx --test tests/cdp-retry.test.ts"
39
42
  },
40
43
  "author": "SandeepDev1",
41
44
  "license": "ISC",