scrapely 1.2.37 → 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
@@ -4,6 +4,10 @@ All notable changes to this project will be documented in this file.
4
4
 
5
5
  ## [Unreleased]
6
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
+
7
11
  ## [1.2.37] - 2026-10-26
8
12
 
9
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.
@@ -26,7 +26,14 @@ export declare class ApiStorageClientError extends Error {
26
26
  export declare function isRetryableResponse(status: number, idempotent: boolean, errorType?: string, bodyText?: string): boolean;
27
27
  /** Default time budget for retrying one API call (429, 503, network errors). */
28
28
  export declare const DEFAULT_API_RETRY_WINDOW_MS = 300000;
29
- /** 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
+ */
30
37
  export declare function apiDedupesKeyedWrites(url?: string): boolean;
31
38
  /** Test hook: forget what was learned about the API, or force an answer for every origin. */
32
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';
@@ -180,20 +183,39 @@ const IDEMPOTENCY_SUPPORT_HEADER = 'x-scrapely-idempotency';
180
183
  const keyedWritesDeduped = new Map();
181
184
  /** Test override for every origin (undefined: learn from answers). */
182
185
  let keyedWritesDedupedOverride;
183
- function originOf(url) {
186
+ /** A key-value store record URL (`.../key-value-stores/<id>/records/<key>`). */
187
+ function isKvRecordUrl(url) {
188
+ let path = url;
184
189
  try {
185
- return new URL(url).origin;
190
+ path = new URL(url).pathname;
186
191
  }
187
- catch {
188
- 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;
189
200
  }
201
+ catch { /* keep the input */ }
202
+ return isKvRecordUrl(url) ? `${origin}#kv-record` : origin;
190
203
  }
191
- /** 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
+ */
192
210
  function apiDedupesKeyedWrites(url) {
193
211
  if (keyedWritesDedupedOverride !== undefined)
194
212
  return keyedWritesDedupedOverride;
195
- if (url !== undefined)
196
- 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
+ }
197
219
  return [...keyedWritesDeduped.values()].pop() === true;
198
220
  }
199
221
  /** Test hook: forget what was learned about the API, or force an answer for every origin. */
@@ -792,10 +814,13 @@ class ApiKeyValueStoreClient extends BaseApiClient {
792
814
  ct = 'application/json';
793
815
  }
794
816
  // Pass timeout/retry options from KeyValueStoreRecordOptions to the API request
795
- const requestOptions = options ? {
796
- timeoutSecs: options.timeoutSecs,
797
- doNotRetryTimeouts: options.doNotRetryTimeouts,
798
- } : 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
+ };
799
824
  await this.request('PUT', `/key-value-stores/${this.id}/records/${(0, utils_js_1.pathSegment)(key)}`, body, contentType || ct, requestOptions);
800
825
  }
801
826
  async deleteRecord(key) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "scrapely",
3
- "version": "1.2.37",
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",
@@ -75,7 +75,8 @@
75
75
  "test:charge-retries": "npx tsx --test tests/charge-retries.test.ts",
76
76
  "test:solver": "npx tsx --test tests/solver.test.ts",
77
77
  "test:lazy-preload": "npx tsx --test tests/lazy-preload.test.ts",
78
- "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"
79
80
  },
80
81
  "author": "TeraShift GmbH (Scrape.ly)",
81
82
  "license": "ISC AND Apache-2.0",
@@ -92,7 +93,7 @@
92
93
  "lodash": "^4.18.1",
93
94
  "mysql2": "^3.23.1",
94
95
  "playwright": "^1.58.2",
95
- "scrapely-client": "^2.25.2",
96
+ "scrapely-client": "^2.25.9",
96
97
  "tar-fs": "2.1.5",
97
98
  "ws": "^8.18.0"
98
99
  },