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
|
-
/**
|
|
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
|
-
|
|
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
|
-
|
|
190
|
+
path = new URL(url).pathname;
|
|
186
191
|
}
|
|
187
|
-
catch {
|
|
188
|
-
|
|
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
|
-
/**
|
|
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
|
-
|
|
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
|
-
|
|
796
|
-
|
|
797
|
-
|
|
798
|
-
|
|
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.
|
|
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.
|
|
96
|
+
"scrapely-client": "^2.25.9",
|
|
96
97
|
"tar-fs": "2.1.5",
|
|
97
98
|
"ws": "^8.18.0"
|
|
98
99
|
},
|