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 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
- process.exit(options?.exitCode ?? 0);
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
- await Actor.exit(opts);
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) 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.
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
- /** Whether the API at `url` (any URL of it; default: the last one seen) de-duplicates keyed writes. */
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) only on a 503
151
- * that certainly was not processed: the rate limiter refused it
152
- * ("rate-limit-unavailable", before the handler), or HAProxy had no backend
153
- * (its own page, nothing forwarded). Any other 503 may mean "outcome unknown"
154
- * (e.g. a direct dataset write whose commit was not confirmed), and re-sending
155
- * could store the items twice.
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' || (bodyText ?? '').includes(NO_BACKEND_MARKER);
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
- function originOf(url) {
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
- return new URL(url).origin;
190
+ path = new URL(url).pathname;
183
191
  }
184
- catch {
185
- return url;
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
- /** Whether the API at `url` (any URL of it; default: the last one seen) de-duplicates keyed writes. */
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
- return keyedWritesDeduped.get(originOf(url)) === true;
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 connection).
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
- const requestOptions = options ? {
792
- timeoutSecs: options.timeoutSecs,
793
- doNotRetryTimeouts: options.doNotRetryTimeouts,
794
- } : undefined;
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.36",
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.2",
96
+ "scrapely-client": "^2.25.9",
95
97
  "tar-fs": "2.1.5",
96
98
  "ws": "^8.18.0"
97
99
  },