scrapely 1.2.36 → 1.2.38
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/CHANGELOG.md +11 -0
- package/dist/actor.d.ts +7 -0
- package/dist/actor.js +29 -3
- package/dist/storage/api-storage-client.d.ts +15 -7
- package/dist/storage/api-storage-client.js +49 -20
- package/package.json +5 -3
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,17 @@
|
|
|
2
2
|
|
|
3
3
|
All notable changes to this project will be documented in this file.
|
|
4
4
|
|
|
5
|
+
## [Unreleased]
|
|
6
|
+
|
|
7
|
+
## [1.2.38] - 2026-10-27
|
|
8
|
+
|
|
9
|
+
- A key-value store record write (`Actor.setValue`, `KeyValueStore.setValue`) is no longer lost to one failed answer. Each write now sends an `Idempotency-Key`, and when its answer is lost (a `502`/`504` page from the edge or load balancer, a dropped connection, a timeout) it is sent again with the same key; the API applies and counts it once. Before, the write was never re-sent, so one such page on the final `OUTPUT` write failed the whole run. An API that answers a keyed write without `X-Scrapely-Idempotency` (an older server) gets no re-sends, as before.
|
|
10
|
+
|
|
11
|
+
## [1.2.37] - 2026-10-26
|
|
12
|
+
|
|
13
|
+
- Opening a named dataset, key-value store or request queue (`Actor.openDataset('name')` and the like) retries when the API answers `503 storage-name-busy`. The API sends it when another request kept creating or renaming a storage of that name for over 15 s; nothing was changed, so the open is simply repeated. It used to fail the open.
|
|
14
|
+
- Once `Actor.fail()` has run, the run stays failed: a later `Actor.exit()`, or the end of `Actor.main()`, keeps the failure's exit code and status message. `fail()` normally ends the process at once, so this only changed runs that called `fail({ exit: false })` and then `exit()` or returned from `Actor.main()`; they used to exit with 0. A second `fail()` keeps the first failure. `fail({ exitCode: undefined })` exits with 1.
|
|
15
|
+
|
|
5
16
|
## [1.2.36] - 2026-10-22
|
|
6
17
|
|
|
7
18
|
- Debug captures (`Actor.debugCapture`, `Actor.debugCaptureSync`) keep payloads over 256 KiB whole. They are stored gzip-compressed and base64-encoded as `{ key, payload, encoding: 'gzip-base64', length, truncated }` instead of being cut at 262,144 characters; up to 8 Mi characters are kept. A payload that does not compress enough keeps its first 256 KiB, now marked `truncated: true` with its full `length`. `Uint8Array` / `Buffer` payloads are stored as UTF-8 text. Captures are only active on the platform's own diagnostic runs (`SCRAPELY_DEBUG_CAPTURE=1`).
|
package/dist/actor.d.ts
CHANGED
|
@@ -332,6 +332,12 @@ export declare class Actor {
|
|
|
332
332
|
* succeeds.
|
|
333
333
|
*/
|
|
334
334
|
private _lifecycleStarted;
|
|
335
|
+
/**
|
|
336
|
+
* Set by {@link Actor.fail} for the rest of the run: a later {@link Actor.exit}
|
|
337
|
+
* keeps the failure's exit code and status message, so a failed run never
|
|
338
|
+
* ends as SUCCEEDED (e.g. `fail({ exit: false })` followed by `exit()`).
|
|
339
|
+
*/
|
|
340
|
+
private static _failure;
|
|
335
341
|
/** Created on first use: scrapely-client is only loaded when something needs the API client. */
|
|
336
342
|
private _apifyClient;
|
|
337
343
|
private readonly chargingManager;
|
|
@@ -388,6 +394,7 @@ export declare class Actor {
|
|
|
388
394
|
* @param options - Exit options
|
|
389
395
|
*/
|
|
390
396
|
static exit(messageOrOptions?: string | ExitOptions, options?: ExitOptions): Promise<void>;
|
|
397
|
+
private static _exitWith;
|
|
391
398
|
static fail(messageOrOptions?: string | ExitOptions, options?: ExitOptions): Promise<void>;
|
|
392
399
|
static getInput<T = ActorInput>(): Promise<T | null>;
|
|
393
400
|
static getInputOrThrow<T = ActorInput>(): Promise<T>;
|
package/dist/actor.js
CHANGED
|
@@ -112,6 +112,12 @@ class Actor {
|
|
|
112
112
|
* succeeds.
|
|
113
113
|
*/
|
|
114
114
|
_lifecycleStarted = false;
|
|
115
|
+
/**
|
|
116
|
+
* Set by {@link Actor.fail} for the rest of the run: a later {@link Actor.exit}
|
|
117
|
+
* keeps the failure's exit code and status message, so a failed run never
|
|
118
|
+
* ends as SUCCEEDED (e.g. `fail({ exit: false })` followed by `exit()`).
|
|
119
|
+
*/
|
|
120
|
+
static _failure;
|
|
115
121
|
/** Created on first use: scrapely-client is only loaded when something needs the API client. */
|
|
116
122
|
_apifyClient;
|
|
117
123
|
chargingManager;
|
|
@@ -189,6 +195,7 @@ class Actor {
|
|
|
189
195
|
if (this.initialized)
|
|
190
196
|
return;
|
|
191
197
|
cachedInput = undefined; // a fresh init reads this run's input again
|
|
198
|
+
Actor._failure = undefined; // a new lifecycle starts unfailed
|
|
192
199
|
// NOTE: `initialized` is set at the END of init — a failed platform init
|
|
193
200
|
// must stay retryable instead of leaving a half-initialized Actor.
|
|
194
201
|
Actor.installFatalChargeHandlers();
|
|
@@ -375,7 +382,8 @@ class Actor {
|
|
|
375
382
|
const result = await userFunc();
|
|
376
383
|
await Actor._shutdownActor(options?.timeoutSecs ?? 30);
|
|
377
384
|
await new Promise((resolve) => process.stdout.write('', () => resolve()));
|
|
378
|
-
|
|
385
|
+
// A run that called fail({ exit: false }) stays failed.
|
|
386
|
+
process.exit(Actor._failure?.exitCode ?? options?.exitCode ?? 0);
|
|
379
387
|
return result;
|
|
380
388
|
}
|
|
381
389
|
catch (err) {
|
|
@@ -403,10 +411,22 @@ class Actor {
|
|
|
403
411
|
const opts = typeof messageOrOptions === 'string'
|
|
404
412
|
? { ...options, statusMessage: messageOrOptions }
|
|
405
413
|
: { ...messageOrOptions, ...options };
|
|
414
|
+
Actor._ensureActorInit('exit');
|
|
415
|
+
const failure = Actor._failure;
|
|
416
|
+
if (failure) {
|
|
417
|
+
// Actor.fail() already ran: the run stays failed. Keep its exit code and
|
|
418
|
+
// status message; this exit() only finishes the shutdown.
|
|
419
|
+
if (opts.statusMessage)
|
|
420
|
+
console.log(`[Scrapely] ${opts.statusMessage}`);
|
|
421
|
+
console.log(`[Scrapely] Actor.exit() after Actor.fail(): the run stays failed (exit code ${failure.exitCode}).`);
|
|
422
|
+
return Actor._exitWith({ ...opts, exitCode: failure.exitCode, statusMessage: undefined });
|
|
423
|
+
}
|
|
424
|
+
return Actor._exitWith(opts);
|
|
425
|
+
}
|
|
426
|
+
static async _exitWith(opts) {
|
|
406
427
|
const code = opts.exitCode ?? 0;
|
|
407
428
|
const timeoutSecs = opts.timeoutSecs ?? 30;
|
|
408
429
|
const shouldExit = opts.exit ?? true;
|
|
409
|
-
Actor._ensureActorInit('exit');
|
|
410
430
|
// The exit/fail message becomes the run's terminal status
|
|
411
431
|
// message. Stored before teardown, while the run and process are alive.
|
|
412
432
|
if (opts.statusMessage)
|
|
@@ -423,7 +443,13 @@ class Actor {
|
|
|
423
443
|
: { exitCode: 1, ...messageOrOptions, ...options };
|
|
424
444
|
console.error(`[Scrapely] Actor failed: ${opts.statusMessage ?? 'Unknown error'}`);
|
|
425
445
|
await new Promise((resolve) => process.stderr.write('', () => resolve()));
|
|
426
|
-
|
|
446
|
+
Actor._ensureActorInit('fail');
|
|
447
|
+
if (Actor._failure) {
|
|
448
|
+
// Failed already: the first failure's code and message stay.
|
|
449
|
+
return Actor._exitWith({ ...opts, exitCode: Actor._failure.exitCode, statusMessage: undefined });
|
|
450
|
+
}
|
|
451
|
+
Actor._failure = { exitCode: opts.exitCode ?? 1, statusMessage: opts.statusMessage };
|
|
452
|
+
await Actor._exitWith({ ...opts, exitCode: opts.exitCode ?? 1 });
|
|
427
453
|
}
|
|
428
454
|
static async getInput() {
|
|
429
455
|
Actor._ensureActorInit('getInput');
|
|
@@ -15,17 +15,25 @@ export declare class ApiStorageClientError extends Error {
|
|
|
15
15
|
/**
|
|
16
16
|
* Whether a non-2xx answer is retried. 429 always: the run API limiter and
|
|
17
17
|
* dataset backpressure refuse it before anything is written. Idempotent
|
|
18
|
-
* methods: also 500/502/503/504. Others (dataset push, KV PUT
|
|
19
|
-
* that certainly was not processed: the rate limiter
|
|
20
|
-
* ("rate-limit-unavailable", before the handler),
|
|
21
|
-
*
|
|
22
|
-
*
|
|
23
|
-
*
|
|
18
|
+
* methods: also 500/502/503/504. Others (dataset push, KV PUT, named storage
|
|
19
|
+
* create) only on a 503 that certainly was not processed: the rate limiter
|
|
20
|
+
* refused it ("rate-limit-unavailable", before the handler), another request
|
|
21
|
+
* held the storage name ("storage-name-busy", nothing changed), or HAProxy had
|
|
22
|
+
* no backend (its own page, nothing forwarded). Any other 503 may mean
|
|
23
|
+
* "outcome unknown" (e.g. a direct dataset write whose commit was not
|
|
24
|
+
* confirmed), and re-sending could store the items twice.
|
|
24
25
|
*/
|
|
25
26
|
export declare function isRetryableResponse(status: number, idempotent: boolean, errorType?: string, bodyText?: string): boolean;
|
|
26
27
|
/** Default time budget for retrying one API call (429, 503, network errors). */
|
|
27
28
|
export declare const DEFAULT_API_RETRY_WINDOW_MS = 300000;
|
|
28
|
-
/**
|
|
29
|
+
/** A key-value store record URL (`.../key-value-stores/<id>/records/<key>`). */
|
|
30
|
+
export declare function isKvRecordUrl(url: string): boolean;
|
|
31
|
+
/**
|
|
32
|
+
* Whether the API at `url` (any URL of it; default: the last one seen) de-duplicates keyed writes.
|
|
33
|
+
* Record PUTs count as de-duplicated until a keyed record PUT was stored without the support
|
|
34
|
+
* header: re-applying the same body to the same record leaves the same record, so the worst
|
|
35
|
+
* case on an API that ignores the key is one extra counted write, while not re-sending fails the run.
|
|
36
|
+
*/
|
|
29
37
|
export declare function apiDedupesKeyedWrites(url?: string): boolean;
|
|
30
38
|
/** Test hook: forget what was learned about the API, or force an answer for every origin. */
|
|
31
39
|
export declare function _resetKeyedWriteSupport(value?: boolean): void;
|
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
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.isKvRecordUrl = isKvRecordUrl;
|
|
5
6
|
exports.apiDedupesKeyedWrites = apiDedupesKeyedWrites;
|
|
6
7
|
exports._resetKeyedWriteSupport = _resetKeyedWriteSupport;
|
|
7
8
|
exports.apiRetryWindowMs = apiRetryWindowMs;
|
|
@@ -139,7 +140,9 @@ function isApiStorageClientError(err) {
|
|
|
139
140
|
return err instanceof ApiStorageClientError;
|
|
140
141
|
}
|
|
141
142
|
// PUT is left out: a KV record PUT is counted (and billed) as a write each
|
|
142
|
-
// time it is applied, so a lost response must not be re-sent blindly.
|
|
143
|
+
// time it is applied, so a lost response must not be re-sent blindly. Record
|
|
144
|
+
// PUTs carry an Idempotency-Key instead (see isKvRecordUrl), which the API
|
|
145
|
+
// de-duplicates, so they are re-sent after a lost answer (e.g. an edge 502).
|
|
143
146
|
const IDEMPOTENT_METHODS = new Set(['GET', 'HEAD', 'DELETE', 'OPTIONS']);
|
|
144
147
|
// HAProxy's own 503 page: no backend was available, so nothing was forwarded.
|
|
145
148
|
const NO_BACKEND_MARKER = 'No server is available to handle this request';
|
|
@@ -147,12 +150,13 @@ const CONNECT_FAILURE_CODES = new Set(['ECONNREFUSED', 'ENOTFOUND', 'EAI_AGAIN',
|
|
|
147
150
|
/**
|
|
148
151
|
* Whether a non-2xx answer is retried. 429 always: the run API limiter and
|
|
149
152
|
* dataset backpressure refuse it before anything is written. Idempotent
|
|
150
|
-
* methods: also 500/502/503/504. Others (dataset push, KV PUT
|
|
151
|
-
* that certainly was not processed: the rate limiter
|
|
152
|
-
* ("rate-limit-unavailable", before the handler),
|
|
153
|
-
*
|
|
154
|
-
*
|
|
155
|
-
*
|
|
153
|
+
* methods: also 500/502/503/504. Others (dataset push, KV PUT, named storage
|
|
154
|
+
* create) only on a 503 that certainly was not processed: the rate limiter
|
|
155
|
+
* refused it ("rate-limit-unavailable", before the handler), another request
|
|
156
|
+
* held the storage name ("storage-name-busy", nothing changed), or HAProxy had
|
|
157
|
+
* no backend (its own page, nothing forwarded). Any other 503 may mean
|
|
158
|
+
* "outcome unknown" (e.g. a direct dataset write whose commit was not
|
|
159
|
+
* confirmed), and re-sending could store the items twice.
|
|
156
160
|
*/
|
|
157
161
|
function isRetryableResponse(status, idempotent, errorType, bodyText) {
|
|
158
162
|
if (status === 429)
|
|
@@ -161,7 +165,9 @@ function isRetryableResponse(status, idempotent, errorType, bodyText) {
|
|
|
161
165
|
return status === 500 || status === 502 || status === 503 || status === 504;
|
|
162
166
|
if (status !== 503)
|
|
163
167
|
return false;
|
|
164
|
-
return errorType === 'rate-limit-unavailable'
|
|
168
|
+
return errorType === 'rate-limit-unavailable'
|
|
169
|
+
|| errorType === 'storage-name-busy'
|
|
170
|
+
|| (bodyText ?? '').includes(NO_BACKEND_MARKER);
|
|
165
171
|
}
|
|
166
172
|
/** Default time budget for retrying one API call (429, 503, network errors). */
|
|
167
173
|
exports.DEFAULT_API_RETRY_WINDOW_MS = 300_000;
|
|
@@ -177,20 +183,39 @@ const IDEMPOTENCY_SUPPORT_HEADER = 'x-scrapely-idempotency';
|
|
|
177
183
|
const keyedWritesDeduped = new Map();
|
|
178
184
|
/** Test override for every origin (undefined: learn from answers). */
|
|
179
185
|
let keyedWritesDedupedOverride;
|
|
180
|
-
|
|
186
|
+
/** A key-value store record URL (`.../key-value-stores/<id>/records/<key>`). */
|
|
187
|
+
function isKvRecordUrl(url) {
|
|
188
|
+
let path = url;
|
|
181
189
|
try {
|
|
182
|
-
|
|
190
|
+
path = new URL(url).pathname;
|
|
183
191
|
}
|
|
184
|
-
catch {
|
|
185
|
-
|
|
192
|
+
catch { /* a path already */ }
|
|
193
|
+
return path.includes('/key-value-stores/') && path.includes('/records/');
|
|
194
|
+
}
|
|
195
|
+
/** What is learned per API: its origin, separately for record PUTs and other keyed writes. */
|
|
196
|
+
function originOf(url) {
|
|
197
|
+
let origin = url;
|
|
198
|
+
try {
|
|
199
|
+
origin = new URL(url).origin;
|
|
186
200
|
}
|
|
201
|
+
catch { /* keep the input */ }
|
|
202
|
+
return isKvRecordUrl(url) ? `${origin}#kv-record` : origin;
|
|
187
203
|
}
|
|
188
|
-
/**
|
|
204
|
+
/**
|
|
205
|
+
* Whether the API at `url` (any URL of it; default: the last one seen) de-duplicates keyed writes.
|
|
206
|
+
* Record PUTs count as de-duplicated until a keyed record PUT was stored without the support
|
|
207
|
+
* header: re-applying the same body to the same record leaves the same record, so the worst
|
|
208
|
+
* case on an API that ignores the key is one extra counted write, while not re-sending fails the run.
|
|
209
|
+
*/
|
|
189
210
|
function apiDedupesKeyedWrites(url) {
|
|
190
211
|
if (keyedWritesDedupedOverride !== undefined)
|
|
191
212
|
return keyedWritesDedupedOverride;
|
|
192
|
-
if (url !== undefined)
|
|
193
|
-
|
|
213
|
+
if (url !== undefined) {
|
|
214
|
+
const learned = keyedWritesDeduped.get(originOf(url));
|
|
215
|
+
if (learned === undefined && isKvRecordUrl(url))
|
|
216
|
+
return true;
|
|
217
|
+
return learned === true;
|
|
218
|
+
}
|
|
194
219
|
return [...keyedWritesDeduped.values()].pop() === true;
|
|
195
220
|
}
|
|
196
221
|
/** Test hook: forget what was learned about the API, or force an answer for every origin. */
|
|
@@ -450,7 +475,8 @@ async function postStorageCollection(config, path, name) {
|
|
|
450
475
|
// Not marked repeatable even by name: a 500 after the create (e.g. scoped
|
|
451
476
|
// token registration failed) must surface, since a re-send would take the
|
|
452
477
|
// existing-name path and hide it. POST rules apply (limiter/HAProxy 503,
|
|
453
|
-
// refused
|
|
478
|
+
// "storage-name-busy" 503 while a parallel create holds the name, refused
|
|
479
|
+
// connection).
|
|
454
480
|
return new CollectionApiClient(config).call('POST', `${path}${query}`);
|
|
455
481
|
}
|
|
456
482
|
/**
|
|
@@ -788,10 +814,13 @@ class ApiKeyValueStoreClient extends BaseApiClient {
|
|
|
788
814
|
ct = 'application/json';
|
|
789
815
|
}
|
|
790
816
|
// Pass timeout/retry options from KeyValueStoreRecordOptions to the API request
|
|
791
|
-
|
|
792
|
-
|
|
793
|
-
|
|
794
|
-
|
|
817
|
+
// One Idempotency-Key for every attempt of this write: a re-send after a lost answer
|
|
818
|
+
// (edge 502/504, dropped connection) is applied and counted once.
|
|
819
|
+
const requestOptions = {
|
|
820
|
+
timeoutSecs: options?.timeoutSecs,
|
|
821
|
+
doNotRetryTimeouts: options?.doNotRetryTimeouts,
|
|
822
|
+
idempotencyKey: (0, node_crypto_1.randomUUID)(),
|
|
823
|
+
};
|
|
795
824
|
await this.request('PUT', `/key-value-stores/${this.id}/records/${(0, utils_js_1.pathSegment)(key)}`, body, contentType || ct, requestOptions);
|
|
796
825
|
}
|
|
797
826
|
async deleteRecord(key) {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "scrapely",
|
|
3
|
-
"version": "1.2.
|
|
3
|
+
"version": "1.2.38",
|
|
4
4
|
"description": "Scrapely SDK for building JavaScript and TypeScript Actors",
|
|
5
5
|
"main": "dist/index.js",
|
|
6
6
|
"types": "dist/index.d.ts",
|
|
@@ -69,12 +69,14 @@
|
|
|
69
69
|
"test:run-control": "npx tsx --test tests/run-control.test.ts",
|
|
70
70
|
"test:rq-list": "npx tsx --test tests/rq-list-requests.test.ts",
|
|
71
71
|
"test:terminal-status": "npx tsx --test tests/terminal-status-message.test.ts",
|
|
72
|
+
"test:fail-then-exit": "npx tsx --test tests/fail-then-exit.test.ts",
|
|
72
73
|
"test:cdp-retry": "npx tsx --test tests/cdp-retry.test.ts",
|
|
73
74
|
"test:proxy-input-aliases": "npx tsx --test tests/proxy-input-aliases.test.ts",
|
|
74
75
|
"test:charge-retries": "npx tsx --test tests/charge-retries.test.ts",
|
|
75
76
|
"test:solver": "npx tsx --test tests/solver.test.ts",
|
|
76
77
|
"test:lazy-preload": "npx tsx --test tests/lazy-preload.test.ts",
|
|
77
|
-
"test:v2-utils": "npx tsx --test tests/v2-utils.test.ts"
|
|
78
|
+
"test:v2-utils": "npx tsx --test tests/v2-utils.test.ts",
|
|
79
|
+
"test:default-api-url": "npx tsx --test tests/default-api-url.test.ts"
|
|
78
80
|
},
|
|
79
81
|
"author": "TeraShift GmbH (Scrape.ly)",
|
|
80
82
|
"license": "ISC AND Apache-2.0",
|
|
@@ -91,7 +93,7 @@
|
|
|
91
93
|
"lodash": "^4.18.1",
|
|
92
94
|
"mysql2": "^3.23.1",
|
|
93
95
|
"playwright": "^1.58.2",
|
|
94
|
-
"scrapely-client": "^2.25.
|
|
96
|
+
"scrapely-client": "^2.25.9",
|
|
95
97
|
"tar-fs": "2.1.5",
|
|
96
98
|
"ws": "^8.18.0"
|
|
97
99
|
},
|