scrapely 1.2.35 → 1.2.37

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.37] - 2026-10-26
8
+
9
+ - 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.
10
+ - 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.
11
+
12
+ ## [1.2.36] - 2026-10-22
13
+
14
+ - 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`).
15
+
5
16
  ## [1.2.35] - 2026-10-20
6
17
 
7
18
  - The storage client percent-encodes request ids and key-value store record keys as one URL path segment and refuses `''`, `.` and `..`. A request id such as `..` read from a queue used to send the call to another resource.
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>;
@@ -459,7 +466,15 @@ export declare class Actor {
459
466
  */
460
467
  static setValue<T>(key: string, value: T | null, options?: RecordOptions): Promise<void>;
461
468
  private static _debugCaptureCount;
469
+ /**
470
+ * A payload up to DEBUG_CAPTURE_MAX_BYTES characters is stored as it is; a larger one gzip-compressed
471
+ * (base64, at most DEBUG_CAPTURE_MAX_BYTES characters again), keeping up to DEBUG_CAPTURE_MAX_CHARS
472
+ * characters / DEBUG_CAPTURE_MAX_ENCODED bytes of it. A plain cut at 256 KiB lost the part a check
473
+ * needed when a large API document starts with site navigation.
474
+ */
462
475
  static readonly DEBUG_CAPTURE_MAX_BYTES: number;
476
+ static readonly DEBUG_CAPTURE_MAX_CHARS: number;
477
+ static readonly DEBUG_CAPTURE_MAX_ENCODED: number;
463
478
  static readonly DEBUG_CAPTURE_MAX_RECORDS = 500;
464
479
  /** The key-value store record name for a capture key (item URL or id). */
465
480
  static debugCaptureRecordKey(key: string): Promise<string>;
@@ -471,6 +486,8 @@ export declare class Actor {
471
486
  /** debugCapture for synchronous code: fire and forget. Does nothing unless capture is enabled for the run. */
472
487
  static debugCaptureSync(key: string, payload: unknown): void;
473
488
  static debugCapture(key: string, payload: unknown): Promise<void>;
489
+ /** The record for one capture: plain when small, else gzip-base64 (plain head as the last resort). */
490
+ static debugCaptureRecord(key: string, text: string): Promise<Record<string, unknown>>;
474
491
  /**
475
492
  * Helper to resolve storage identifier to a string ID or name.
476
493
  * Supports: string, { id: '...' }, { name: '...' }, { alias: '...' }
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');
@@ -660,7 +686,15 @@ class Actor {
660
686
  await store.setValue(key, value, options);
661
687
  }
662
688
  static _debugCaptureCount = 0;
689
+ /**
690
+ * A payload up to DEBUG_CAPTURE_MAX_BYTES characters is stored as it is; a larger one gzip-compressed
691
+ * (base64, at most DEBUG_CAPTURE_MAX_BYTES characters again), keeping up to DEBUG_CAPTURE_MAX_CHARS
692
+ * characters / DEBUG_CAPTURE_MAX_ENCODED bytes of it. A plain cut at 256 KiB lost the part a check
693
+ * needed when a large API document starts with site navigation.
694
+ */
663
695
  static DEBUG_CAPTURE_MAX_BYTES = 256 * 1024;
696
+ static DEBUG_CAPTURE_MAX_CHARS = 8 * 1024 * 1024;
697
+ static DEBUG_CAPTURE_MAX_ENCODED = 8 * 1024 * 1024;
664
698
  static DEBUG_CAPTURE_MAX_RECORDS = 500;
665
699
  /** The key-value store record name for a capture key (item URL or id). */
666
700
  static async debugCaptureRecordKey(key) {
@@ -684,14 +718,32 @@ class Actor {
684
718
  if (Actor._debugCaptureCount >= Actor.DEBUG_CAPTURE_MAX_RECORDS)
685
719
  return;
686
720
  try {
687
- const text = (typeof payload === 'string' ? payload : JSON.stringify(payload) ?? '').slice(0, Actor.DEBUG_CAPTURE_MAX_BYTES);
721
+ const text = typeof payload === 'string' ? payload
722
+ : payload instanceof Uint8Array ? Buffer.from(payload.buffer, payload.byteOffset, payload.byteLength).toString('utf8')
723
+ : JSON.stringify(payload) ?? '';
724
+ const record = await Actor.debugCaptureRecord(String(key), text);
688
725
  Actor._debugCaptureCount++;
689
- await Actor.setValue(await Actor.debugCaptureRecordKey(key), { key: String(key), payload: text });
726
+ await Actor.setValue(await Actor.debugCaptureRecordKey(key), record);
690
727
  }
691
728
  catch {
692
729
  // never break the run
693
730
  }
694
731
  }
732
+ /** The record for one capture: plain when small, else gzip-base64 (plain head as the last resort). */
733
+ static async debugCaptureRecord(key, text) {
734
+ const limit = Actor.DEBUG_CAPTURE_MAX_BYTES;
735
+ if (text.length <= limit)
736
+ return { key, payload: text };
737
+ const { gzipSync } = await import('node:zlib');
738
+ const raw = Buffer.from(text.slice(0, Actor.DEBUG_CAPTURE_MAX_CHARS), 'utf8');
739
+ const kept = raw.subarray(0, Actor.DEBUG_CAPTURE_MAX_ENCODED);
740
+ const packed = gzipSync(kept, { level: 6 }).toString('base64');
741
+ if (packed.length <= limit) {
742
+ const truncated = text.length > Actor.DEBUG_CAPTURE_MAX_CHARS || kept.length < raw.length;
743
+ return { key, payload: packed, encoding: 'gzip-base64', length: text.length, truncated };
744
+ }
745
+ return { key, payload: text.slice(0, limit), length: text.length, truncated: true };
746
+ }
695
747
  /**
696
748
  * Helper to resolve storage identifier to a string ID or name.
697
749
  * Supports: string, { id: '...' }, { name: '...' }, { alias: '...' }
@@ -15,12 +15,13 @@ 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). */
@@ -147,12 +147,13 @@ const CONNECT_FAILURE_CODES = new Set(['ECONNREFUSED', 'ENOTFOUND', 'EAI_AGAIN',
147
147
  /**
148
148
  * Whether a non-2xx answer is retried. 429 always: the run API limiter and
149
149
  * 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.
150
+ * methods: also 500/502/503/504. Others (dataset push, KV PUT, named storage
151
+ * create) only on a 503 that certainly was not processed: the rate limiter
152
+ * refused it ("rate-limit-unavailable", before the handler), another request
153
+ * held the storage name ("storage-name-busy", nothing changed), or HAProxy had
154
+ * no backend (its own page, nothing forwarded). Any other 503 may mean
155
+ * "outcome unknown" (e.g. a direct dataset write whose commit was not
156
+ * confirmed), and re-sending could store the items twice.
156
157
  */
157
158
  function isRetryableResponse(status, idempotent, errorType, bodyText) {
158
159
  if (status === 429)
@@ -161,7 +162,9 @@ function isRetryableResponse(status, idempotent, errorType, bodyText) {
161
162
  return status === 500 || status === 502 || status === 503 || status === 504;
162
163
  if (status !== 503)
163
164
  return false;
164
- return errorType === 'rate-limit-unavailable' || (bodyText ?? '').includes(NO_BACKEND_MARKER);
165
+ return errorType === 'rate-limit-unavailable'
166
+ || errorType === 'storage-name-busy'
167
+ || (bodyText ?? '').includes(NO_BACKEND_MARKER);
165
168
  }
166
169
  /** Default time budget for retrying one API call (429, 503, network errors). */
167
170
  exports.DEFAULT_API_RETRY_WINDOW_MS = 300_000;
@@ -450,7 +453,8 @@ async function postStorageCollection(config, path, name) {
450
453
  // Not marked repeatable even by name: a 500 after the create (e.g. scoped
451
454
  // token registration failed) must surface, since a re-send would take the
452
455
  // existing-name path and hide it. POST rules apply (limiter/HAProxy 503,
453
- // refused connection).
456
+ // "storage-name-busy" 503 while a parallel create holds the name, refused
457
+ // connection).
454
458
  return new CollectionApiClient(config).call('POST', `${path}${query}`);
455
459
  }
456
460
  /**
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "scrapely",
3
- "version": "1.2.35",
3
+ "version": "1.2.37",
4
4
  "description": "Scrapely SDK for building JavaScript and TypeScript Actors",
5
5
  "main": "dist/index.js",
6
6
  "types": "dist/index.d.ts",
@@ -48,6 +48,7 @@
48
48
  "test:charge-platform-budget": "npx tsx --test tests/charge-platform-budget.test.ts",
49
49
  "test:storage-retries": "npx tsx --test tests/storage-retries.test.ts",
50
50
  "test:storage-path-segments": "npx tsx --test tests/storage-path-segments.test.ts",
51
+ "test:storage-record-keys": "npx tsx --test tests/storage-record-keys.test.ts",
51
52
  "test:push-idempotency": "npx tsx --test tests/push-idempotency.test.ts",
52
53
  "test:stdio-drain": "npx tsx --test tests/stdio-drain.test.ts",
53
54
  "test:charging-client": "npx tsx --test tests/charging-client.test.ts",
@@ -68,6 +69,7 @@
68
69
  "test:run-control": "npx tsx --test tests/run-control.test.ts",
69
70
  "test:rq-list": "npx tsx --test tests/rq-list-requests.test.ts",
70
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",
71
73
  "test:cdp-retry": "npx tsx --test tests/cdp-retry.test.ts",
72
74
  "test:proxy-input-aliases": "npx tsx --test tests/proxy-input-aliases.test.ts",
73
75
  "test:charge-retries": "npx tsx --test tests/charge-retries.test.ts",