scrapely 1.2.37 → 1.2.39
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 +8 -0
- package/dist/actor.js +3 -0
- package/dist/charging.d.ts +59 -2
- package/dist/charging.js +206 -18
- package/dist/events/events-client.js +3 -2
- package/dist/storage/api-storage-client.d.ts +8 -1
- package/dist/storage/api-storage-client.js +37 -12
- package/package.json +6 -4
package/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,14 @@ All notable changes to this project will be documented in this file.
|
|
|
4
4
|
|
|
5
5
|
## [Unreleased]
|
|
6
6
|
|
|
7
|
+
## [1.2.39] - 2026-10-29
|
|
8
|
+
|
|
9
|
+
- A refused charge names the limit that refused it. When a charge is refused or cut short, the platform now says why (HTTP 402 `error.data.reason`, and `limitedBy` on a partial acceptance): the run's item limit (`maxItems`), its maximum total charge (`maxTotalChargeUsd`) or the account balance. The SDK used to call all of them the spending limit. Now its log lines name the real limit; `ChargingManager.getChargeLimitReason()` returns `'max-items'`, `'max-charge'` or `'insufficient-funds'` and `getChargeLimitMessage()` the stop message ("Stopped: reached the item limit you set (800 items)", "Stopped: reached the maximum charge you set ($3.00)", "Stopped: your account has no credit left for this run"); and once a limit was reached, a status message (`Actor.setStatusMessage`, `Actor.exit`, `Actor.fail`) whose clause blames the maximum charge gets that clause replaced by the real stop message. Platforms that do not send the reason yet: inferred from the 402 message and `ACTOR_MAX_PAID_DATASET_ITEMS`. What is charged and saved does not change.
|
|
10
|
+
|
|
11
|
+
## [1.2.38] - 2026-10-27
|
|
12
|
+
|
|
13
|
+
- 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.
|
|
14
|
+
|
|
7
15
|
## [1.2.37] - 2026-10-26
|
|
8
16
|
|
|
9
17
|
- 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.
|
package/dist/actor.js
CHANGED
|
@@ -1157,6 +1157,9 @@ class Actor {
|
|
|
1157
1157
|
static async setStatusMessage(message, options) {
|
|
1158
1158
|
const { isStatusMessageTerminal, level } = options || {};
|
|
1159
1159
|
Actor._ensureActorInit('setStatusMessage');
|
|
1160
|
+
// Actors word a refused charge as "the maximum charge for this run was
|
|
1161
|
+
// reached" whatever refused it; name the limit that really stopped the run.
|
|
1162
|
+
message = (0, charging_js_2.truthfulStatusMessage)(message);
|
|
1160
1163
|
const loggedStatusMessage = `[Status message]: ${message}`;
|
|
1161
1164
|
switch (level) {
|
|
1162
1165
|
case 'DEBUG':
|
package/dist/charging.d.ts
CHANGED
|
@@ -21,14 +21,40 @@ export declare class ChargeRejectedError extends Error {
|
|
|
21
21
|
readonly count: number;
|
|
22
22
|
readonly statusCode?: number;
|
|
23
23
|
readonly reason: string;
|
|
24
|
+
/** Which run limit refused the charge, when the platform said (see ChargeLimitReason). */
|
|
25
|
+
readonly limitReason?: ChargeLimitReason;
|
|
26
|
+
readonly maxItems?: number;
|
|
24
27
|
constructor(message: string, opts: {
|
|
25
28
|
operationId: string;
|
|
26
29
|
eventName: string;
|
|
27
30
|
count: number;
|
|
28
31
|
statusCode?: number;
|
|
29
32
|
reason: string;
|
|
33
|
+
limitReason?: ChargeLimitReason;
|
|
34
|
+
maxItems?: number;
|
|
30
35
|
});
|
|
31
36
|
}
|
|
37
|
+
/**
|
|
38
|
+
* Which run limit refused or cut down a charge: the platform's `error.reason`
|
|
39
|
+
* on HTTP 402 and `limitedBy` on a partial acceptance. 'max-items' is the item
|
|
40
|
+
* limit the user set (maxItems), 'max-charge' the maximum total charge
|
|
41
|
+
* (maxTotalChargeUsd), 'insufficient-funds' the account balance. The run's
|
|
42
|
+
* stop message names it, so an item limit is never called a spending limit.
|
|
43
|
+
*/
|
|
44
|
+
export type ChargeLimitReason = 'max-items' | 'max-charge' | 'insufficient-funds';
|
|
45
|
+
/** The run's stop message for a charge limit, or undefined when the reason is unknown. */
|
|
46
|
+
export declare function chargeLimitStopMessage(reason: ChargeLimitReason | undefined, opts?: {
|
|
47
|
+
maxItems?: number | null;
|
|
48
|
+
maxTotalChargeUsd?: number | null;
|
|
49
|
+
}): string | undefined;
|
|
50
|
+
/**
|
|
51
|
+
* Replace the clause of `message` that blames the spending limit with
|
|
52
|
+
* `stopMessage`: "Saved 800 results; stopped because the maximum charge for
|
|
53
|
+
* this run was reached" becomes "Saved 800 results. Stopped: reached the item
|
|
54
|
+
* limit you set (800 items)". Only the first such clause is replaced (later
|
|
55
|
+
* ones are dropped); a message without one is returned as is.
|
|
56
|
+
*/
|
|
57
|
+
export declare function rewriteChargeLimitClaims(message: string, stopMessage: string | undefined): string;
|
|
32
58
|
/** @internal Register the manager whose limit guards default-dataset writes. */
|
|
33
59
|
export declare function setActiveChargingManager(manager: ChargingManager | undefined): void;
|
|
34
60
|
/** @internal The run's default dataset ID, as the platform passes it. */
|
|
@@ -39,6 +65,8 @@ export declare function defaultDatasetIdFromEnv(): string | undefined;
|
|
|
39
65
|
* clients for writes that did not go through `pushDataAndCharge`
|
|
40
66
|
* (`Actor.openDataset().pushData()`, crawler `pushData`, ...).
|
|
41
67
|
*/
|
|
68
|
+
/** `message` naming the limit that really stopped the run (Actor.setStatusMessage). */
|
|
69
|
+
export declare function truthfulStatusMessage(message: string): string;
|
|
42
70
|
export declare function refuseDefaultDatasetPush(itemCount: number): boolean;
|
|
43
71
|
export declare const APIFY_DEFAULT_DATASET_ITEM_EVENT = "apify-default-dataset-item";
|
|
44
72
|
/** Scrapely-native synthetic event for default-dataset items. */
|
|
@@ -74,6 +102,12 @@ export declare class ChargingManager {
|
|
|
74
102
|
private _closed;
|
|
75
103
|
/** Sticky: the platform refused or capped a charge (HTTP 402 / fewer accepted) or the local gate trimmed one. */
|
|
76
104
|
private chargeLimitHit;
|
|
105
|
+
/** Which limit that was (the first one known wins) and the item limit the platform reported. */
|
|
106
|
+
private chargeLimitReason;
|
|
107
|
+
private chargeLimitMaxItems;
|
|
108
|
+
/** limitedBy / maxItems of the last partially accepted charge response. */
|
|
109
|
+
private lastLimitedBy;
|
|
110
|
+
private lastLimitMaxItems;
|
|
77
111
|
/** Default-dataset rows refused after the limit (one warning, then a count). */
|
|
78
112
|
private refusedDefaultDatasetItems;
|
|
79
113
|
/**
|
|
@@ -124,10 +158,33 @@ export declare class ChargingManager {
|
|
|
124
158
|
*/
|
|
125
159
|
isChargeLimitReached(): boolean;
|
|
126
160
|
private markChargeLimitHit;
|
|
161
|
+
private markRejectedCharge;
|
|
162
|
+
/** The platform accepted fewer events than asked: record which limit cut it. */
|
|
163
|
+
private markPartialCharge;
|
|
164
|
+
/** The run's item limit: as the platform reported it, else ACTOR_MAX_PAID_DATASET_ITEMS. */
|
|
165
|
+
private maxItemsSetting;
|
|
166
|
+
/**
|
|
167
|
+
* Why this run's charges stopped being accepted, or undefined while they
|
|
168
|
+
* still are: 'max-items' (the item limit the user set), 'max-charge' (the
|
|
169
|
+
* maximum total charge) or 'insufficient-funds' (the account balance).
|
|
170
|
+
* Platforms that do not say which limit it was: inferred from the run's settings.
|
|
171
|
+
*/
|
|
172
|
+
getChargeLimitReason(): ChargeLimitReason | undefined;
|
|
173
|
+
/**
|
|
174
|
+
* The run's stop message for the limit that was reached, or undefined:
|
|
175
|
+
* "Stopped: reached the item limit you set (800 items)", "Stopped: reached
|
|
176
|
+
* the maximum charge you set ($3.00)", ... Use it as (part of) the final
|
|
177
|
+
* status message when the Actor stops because a charge was refused.
|
|
178
|
+
*/
|
|
179
|
+
getChargeLimitMessage(): string | undefined;
|
|
180
|
+
/** `message` with a clause blaming the spending limit replaced by the real reason (once a limit was reached). */
|
|
181
|
+
rewriteStatusMessage(message: string): string;
|
|
182
|
+
/** 'the item limit of this run (maxItems = 800)', ... for log lines. */
|
|
183
|
+
private limitName;
|
|
127
184
|
/** @internal Count rows dropped because of the limit; warn on the first one only. */
|
|
128
185
|
_noteRefusedDefaultDatasetItems(count: number): void;
|
|
129
|
-
/** @internal */
|
|
130
|
-
_markChargeLimitHit(): void;
|
|
186
|
+
/** @internal `reason`: the limit that trimmed the push, when known. */
|
|
187
|
+
_markChargeLimitHit(reason?: ChargeLimitReason): void;
|
|
131
188
|
/** @internal Whether every row of a push with these events is charged a positive price. */
|
|
132
189
|
_isPaidPerItem(eventName: string | undefined, isDefaultDataset: boolean): boolean;
|
|
133
190
|
/** @internal */
|
package/dist/charging.js
CHANGED
|
@@ -1,8 +1,11 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
3
|
exports.ChargingManager = exports.DEFAULT_DATASET_ITEM_EVENT = exports.SCRAPELY_DEFAULT_DATASET_ITEM_EVENT = exports.APIFY_DEFAULT_DATASET_ITEM_EVENT = exports.ChargeRejectedError = exports.ChargeUncertainError = exports.CHARGE_IDEMPOTENCY_SAFE_AGE_MS = void 0;
|
|
4
|
+
exports.chargeLimitStopMessage = chargeLimitStopMessage;
|
|
5
|
+
exports.rewriteChargeLimitClaims = rewriteChargeLimitClaims;
|
|
4
6
|
exports.setActiveChargingManager = setActiveChargingManager;
|
|
5
7
|
exports.defaultDatasetIdFromEnv = defaultDatasetIdFromEnv;
|
|
8
|
+
exports.truthfulStatusMessage = truthfulStatusMessage;
|
|
6
9
|
exports.refuseDefaultDatasetPush = refuseDefaultDatasetPush;
|
|
7
10
|
exports.isDefaultDatasetItemEvent = isDefaultDatasetItemEvent;
|
|
8
11
|
exports.resolveDefaultDatasetItemEventName = resolveDefaultDatasetItemEventName;
|
|
@@ -57,6 +60,9 @@ class ChargeRejectedError extends Error {
|
|
|
57
60
|
count;
|
|
58
61
|
statusCode;
|
|
59
62
|
reason;
|
|
63
|
+
/** Which run limit refused the charge, when the platform said (see ChargeLimitReason). */
|
|
64
|
+
limitReason;
|
|
65
|
+
maxItems;
|
|
60
66
|
constructor(message, opts) {
|
|
61
67
|
super(message);
|
|
62
68
|
this.name = 'ChargeRejectedError';
|
|
@@ -65,9 +71,99 @@ class ChargeRejectedError extends Error {
|
|
|
65
71
|
this.count = opts.count;
|
|
66
72
|
this.statusCode = opts.statusCode;
|
|
67
73
|
this.reason = opts.reason;
|
|
74
|
+
this.limitReason = opts.limitReason;
|
|
75
|
+
this.maxItems = opts.maxItems;
|
|
68
76
|
}
|
|
69
77
|
}
|
|
70
78
|
exports.ChargeRejectedError = ChargeRejectedError;
|
|
79
|
+
const CHARGE_LIMIT_REASONS = new Set(['max-items', 'max-charge', 'insufficient-funds']);
|
|
80
|
+
function asChargeLimitReason(raw) {
|
|
81
|
+
return typeof raw === 'string' && CHARGE_LIMIT_REASONS.has(raw) ? raw : undefined;
|
|
82
|
+
}
|
|
83
|
+
function positiveInt(raw) {
|
|
84
|
+
return typeof raw === 'number' && Number.isFinite(raw) && raw > 0 ? Math.floor(raw) : undefined;
|
|
85
|
+
}
|
|
86
|
+
/** $3.00, $0.005: two decimals unless the amount needs more. */
|
|
87
|
+
function formatUsd(usd) {
|
|
88
|
+
return Math.abs(usd * 100 - Math.round(usd * 100)) < 1e-9 ? `$${usd.toFixed(2)}` : `$${Number(usd.toPrecision(6))}`;
|
|
89
|
+
}
|
|
90
|
+
/** The run's stop message for a charge limit, or undefined when the reason is unknown. */
|
|
91
|
+
function chargeLimitStopMessage(reason, opts = {}) {
|
|
92
|
+
if (reason === 'max-items') {
|
|
93
|
+
const n = opts.maxItems;
|
|
94
|
+
return n && n > 0
|
|
95
|
+
? `Stopped: reached the item limit you set (${n} ${n === 1 ? 'item' : 'items'})`
|
|
96
|
+
: 'Stopped: reached the item limit you set';
|
|
97
|
+
}
|
|
98
|
+
if (reason === 'max-charge') {
|
|
99
|
+
const usd = opts.maxTotalChargeUsd;
|
|
100
|
+
return typeof usd === 'number' && Number.isFinite(usd)
|
|
101
|
+
? `Stopped: reached the maximum charge you set (${formatUsd(usd)})`
|
|
102
|
+
: 'Stopped: reached the maximum charge you set';
|
|
103
|
+
}
|
|
104
|
+
if (reason === 'insufficient-funds')
|
|
105
|
+
return 'Stopped: your account has no credit left for this run';
|
|
106
|
+
return undefined;
|
|
107
|
+
}
|
|
108
|
+
// A status-message clause that blames the spending limit ("stopped because the
|
|
109
|
+
// maximum charge for this run was reached", "The run reached its maximum total
|
|
110
|
+
// charge. Stopping.", ...). Actors written before the platform said which limit
|
|
111
|
+
// was hit use such texts for every refused charge.
|
|
112
|
+
const CHARGE_CLAIM_RE = /\bmax(?:imum)?\s+(?:total\s+)?charge|\bspending\s+limit/i;
|
|
113
|
+
const CLAUSE_SEPARATOR_RE = /(;\s*|[.!?](?:\s+|$))/;
|
|
114
|
+
/**
|
|
115
|
+
* Replace the clause of `message` that blames the spending limit with
|
|
116
|
+
* `stopMessage`: "Saved 800 results; stopped because the maximum charge for
|
|
117
|
+
* this run was reached" becomes "Saved 800 results. Stopped: reached the item
|
|
118
|
+
* limit you set (800 items)". Only the first such clause is replaced (later
|
|
119
|
+
* ones are dropped); a message without one is returned as is.
|
|
120
|
+
*/
|
|
121
|
+
function rewriteChargeLimitClaims(message, stopMessage) {
|
|
122
|
+
if (!message || !stopMessage || message.includes(stopMessage) || !CHARGE_CLAIM_RE.test(message))
|
|
123
|
+
return message;
|
|
124
|
+
const parts = message.split(CLAUSE_SEPARATOR_RE);
|
|
125
|
+
const out = [];
|
|
126
|
+
let replaced = false;
|
|
127
|
+
for (let i = 0; i < parts.length; i += 2) {
|
|
128
|
+
const clause = parts[i] ?? '';
|
|
129
|
+
const sep = parts[i + 1] ?? '';
|
|
130
|
+
if (!CHARGE_CLAIM_RE.test(clause)) {
|
|
131
|
+
out.push(clause + sep);
|
|
132
|
+
continue;
|
|
133
|
+
}
|
|
134
|
+
if (replaced)
|
|
135
|
+
continue;
|
|
136
|
+
replaced = true;
|
|
137
|
+
const prev = out.length ? out[out.length - 1].trimEnd() : '';
|
|
138
|
+
if (prev.endsWith(';'))
|
|
139
|
+
out[out.length - 1] = `${prev.slice(0, -1).trimEnd()}. `;
|
|
140
|
+
const lead = clause.slice(0, clause.length - clause.trimStart().length);
|
|
141
|
+
let tail = '';
|
|
142
|
+
if (sep)
|
|
143
|
+
tail = [';', '.', '!', '?'].includes(sep.trim()) ? `.${sep.slice(sep.trimEnd().length)}` : sep;
|
|
144
|
+
out.push(lead + stopMessage + tail);
|
|
145
|
+
}
|
|
146
|
+
let result = out.join('').trimEnd();
|
|
147
|
+
if (message.trimEnd().endsWith('.') && !result.endsWith('.'))
|
|
148
|
+
result += '.';
|
|
149
|
+
return result;
|
|
150
|
+
}
|
|
151
|
+
/** Which limit a 402 names: error.reason / error.data.reason, else its message (older platforms). */
|
|
152
|
+
function rejectionLimit(err, message) {
|
|
153
|
+
const e = err;
|
|
154
|
+
const data = e?.data && typeof e.data === 'object' ? e.data : undefined;
|
|
155
|
+
let limitReason = asChargeLimitReason(data?.reason) ?? asChargeLimitReason(e?.body?.error?.reason);
|
|
156
|
+
const maxItems = positiveInt(data?.maxItems) ?? positiveInt(e?.body?.maxItems);
|
|
157
|
+
if (!limitReason) {
|
|
158
|
+
if (/maxitems|maximum number of results/i.test(message))
|
|
159
|
+
limitReason = 'max-items';
|
|
160
|
+
else if (/insufficient balance/i.test(message))
|
|
161
|
+
limitReason = 'insufficient-funds';
|
|
162
|
+
else if (/maximum total charge/i.test(message))
|
|
163
|
+
limitReason = 'max-charge';
|
|
164
|
+
}
|
|
165
|
+
return { limitReason, maxItems };
|
|
166
|
+
}
|
|
71
167
|
/** True when a ChargeRejectedError means the run's spending limit is used up. */
|
|
72
168
|
function isChargeLimitRejection(err) {
|
|
73
169
|
return (err.statusCode === 402 ||
|
|
@@ -101,6 +197,10 @@ function defaultDatasetIdFromEnv() {
|
|
|
101
197
|
* clients for writes that did not go through `pushDataAndCharge`
|
|
102
198
|
* (`Actor.openDataset().pushData()`, crawler `pushData`, ...).
|
|
103
199
|
*/
|
|
200
|
+
/** `message` naming the limit that really stopped the run (Actor.setStatusMessage). */
|
|
201
|
+
function truthfulStatusMessage(message) {
|
|
202
|
+
return activeChargingManager ? activeChargingManager.rewriteStatusMessage(message) : message;
|
|
203
|
+
}
|
|
104
204
|
function refuseDefaultDatasetPush(itemCount) {
|
|
105
205
|
if (chargeGatedPush.getStore())
|
|
106
206
|
return false;
|
|
@@ -154,6 +254,12 @@ class ChargingManager {
|
|
|
154
254
|
_closed = false;
|
|
155
255
|
/** Sticky: the platform refused or capped a charge (HTTP 402 / fewer accepted) or the local gate trimmed one. */
|
|
156
256
|
chargeLimitHit = false;
|
|
257
|
+
/** Which limit that was (the first one known wins) and the item limit the platform reported. */
|
|
258
|
+
chargeLimitReason;
|
|
259
|
+
chargeLimitMaxItems;
|
|
260
|
+
/** limitedBy / maxItems of the last partially accepted charge response. */
|
|
261
|
+
lastLimitedBy;
|
|
262
|
+
lastLimitMaxItems;
|
|
157
263
|
/** Default-dataset rows refused after the limit (one warning, then a count). */
|
|
158
264
|
refusedDefaultDatasetItems = 0;
|
|
159
265
|
/**
|
|
@@ -214,7 +320,7 @@ class ChargingManager {
|
|
|
214
320
|
close() {
|
|
215
321
|
this._closed = true;
|
|
216
322
|
if (this.refusedDefaultDatasetItems > 0) {
|
|
217
|
-
console.warn(`[Scrapely] ${this.refusedDefaultDatasetItems} result(s) pushed to the default dataset after
|
|
323
|
+
console.warn(`[Scrapely] ${this.refusedDefaultDatasetItems} result(s) pushed to the default dataset after ${this.limitName()} was reached were not saved`);
|
|
218
324
|
}
|
|
219
325
|
}
|
|
220
326
|
/**
|
|
@@ -242,25 +348,104 @@ class ChargingManager {
|
|
|
242
348
|
// Same rounding as the budget gate, so 0.03 - 0.02 still pays a $0.01 event.
|
|
243
349
|
return this.calculateMaxChargesByPrice(Math.min(...prices)) <= 0;
|
|
244
350
|
}
|
|
245
|
-
markChargeLimitHit() {
|
|
246
|
-
if (this.isPayPerEvent)
|
|
247
|
-
|
|
351
|
+
markChargeLimitHit(reason, maxItems) {
|
|
352
|
+
if (!this.isPayPerEvent)
|
|
353
|
+
return;
|
|
354
|
+
this.chargeLimitHit = true;
|
|
355
|
+
if (reason && this.chargeLimitReason === undefined)
|
|
356
|
+
this.chargeLimitReason = reason;
|
|
357
|
+
if (reason === 'max-items' && maxItems !== undefined && maxItems > 0)
|
|
358
|
+
this.chargeLimitMaxItems = maxItems;
|
|
359
|
+
}
|
|
360
|
+
markRejectedCharge(err) {
|
|
361
|
+
if (isChargeLimitRejection(err))
|
|
362
|
+
this.markChargeLimitHit(err.limitReason, err.maxItems);
|
|
363
|
+
}
|
|
364
|
+
/** The platform accepted fewer events than asked: record which limit cut it. */
|
|
365
|
+
markPartialCharge() {
|
|
366
|
+
this.markChargeLimitHit(this.lastLimitedBy, this.lastLimitMaxItems);
|
|
367
|
+
}
|
|
368
|
+
/** The run's item limit: as the platform reported it, else ACTOR_MAX_PAID_DATASET_ITEMS. */
|
|
369
|
+
maxItemsSetting() {
|
|
370
|
+
if (this.chargeLimitMaxItems !== undefined)
|
|
371
|
+
return this.chargeLimitMaxItems;
|
|
372
|
+
const raw = Number(process.env.ACTOR_MAX_PAID_DATASET_ITEMS ?? process.env.APIFY_ACTOR_MAX_PAID_DATASET_ITEMS);
|
|
373
|
+
return Number.isFinite(raw) && raw > 0 ? Math.floor(raw) : undefined;
|
|
374
|
+
}
|
|
375
|
+
/**
|
|
376
|
+
* Why this run's charges stopped being accepted, or undefined while they
|
|
377
|
+
* still are: 'max-items' (the item limit the user set), 'max-charge' (the
|
|
378
|
+
* maximum total charge) or 'insufficient-funds' (the account balance).
|
|
379
|
+
* Platforms that do not say which limit it was: inferred from the run's settings.
|
|
380
|
+
*/
|
|
381
|
+
getChargeLimitReason() {
|
|
382
|
+
if (!this.isChargeLimitReached())
|
|
383
|
+
return undefined;
|
|
384
|
+
if (this.chargeLimitReason)
|
|
385
|
+
return this.chargeLimitReason;
|
|
386
|
+
const maxItems = this.maxItemsSetting();
|
|
387
|
+
if (maxItems !== undefined &&
|
|
388
|
+
Object.entries(this.chargingState ?? {}).some(([name, state]) => !isSyntheticEvent(name) && (this.pricingInfo[name]?.price ?? 0) > 0 && state.chargeCount >= maxItems)) {
|
|
389
|
+
return 'max-items';
|
|
390
|
+
}
|
|
391
|
+
if (Number.isFinite(this.maxTotalChargeUsd)) {
|
|
392
|
+
const prices = Object.values(this.pricingInfo).map(({ price }) => price).filter((price) => price > 0);
|
|
393
|
+
if (prices.length > 0 && this.calculateMaxChargesByPrice(Math.min(...prices)) <= 0)
|
|
394
|
+
return 'max-charge';
|
|
395
|
+
}
|
|
396
|
+
return undefined;
|
|
397
|
+
}
|
|
398
|
+
/**
|
|
399
|
+
* The run's stop message for the limit that was reached, or undefined:
|
|
400
|
+
* "Stopped: reached the item limit you set (800 items)", "Stopped: reached
|
|
401
|
+
* the maximum charge you set ($3.00)", ... Use it as (part of) the final
|
|
402
|
+
* status message when the Actor stops because a charge was refused.
|
|
403
|
+
*/
|
|
404
|
+
getChargeLimitMessage() {
|
|
405
|
+
return chargeLimitStopMessage(this.getChargeLimitReason(), {
|
|
406
|
+
maxItems: this.maxItemsSetting(),
|
|
407
|
+
maxTotalChargeUsd: Number.isFinite(this.maxTotalChargeUsd) ? this.maxTotalChargeUsd : null,
|
|
408
|
+
});
|
|
409
|
+
}
|
|
410
|
+
/** `message` with a clause blaming the spending limit replaced by the real reason (once a limit was reached). */
|
|
411
|
+
rewriteStatusMessage(message) {
|
|
412
|
+
try {
|
|
413
|
+
return rewriteChargeLimitClaims(message, this.getChargeLimitMessage());
|
|
414
|
+
}
|
|
415
|
+
catch {
|
|
416
|
+
return message;
|
|
417
|
+
}
|
|
418
|
+
}
|
|
419
|
+
/** 'the item limit of this run (maxItems = 800)', ... for log lines. */
|
|
420
|
+
limitName() {
|
|
421
|
+
const reason = this.getChargeLimitReason();
|
|
422
|
+
if (reason === 'max-items') {
|
|
423
|
+
const n = this.maxItemsSetting();
|
|
424
|
+
return n ? `the item limit of this run (maxItems = ${n})` : 'the item limit of this run (maxItems)';
|
|
425
|
+
}
|
|
426
|
+
if (reason === 'insufficient-funds')
|
|
427
|
+
return 'the end of the account balance';
|
|
428
|
+
if (reason === 'max-charge' || Number.isFinite(this.maxTotalChargeUsd)) {
|
|
429
|
+
const limit = Number.isFinite(this.maxTotalChargeUsd) ? `$${this.maxTotalChargeUsd}` : 'the limit';
|
|
430
|
+
return `the spending limit of this run (maxTotalChargeUsd = ${limit})`;
|
|
431
|
+
}
|
|
432
|
+
return 'the limit of this run';
|
|
248
433
|
}
|
|
249
434
|
/** @internal Count rows dropped because of the limit; warn on the first one only. */
|
|
250
435
|
_noteRefusedDefaultDatasetItems(count) {
|
|
251
436
|
if (count <= 0)
|
|
252
437
|
return;
|
|
253
438
|
if (this.refusedDefaultDatasetItems === 0) {
|
|
254
|
-
const
|
|
255
|
-
console.warn(`[Scrapely]
|
|
439
|
+
const name = this.limitName();
|
|
440
|
+
console.warn(`[Scrapely] ${name[0].toUpperCase()}${name.slice(1)} has been reached: ` +
|
|
256
441
|
'results pushed to the default dataset are no longer saved. The Actor should stop now ' +
|
|
257
442
|
'(check ChargeResult.eventChargeLimitReached or Actor.getChargingManager().isChargeLimitReached()).');
|
|
258
443
|
}
|
|
259
444
|
this.refusedDefaultDatasetItems += count;
|
|
260
445
|
}
|
|
261
|
-
/** @internal */
|
|
262
|
-
_markChargeLimitHit() {
|
|
263
|
-
this.markChargeLimitHit();
|
|
446
|
+
/** @internal `reason`: the limit that trimmed the push, when known. */
|
|
447
|
+
_markChargeLimitHit(reason) {
|
|
448
|
+
this.markChargeLimitHit(reason);
|
|
264
449
|
}
|
|
265
450
|
/** @internal Whether every row of a push with these events is charged a positive price. */
|
|
266
451
|
_isPaidPerItem(eventName, isDefaultDataset) {
|
|
@@ -728,6 +913,8 @@ class ChargingManager {
|
|
|
728
913
|
const accepted = readAcceptedCount(body, intent.count);
|
|
729
914
|
const remaining = body.remainingBudgetNd;
|
|
730
915
|
this.lastRemainingBudgetNd = typeof remaining === 'number' && Number.isFinite(remaining) && remaining >= 0 ? remaining : null;
|
|
916
|
+
this.lastLimitedBy = asChargeLimitReason(body.limitedBy);
|
|
917
|
+
this.lastLimitMaxItems = positiveInt(body.maxItems);
|
|
731
918
|
return accepted;
|
|
732
919
|
}
|
|
733
920
|
catch (err) {
|
|
@@ -966,7 +1153,7 @@ class ChargingManager {
|
|
|
966
1153
|
const accepted = await this.postChargeWithRetries(intent, deadline);
|
|
967
1154
|
if (accepted !== null) {
|
|
968
1155
|
if (accepted < intent.count)
|
|
969
|
-
this.
|
|
1156
|
+
this.markPartialCharge();
|
|
970
1157
|
const eventPrice = this.getEventPricing(intent.eventName).price;
|
|
971
1158
|
this.applyAcceptedCount(intent.eventName, intent.count, accepted, eventPrice);
|
|
972
1159
|
this.pendingCharges.delete(intent.idempotencyKey);
|
|
@@ -975,8 +1162,7 @@ class ChargingManager {
|
|
|
975
1162
|
}
|
|
976
1163
|
catch (err) {
|
|
977
1164
|
if (err instanceof ChargeRejectedError) {
|
|
978
|
-
|
|
979
|
-
this.markChargeLimitHit();
|
|
1165
|
+
this.markRejectedCharge(err);
|
|
980
1166
|
const eventPrice = this.getEventPricing(intent.eventName).price;
|
|
981
1167
|
this.applyAcceptedCount(intent.eventName, intent.count, 0, eventPrice);
|
|
982
1168
|
this.pendingCharges.delete(intent.idempotencyKey);
|
|
@@ -1049,8 +1235,9 @@ class ChargingManager {
|
|
|
1049
1235
|
}
|
|
1050
1236
|
return 0;
|
|
1051
1237
|
})();
|
|
1238
|
+
// The local budget gate: only maxTotalChargeUsd trims here.
|
|
1052
1239
|
if (chargedCount < count)
|
|
1053
|
-
this.markChargeLimitHit();
|
|
1240
|
+
this.markChargeLimitHit('max-charge');
|
|
1054
1241
|
if (chargedCount === 0) {
|
|
1055
1242
|
return {
|
|
1056
1243
|
eventChargeLimitReached: count > 0,
|
|
@@ -1093,8 +1280,7 @@ class ChargingManager {
|
|
|
1093
1280
|
}
|
|
1094
1281
|
catch (err) {
|
|
1095
1282
|
if (err instanceof ChargeRejectedError) {
|
|
1096
|
-
|
|
1097
|
-
this.markChargeLimitHit();
|
|
1283
|
+
this.markRejectedCharge(err);
|
|
1098
1284
|
// Server definitively rejected: unwind local reservation.
|
|
1099
1285
|
this.pendingCharges.delete(intent.idempotencyKey);
|
|
1100
1286
|
chargedCount = this.applyAcceptedCount(normalizedEventName, chargedCount, 0, eventPricing.price);
|
|
@@ -1112,7 +1298,7 @@ class ChargingManager {
|
|
|
1112
1298
|
this.lastAcceptedOperationId = intent.idempotencyKey;
|
|
1113
1299
|
// The platform capped the charge at the run's limit.
|
|
1114
1300
|
if (accepted < chargedCount)
|
|
1115
|
-
this.
|
|
1301
|
+
this.markPartialCharge();
|
|
1116
1302
|
chargedCount = this.applyAcceptedCount(normalizedEventName, chargedCount, accepted, eventPricing.price);
|
|
1117
1303
|
this.syncPlatformBudget();
|
|
1118
1304
|
}
|
|
@@ -1143,7 +1329,7 @@ class ChargingManager {
|
|
|
1143
1329
|
}
|
|
1144
1330
|
if (chargedCount < count) {
|
|
1145
1331
|
const subject = count === 1 ? 'instance' : 'instances';
|
|
1146
|
-
console.log(`[Scrapely] Charging ${count} ${subject} of '${normalizedEventName}' event would exceed
|
|
1332
|
+
console.log(`[Scrapely] Charging ${count} ${subject} of '${normalizedEventName}' event would exceed ${this.limitName()} - only ${chargedCount} events were charged`);
|
|
1147
1333
|
}
|
|
1148
1334
|
return {
|
|
1149
1335
|
eventChargeLimitReached: this.calculateMaxEventChargeCountWithinLimit(normalizedEventName) <= 0,
|
|
@@ -1197,6 +1383,7 @@ function classifyChargeHttpError(err, intent) {
|
|
|
1197
1383
|
count: intent.count,
|
|
1198
1384
|
statusCode: status,
|
|
1199
1385
|
reason: status === 402 ? 'charge-limit-exceeded' : `http-${status}`,
|
|
1386
|
+
...(status === 402 ? rejectionLimit(err, String(err?.message ?? '')) : {}),
|
|
1200
1387
|
});
|
|
1201
1388
|
}
|
|
1202
1389
|
// A 5xx / 408 / 429 answer is never a definitive rejection, whatever its text
|
|
@@ -1213,6 +1400,7 @@ function classifyChargeHttpError(err, intent) {
|
|
|
1213
1400
|
count: intent.count,
|
|
1214
1401
|
statusCode: status,
|
|
1215
1402
|
reason: 'message-match',
|
|
1403
|
+
...(/charge-limit-exceeded/i.test(message) ? rejectionLimit(err, message) : {}),
|
|
1216
1404
|
});
|
|
1217
1405
|
}
|
|
1218
1406
|
return null;
|
|
@@ -1302,7 +1490,7 @@ async function pushDataAndCharge({ chargingManager, items, eventName, isDefaultD
|
|
|
1302
1490
|
const allItemsTrimmed = itemsArray.length > 0 && limitedItems.length === 0;
|
|
1303
1491
|
if (isDefaultDataset && limitedItems.length < itemsArray.length) {
|
|
1304
1492
|
// Rows beyond what the budget can pay for are dropped.
|
|
1305
|
-
chargingManager._markChargeLimitHit?.();
|
|
1493
|
+
chargingManager._markChargeLimitHit?.('max-charge');
|
|
1306
1494
|
chargingManager._noteRefusedDefaultDatasetItems?.(itemsArray.length - limitedItems.length);
|
|
1307
1495
|
}
|
|
1308
1496
|
if (limitedItems.length === 0) {
|
|
@@ -31,8 +31,9 @@ function connectEvents(eventsWsUrl, token, runId) {
|
|
|
31
31
|
return;
|
|
32
32
|
shouldReconnect = true;
|
|
33
33
|
try {
|
|
34
|
-
|
|
35
|
-
|
|
34
|
+
// The token goes in a header, not the URL: URLs end up in access logs.
|
|
35
|
+
const url = `${eventsWsUrl}?runId=${encodeURIComponent(runId)}`;
|
|
36
|
+
ws = new ws_1.default(url, { headers: { Authorization: `Bearer ${token}` } });
|
|
36
37
|
ws.onopen = () => {
|
|
37
38
|
connected = true;
|
|
38
39
|
console.log(`[Scrapely Events] Connected to ${eventsWsUrl}`);
|
|
@@ -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.39",
|
|
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
|
},
|
|
@@ -130,6 +131,7 @@
|
|
|
130
131
|
"@inquirer/external-editor": "3.0.3",
|
|
131
132
|
"nanoid": "5.1.16",
|
|
132
133
|
"browserslist": "4.28.7",
|
|
133
|
-
"basic-ftp": "^6.2.3"
|
|
134
|
+
"basic-ftp": "^6.2.3",
|
|
135
|
+
"@asamuzakjp/css-color": "^7.0.1"
|
|
134
136
|
}
|
|
135
137
|
}
|