scrapely 1.2.11 → 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 +5 -0
- package/dist/actor.js +30 -2
- package/dist/cdp-retry.d.ts +43 -0
- package/dist/cdp-retry.js +171 -0
- package/dist/patch-browser-pool.js +8 -0
- package/package.json +4 -2
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);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "scrapely",
|
|
3
|
-
"version": "1.2.
|
|
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",
|
|
@@ -36,7 +36,9 @@
|
|
|
36
36
|
"test:init-rollback": "npx tsx --test tests/init-rollback.test.ts",
|
|
37
37
|
"test:apify-parity": "npx tsx --test tests/apify-parity.test.ts",
|
|
38
38
|
"test:run-control": "npx tsx --test tests/run-control.test.ts",
|
|
39
|
-
"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"
|
|
40
42
|
},
|
|
41
43
|
"author": "SandeepDev1",
|
|
42
44
|
"license": "ISC",
|