scrapely 1.2.8 → 1.2.10
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/dist/actor.d.ts +5 -1
- package/dist/actor.js +42 -7
- package/dist/charging.d.ts +32 -0
- package/dist/charging.js +195 -10
- package/package.json +3 -1
package/dist/actor.d.ts
CHANGED
|
@@ -371,7 +371,9 @@ export declare class Actor {
|
|
|
371
371
|
static getInputOrThrow<T = ActorInput>(): Promise<T>;
|
|
372
372
|
/**
|
|
373
373
|
* Synchronously get the actor input (cached from first access).
|
|
374
|
-
*
|
|
374
|
+
* The platform sets ACTOR_INPUT only for small inputs; for large ones this
|
|
375
|
+
* returns the input once `await Actor.getInput()` has loaded it from the
|
|
376
|
+
* default key-value store.
|
|
375
377
|
*/
|
|
376
378
|
static get input(): Record<string, any> | undefined;
|
|
377
379
|
/**
|
|
@@ -701,3 +703,5 @@ export declare class Actor {
|
|
|
701
703
|
enqueueLinks: typeof import("./utils.js").enqueueLinks;
|
|
702
704
|
};
|
|
703
705
|
}
|
|
706
|
+
/** Resolves once stdout and stderr have flushed their buffers, or after `maxMs`. */
|
|
707
|
+
export declare function waitForStdioDrain(maxMs: number, streams?: NodeJS.WritableStream[]): Promise<void>;
|
package/dist/actor.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
3
|
exports.Actor = exports.EXIT_CODES = exports.ACTOR_PERMISSION_LEVEL = void 0;
|
|
4
|
+
exports.waitForStdioDrain = waitForStdioDrain;
|
|
4
5
|
require("./log-env.js");
|
|
5
6
|
const node_events_1 = require("node:events");
|
|
6
7
|
/** Actor permission level - matches Apify's ACTOR_PERMISSION_LEVEL enum */
|
|
@@ -172,6 +173,7 @@ class Actor {
|
|
|
172
173
|
async init(options = {}) {
|
|
173
174
|
if (this.initialized)
|
|
174
175
|
return;
|
|
176
|
+
cachedInput = undefined; // a fresh init reads this run's input again
|
|
175
177
|
// NOTE: `initialized` is set at the END of init — a failed platform init
|
|
176
178
|
// must stay retryable instead of leaving a half-initialized Actor.
|
|
177
179
|
Actor.installFatalChargeHandlers();
|
|
@@ -410,6 +412,13 @@ class Actor {
|
|
|
410
412
|
const metamorphKey = `INPUT-METAMORPH-${count}`;
|
|
411
413
|
const metamorphInput = await store.getValue(metamorphKey);
|
|
412
414
|
if (metamorphInput !== null && metamorphInput !== undefined) {
|
|
415
|
+
// Same treatment as the regular input: decrypt secret fields
|
|
416
|
+
// and let the synchronous Actor.input getter return it.
|
|
417
|
+
if (typeof metamorphInput === 'object' && !Array.isArray(metamorphInput)) {
|
|
418
|
+
const decrypted = (0, secrets_js_1.decryptSecretInputs)(metamorphInput);
|
|
419
|
+
cachedInput = decrypted;
|
|
420
|
+
return decrypted;
|
|
421
|
+
}
|
|
413
422
|
return metamorphInput;
|
|
414
423
|
}
|
|
415
424
|
// Fall through to regular INPUT if metamorph key not found
|
|
@@ -437,7 +446,11 @@ class Actor {
|
|
|
437
446
|
// Decrypt secret inputs after loading (must happen here, not in init(),
|
|
438
447
|
// because the input needs to be available first)
|
|
439
448
|
if (input && typeof input === 'object' && !Array.isArray(input)) {
|
|
440
|
-
|
|
449
|
+
const decrypted = (0, secrets_js_1.decryptSecretInputs)(input);
|
|
450
|
+
// Large inputs are only in the INPUT record (no ACTOR_INPUT env):
|
|
451
|
+
// let the synchronous Actor.input getter return what was loaded here.
|
|
452
|
+
cachedInput = decrypted;
|
|
453
|
+
return decrypted;
|
|
441
454
|
}
|
|
442
455
|
return input;
|
|
443
456
|
}
|
|
@@ -450,14 +463,16 @@ class Actor {
|
|
|
450
463
|
}
|
|
451
464
|
/**
|
|
452
465
|
* Synchronously get the actor input (cached from first access).
|
|
453
|
-
*
|
|
466
|
+
* The platform sets ACTOR_INPUT only for small inputs; for large ones this
|
|
467
|
+
* returns the input once `await Actor.getInput()` has loaded it from the
|
|
468
|
+
* default key-value store.
|
|
454
469
|
*/
|
|
455
470
|
static get input() {
|
|
456
471
|
if (cachedInput !== undefined)
|
|
457
472
|
return cachedInput ?? undefined;
|
|
458
473
|
const raw = process.env.ACTOR_INPUT;
|
|
459
474
|
if (!raw) {
|
|
460
|
-
|
|
475
|
+
// Not cached: a later Actor.getInput() fills it from the INPUT record.
|
|
461
476
|
return undefined;
|
|
462
477
|
}
|
|
463
478
|
try {
|
|
@@ -928,10 +943,11 @@ class Actor {
|
|
|
928
943
|
await eventManager.close();
|
|
929
944
|
(0, events_client_js_1.disconnectEvents)();
|
|
930
945
|
(0, patch_storage_js_1.disconnectStorage)();
|
|
931
|
-
//
|
|
932
|
-
|
|
933
|
-
|
|
934
|
-
|
|
946
|
+
// Let buffered stdout/stderr reach the log pipe before the process exits
|
|
947
|
+
// (pipe writes are async in Node). Storage was flushed above, so this
|
|
948
|
+
// replaces the old fixed 1 s sleep: it resolves as soon as both streams
|
|
949
|
+
// drain, usually within milliseconds, and is still bounded.
|
|
950
|
+
await waitForStdioDrain(Math.min(1000, flushDeadlineMs));
|
|
935
951
|
}
|
|
936
952
|
/**
|
|
937
953
|
* Sets the status message for the current Actor run.
|
|
@@ -1439,3 +1455,22 @@ class Actor {
|
|
|
1439
1455
|
static utils = utils_js_1.utils;
|
|
1440
1456
|
}
|
|
1441
1457
|
exports.Actor = Actor;
|
|
1458
|
+
/** Resolves once stdout and stderr have flushed their buffers, or after `maxMs`. */
|
|
1459
|
+
async function waitForStdioDrain(maxMs, streams = [process.stdout, process.stderr]) {
|
|
1460
|
+
const drained = Promise.all(streams.map((stream) => new Promise((resolve) => {
|
|
1461
|
+
const s = stream;
|
|
1462
|
+
if (!s.writableLength || s.writableFinished)
|
|
1463
|
+
return resolve();
|
|
1464
|
+
// An ended stream accepts no more writes; wait for it to finish flushing.
|
|
1465
|
+
if (s.writableEnded) {
|
|
1466
|
+
s.once('finish', () => resolve());
|
|
1467
|
+
return;
|
|
1468
|
+
}
|
|
1469
|
+
// A zero-length write's callback runs after everything queued before it.
|
|
1470
|
+
s.write('', () => resolve());
|
|
1471
|
+
})));
|
|
1472
|
+
let timer;
|
|
1473
|
+
await Promise.race([drained, new Promise((resolve) => { timer = setTimeout(resolve, maxMs); })]);
|
|
1474
|
+
if (timer)
|
|
1475
|
+
clearTimeout(timer);
|
|
1476
|
+
}
|
package/dist/charging.d.ts
CHANGED
|
@@ -76,6 +76,10 @@ export declare class ChargingManager {
|
|
|
76
76
|
private chargingState?;
|
|
77
77
|
private chargingLogDataset?;
|
|
78
78
|
private notPpeWarningPrinted;
|
|
79
|
+
/** Idempotency key of the last charge the platform accepted (pushDataAndCharge void-on-failure). */
|
|
80
|
+
private lastAcceptedOperationId;
|
|
81
|
+
/** Voids not yet confirmed by the platform; retried before the next push and at exit. */
|
|
82
|
+
private pendingVoids;
|
|
79
83
|
/** Unresolved platform POSTs: key = idempotency UUID (never event name). */
|
|
80
84
|
private readonly pendingCharges;
|
|
81
85
|
private flushInFlight;
|
|
@@ -118,6 +122,8 @@ export declare class ChargingManager {
|
|
|
118
122
|
private getEventPricing;
|
|
119
123
|
private normalizeChargingEventName;
|
|
120
124
|
private calculateMaxChargesByPrice;
|
|
125
|
+
/** @internal Remaining allowance per event (pushDataAndCharge's uncertain path). */
|
|
126
|
+
_chargeableWithinLimit(): Record<string, number>;
|
|
121
127
|
private getChargeableWithinLimit;
|
|
122
128
|
private quarantineStalePending;
|
|
123
129
|
/**
|
|
@@ -126,6 +132,26 @@ export declare class ChargingManager {
|
|
|
126
132
|
* Returns null when the outcome is uncertain after retries.
|
|
127
133
|
*/
|
|
128
134
|
private postChargeWithRetries;
|
|
135
|
+
/** @internal Idempotency key of the charge accepted by the last _chargeExclusive call, if any. */
|
|
136
|
+
_takeLastAcceptedOperationId(): string | undefined;
|
|
137
|
+
/**
|
|
138
|
+
* @internal Reverse an accepted charge whose items were never stored
|
|
139
|
+
* (POST /v2/actor-runs/:runId/charge/void), then unwind the local
|
|
140
|
+
* reservation. Returns true when the platform voided it.
|
|
141
|
+
*/
|
|
142
|
+
/**
|
|
143
|
+
* @internal Cancel a charge whose outcome was never confirmed, for items that
|
|
144
|
+
* were then not stored: drop the pending intent (so the exit flush never
|
|
145
|
+
* posts it) and void it in case the original request did apply. A 404 here
|
|
146
|
+
* means it never applied.
|
|
147
|
+
*/
|
|
148
|
+
_cancelUncertainCharge(eventName: string, operationId: string, reservedCount: number): Promise<boolean>;
|
|
149
|
+
_voidCharge(eventName: string, operationId: string, reservedCount: number, notFoundMeansVoided?: boolean): Promise<boolean>;
|
|
150
|
+
/**
|
|
151
|
+
* @internal Retry unconfirmed voids (caller holds the operation lock).
|
|
152
|
+
* Returns false while any earlier void is still unconfirmed.
|
|
153
|
+
*/
|
|
154
|
+
_retryPendingVoids(): Promise<boolean>;
|
|
129
155
|
/** Clamp local reservation down when the server accepted fewer events. */
|
|
130
156
|
/** @internal Used by pushDataAndCharge dual-event reconciliation. */
|
|
131
157
|
applyAcceptedCount(eventName: string, reservedCount: number, acceptedCount: number, eventPrice?: number): number;
|
|
@@ -151,3 +177,9 @@ export declare function pushDataAndCharge<T>({ chargingManager, items, eventName
|
|
|
151
177
|
isDefaultDataset: boolean;
|
|
152
178
|
pushFn: (limitedItems: T | T[]) => Promise<void>;
|
|
153
179
|
}): Promise<ChargeResult>;
|
|
180
|
+
/**
|
|
181
|
+
* @internal True when a dataset push certainly stored nothing: the storage API
|
|
182
|
+
* answered with an error status, or the request never reached it. Timeouts and
|
|
183
|
+
* resets after the request was sent are not proof (the write may have landed).
|
|
184
|
+
*/
|
|
185
|
+
export declare function pushCertainlyFailed(err: unknown): boolean;
|
package/dist/charging.js
CHANGED
|
@@ -6,6 +6,7 @@ exports.resolveDefaultDatasetItemEventName = resolveDefaultDatasetItemEventName;
|
|
|
6
6
|
exports.hasDefaultDatasetItemEventInPricing = hasDefaultDatasetItemEventInPricing;
|
|
7
7
|
exports.mergeChargeResults = mergeChargeResults;
|
|
8
8
|
exports.pushDataAndCharge = pushDataAndCharge;
|
|
9
|
+
exports.pushCertainlyFailed = pushCertainlyFailed;
|
|
9
10
|
const node_crypto_1 = require("node:crypto");
|
|
10
11
|
/** Client safe-replay window; server TTL is longer (35d). */
|
|
11
12
|
exports.CHARGE_IDEMPOTENCY_SAFE_AGE_MS = 30 * 24 * 60 * 60 * 1000;
|
|
@@ -108,6 +109,10 @@ class ChargingManager {
|
|
|
108
109
|
chargingState;
|
|
109
110
|
chargingLogDataset;
|
|
110
111
|
notPpeWarningPrinted = false;
|
|
112
|
+
/** Idempotency key of the last charge the platform accepted (pushDataAndCharge void-on-failure). */
|
|
113
|
+
lastAcceptedOperationId;
|
|
114
|
+
/** Voids not yet confirmed by the platform; retried before the next push and at exit. */
|
|
115
|
+
pendingVoids = [];
|
|
111
116
|
/** Unresolved platform POSTs: key = idempotency UUID (never event name). */
|
|
112
117
|
pendingCharges = new Map();
|
|
113
118
|
flushInFlight = null;
|
|
@@ -340,6 +345,10 @@ class ChargingManager {
|
|
|
340
345
|
const roundedResult = Math.floor(Number(unroundedResult.toFixed(4)));
|
|
341
346
|
return Math.max(0, roundedResult);
|
|
342
347
|
}
|
|
348
|
+
/** @internal Remaining allowance per event (pushDataAndCharge's uncertain path). */
|
|
349
|
+
_chargeableWithinLimit() {
|
|
350
|
+
return this.chargingState === undefined ? {} : this.getChargeableWithinLimit();
|
|
351
|
+
}
|
|
343
352
|
getChargeableWithinLimit() {
|
|
344
353
|
if (Object.keys(this.pricingInfo).length === 0) {
|
|
345
354
|
return {};
|
|
@@ -398,6 +407,76 @@ class ChargingManager {
|
|
|
398
407
|
`${lastError?.message ?? lastError}`);
|
|
399
408
|
return null;
|
|
400
409
|
}
|
|
410
|
+
/** @internal Idempotency key of the charge accepted by the last _chargeExclusive call, if any. */
|
|
411
|
+
_takeLastAcceptedOperationId() {
|
|
412
|
+
const id = this.lastAcceptedOperationId;
|
|
413
|
+
this.lastAcceptedOperationId = undefined;
|
|
414
|
+
return id;
|
|
415
|
+
}
|
|
416
|
+
/**
|
|
417
|
+
* @internal Reverse an accepted charge whose items were never stored
|
|
418
|
+
* (POST /v2/actor-runs/:runId/charge/void), then unwind the local
|
|
419
|
+
* reservation. Returns true when the platform voided it.
|
|
420
|
+
*/
|
|
421
|
+
/**
|
|
422
|
+
* @internal Cancel a charge whose outcome was never confirmed, for items that
|
|
423
|
+
* were then not stored: drop the pending intent (so the exit flush never
|
|
424
|
+
* posts it) and void it in case the original request did apply. A 404 here
|
|
425
|
+
* means it never applied.
|
|
426
|
+
*/
|
|
427
|
+
async _cancelUncertainCharge(eventName, operationId, reservedCount) {
|
|
428
|
+
this.pendingCharges.delete(operationId);
|
|
429
|
+
return this._voidCharge(eventName, operationId, reservedCount, true);
|
|
430
|
+
}
|
|
431
|
+
async _voidCharge(eventName, operationId, reservedCount, notFoundMeansVoided = false) {
|
|
432
|
+
if (!this.isAtHome || !this.actorRunId)
|
|
433
|
+
return false;
|
|
434
|
+
const runClient = this.apifyClient.run(this.actorRunId);
|
|
435
|
+
for (let attempt = 0; attempt < this.postAttempts; attempt++) {
|
|
436
|
+
try {
|
|
437
|
+
await runClient.httpClient.call({
|
|
438
|
+
url: runClient._url('charge/void'),
|
|
439
|
+
method: 'POST',
|
|
440
|
+
headers: { 'idempotency-key': operationId },
|
|
441
|
+
});
|
|
442
|
+
// Confirmed (voided or already voided): only now give the budget back.
|
|
443
|
+
this.applyAcceptedCount(eventName, reservedCount, 0);
|
|
444
|
+
return true;
|
|
445
|
+
}
|
|
446
|
+
catch (err) {
|
|
447
|
+
const status = httpStatusOf(err);
|
|
448
|
+
// Only the void route's own answer proves it never applied; a server
|
|
449
|
+
// without the route (older deployment) also answers 404.
|
|
450
|
+
if (status === 404 && notFoundMeansVoided && apiErrorTypeOf(err) === 'charge-not-found') {
|
|
451
|
+
// The unconfirmed charge never reached the ledger: nothing billed.
|
|
452
|
+
this.applyAcceptedCount(eventName, reservedCount, 0);
|
|
453
|
+
return true;
|
|
454
|
+
}
|
|
455
|
+
// 404: nothing to void; 409: already settled; 4xx auth: permanent.
|
|
456
|
+
// The charge stands; keep the local reservation that matches it.
|
|
457
|
+
if (status !== undefined && status >= 400 && status < 500)
|
|
458
|
+
return false;
|
|
459
|
+
if (attempt + 1 < this.postAttempts)
|
|
460
|
+
await new Promise((r) => setTimeout(r, 250 * (attempt + 1)));
|
|
461
|
+
}
|
|
462
|
+
}
|
|
463
|
+
// Outcome unknown: keep the reservation and retry before the next push / at exit.
|
|
464
|
+
this.pendingVoids.push({ eventName, operationId, reservedCount, notFoundMeansVoided });
|
|
465
|
+
console.error(`[Scrapely] Void of charge ${operationId} ('${eventName}') not confirmed yet; will retry`);
|
|
466
|
+
return false;
|
|
467
|
+
}
|
|
468
|
+
/**
|
|
469
|
+
* @internal Retry unconfirmed voids (caller holds the operation lock).
|
|
470
|
+
* Returns false while any earlier void is still unconfirmed.
|
|
471
|
+
*/
|
|
472
|
+
async _retryPendingVoids() {
|
|
473
|
+
const pending = this.pendingVoids;
|
|
474
|
+
this.pendingVoids = [];
|
|
475
|
+
for (const v of pending) {
|
|
476
|
+
await this._voidCharge(v.eventName, v.operationId, v.reservedCount, v.notFoundMeansVoided);
|
|
477
|
+
}
|
|
478
|
+
return this.pendingVoids.length === 0;
|
|
479
|
+
}
|
|
401
480
|
/** Clamp local reservation down when the server accepted fewer events. */
|
|
402
481
|
/** @internal Used by pushDataAndCharge dual-event reconciliation. */
|
|
403
482
|
applyAcceptedCount(eventName, reservedCount, acceptedCount, eventPrice) {
|
|
@@ -431,6 +510,7 @@ class ChargingManager {
|
|
|
431
510
|
async _flushPendingExclusive(maxIntents = 32, deadlineMs) {
|
|
432
511
|
if (!this.actorRunId)
|
|
433
512
|
return 0;
|
|
513
|
+
await this._retryPendingVoids();
|
|
434
514
|
if (this.flushInFlight) {
|
|
435
515
|
return this.flushInFlight;
|
|
436
516
|
}
|
|
@@ -584,6 +664,8 @@ class ChargingManager {
|
|
|
584
664
|
}
|
|
585
665
|
if (accepted !== null) {
|
|
586
666
|
this.pendingCharges.delete(intent.idempotencyKey);
|
|
667
|
+
if (accepted > 0)
|
|
668
|
+
this.lastAcceptedOperationId = intent.idempotencyKey;
|
|
587
669
|
chargedCount = this.applyAcceptedCount(normalizedEventName, chargedCount, accepted, eventPricing.price);
|
|
588
670
|
}
|
|
589
671
|
else {
|
|
@@ -683,11 +765,22 @@ async function pushDataAndCharge({ chargingManager, items, eventName, isDefaultD
|
|
|
683
765
|
if (chargingManager.isClosed()) {
|
|
684
766
|
throw new Error('ChargingManager is closed (actor is shutting down); charge rejected');
|
|
685
767
|
}
|
|
768
|
+
// A void that did not go through for an earlier failed push must land
|
|
769
|
+
// before this push charges again, or one unsaved result is billed twice.
|
|
770
|
+
const voidsConfirmed = (await chargingManager._retryPendingVoids?.()) !== false;
|
|
686
771
|
const { limitedItems, eventsToCharge } = chargingManager.calculatePushDataLimits({
|
|
687
772
|
items,
|
|
688
773
|
eventName,
|
|
689
774
|
isDefaultDataset,
|
|
690
775
|
});
|
|
776
|
+
// Only events sent to the platform count: the synthetic default-dataset
|
|
777
|
+
// event is local bookkeeping and never charged through the API.
|
|
778
|
+
if (!voidsConfirmed && Object.keys(eventsToCharge).some((name) => !isSyntheticEvent(name))) {
|
|
779
|
+
// The charge for an earlier, unsaved push may still stand: charging
|
|
780
|
+
// this push now could bill one result twice. Let the caller retry.
|
|
781
|
+
// Pushes that charge nothing go through.
|
|
782
|
+
throw new Error('[Scrapely] An earlier charge for a failed dataset push could not be voided yet; retry the push');
|
|
783
|
+
}
|
|
691
784
|
const itemsArray = Array.isArray(items) ? items : [items];
|
|
692
785
|
const allItemsTrimmed = itemsArray.length > 0 && limitedItems.length === 0;
|
|
693
786
|
if (limitedItems.length === 0) {
|
|
@@ -711,12 +804,33 @@ async function pushDataAndCharge({ chargingManager, items, eventName, isDefaultD
|
|
|
711
804
|
const reservedInOp = [];
|
|
712
805
|
try {
|
|
713
806
|
for (const [name, chargeCount] of ordered) {
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
|
|
807
|
+
chargingManager._takeLastAcceptedOperationId?.();
|
|
808
|
+
let r;
|
|
809
|
+
try {
|
|
810
|
+
r = await chargingManager._chargeExclusive({
|
|
811
|
+
eventName: name,
|
|
812
|
+
count: chargeCount,
|
|
813
|
+
});
|
|
814
|
+
}
|
|
815
|
+
catch (chargeErr) {
|
|
816
|
+
if (!(chargeErr instanceof ChargeUncertainError))
|
|
817
|
+
throw chargeErr;
|
|
818
|
+
// The platform did not confirm the charge (5xx / timeouts after
|
|
819
|
+
// retries). Never drop the items for that: the intent stays
|
|
820
|
+
// pending with its idempotency key and is re-posted by the exit
|
|
821
|
+
// flush, so the items are stored now and billed once.
|
|
822
|
+
console.warn(`[Scrapely] Charge for '${name}' not confirmed yet (${chargeErr.operationId}); storing the items and retrying the charge later`);
|
|
823
|
+
r = {
|
|
824
|
+
eventChargeLimitReached: false,
|
|
825
|
+
chargedCount: chargeErr.count,
|
|
826
|
+
chargeableWithinLimit: chargingManager._chargeableWithinLimit?.() ?? {},
|
|
827
|
+
};
|
|
828
|
+
results.push(r);
|
|
829
|
+
reservedInOp.push({ name, reserved: chargeErr.count, operationId: chargeErr.operationId, uncertain: true });
|
|
830
|
+
continue;
|
|
831
|
+
}
|
|
718
832
|
results.push(r);
|
|
719
|
-
reservedInOp.push({ name, reserved: r.chargedCount });
|
|
833
|
+
reservedInOp.push({ name, reserved: r.chargedCount, operationId: chargingManager._takeLastAcceptedOperationId?.() });
|
|
720
834
|
}
|
|
721
835
|
}
|
|
722
836
|
catch (err) {
|
|
@@ -741,7 +855,12 @@ async function pushDataAndCharge({ chargingManager, items, eventName, isDefaultD
|
|
|
741
855
|
const [name] = ordered[i];
|
|
742
856
|
const reserved = results[i].chargedCount;
|
|
743
857
|
if (reserved > acceptedForItems) {
|
|
744
|
-
|
|
858
|
+
// Nothing stored and a platform charge was made: leave its local
|
|
859
|
+
// count to the void below, which releases it only once the
|
|
860
|
+
// platform confirms (never on a server without the void route).
|
|
861
|
+
const platformOp = acceptedForItems <= 0 && reservedInOp[i]?.operationId;
|
|
862
|
+
if (!platformOp)
|
|
863
|
+
chargingManager.applyAcceptedCount(name, reserved, acceptedForItems);
|
|
745
864
|
results[i] = {
|
|
746
865
|
...results[i],
|
|
747
866
|
chargedCount: acceptedForItems,
|
|
@@ -751,6 +870,20 @@ async function pushDataAndCharge({ chargingManager, items, eventName, isDefaultD
|
|
|
751
870
|
}
|
|
752
871
|
chargeResult = results.reduce(mergeChargeResults);
|
|
753
872
|
if (acceptedForItems <= 0) {
|
|
873
|
+
// Nothing will be stored (another event of this push was capped to
|
|
874
|
+
// zero): undo every platform charge this operation made, so no
|
|
875
|
+
// result is billed for items that are never saved.
|
|
876
|
+
for (let i = 0; i < reservedInOp.length; i++) {
|
|
877
|
+
const { name, operationId, uncertain, reserved } = reservedInOp[i];
|
|
878
|
+
if (reserved <= 0 || !operationId)
|
|
879
|
+
continue;
|
|
880
|
+
// Original count: the local reservation is released only when
|
|
881
|
+
// the platform confirms the void (or that it never applied).
|
|
882
|
+
if (uncertain)
|
|
883
|
+
await chargingManager._cancelUncertainCharge?.(name, operationId, reserved);
|
|
884
|
+
else
|
|
885
|
+
await chargingManager._voidCharge(name, operationId, reserved);
|
|
886
|
+
}
|
|
754
887
|
return {
|
|
755
888
|
eventChargeLimitReached: true,
|
|
756
889
|
chargedCount: chargeResult.chargedCount,
|
|
@@ -762,11 +895,34 @@ async function pushDataAndCharge({ chargingManager, items, eventName, isDefaultD
|
|
|
762
895
|
await pushFn(Array.isArray(items) ? toPersist : toPersist[0]);
|
|
763
896
|
}
|
|
764
897
|
catch (pushErr) {
|
|
765
|
-
//
|
|
766
|
-
//
|
|
767
|
-
//
|
|
898
|
+
// Storage failed after the charge was accepted: a result is billed
|
|
899
|
+
// only if it was saved, so void this operation's platform charges
|
|
900
|
+
// and unwind the local (synthetic) reservations, then surface the
|
|
901
|
+
// error so the caller can retry the push (which charges again).
|
|
768
902
|
console.error(`[Scrapely] Dataset push failed after charge accepted ` +
|
|
769
|
-
`(items=${acceptedForItems}): ${pushErr.message}`);
|
|
903
|
+
`(items=${acceptedForItems}): ${pushErr.message}; voiding the charge`);
|
|
904
|
+
// Only when the push certainly stored nothing: the storage API
|
|
905
|
+
// answered with an error, or the connection was never made. A
|
|
906
|
+
// timeout or reset after sending may have stored the items, so
|
|
907
|
+
// the charge stands for those (they may be in the dataset).
|
|
908
|
+
if (!pushCertainlyFailed(pushErr))
|
|
909
|
+
throw pushErr;
|
|
910
|
+
for (let i = 0; i < reservedInOp.length; i++) {
|
|
911
|
+
const { name, operationId, uncertain } = reservedInOp[i];
|
|
912
|
+
const reserved = results[i].chargedCount;
|
|
913
|
+
if (reserved <= 0)
|
|
914
|
+
continue;
|
|
915
|
+
if (operationId && uncertain) {
|
|
916
|
+
// Never re-post a charge for items that were not stored.
|
|
917
|
+
await chargingManager._cancelUncertainCharge?.(name, operationId, reserved);
|
|
918
|
+
}
|
|
919
|
+
else if (operationId) {
|
|
920
|
+
await chargingManager._voidCharge(name, operationId, reserved);
|
|
921
|
+
}
|
|
922
|
+
else if (isSyntheticEvent(name)) {
|
|
923
|
+
chargingManager.applyAcceptedCount(name, reserved, 0);
|
|
924
|
+
}
|
|
925
|
+
}
|
|
770
926
|
throw pushErr;
|
|
771
927
|
}
|
|
772
928
|
return chargeResult;
|
|
@@ -780,3 +936,32 @@ async function pushDataAndCharge({ chargingManager, items, eventName, isDefaultD
|
|
|
780
936
|
};
|
|
781
937
|
});
|
|
782
938
|
}
|
|
939
|
+
/** HTTP status of an API/client error, if the server answered. */
|
|
940
|
+
function httpStatusOf(err) {
|
|
941
|
+
const e = err;
|
|
942
|
+
const raw = e?.statusCode ?? e?.status ?? e?.response?.status ?? e?.response?.statusCode;
|
|
943
|
+
return typeof raw === 'number' && Number.isFinite(raw) ? raw : undefined;
|
|
944
|
+
}
|
|
945
|
+
const NOT_SENT_CODES = new Set(['ECONNREFUSED', 'ENOTFOUND', 'EAI_AGAIN', 'EHOSTUNREACH', 'ENETUNREACH']);
|
|
946
|
+
/**
|
|
947
|
+
* @internal True when a dataset push certainly stored nothing: the storage API
|
|
948
|
+
* answered with an error status, or the request never reached it. Timeouts and
|
|
949
|
+
* resets after the request was sent are not proof (the write may have landed).
|
|
950
|
+
*/
|
|
951
|
+
function pushCertainlyFailed(err) {
|
|
952
|
+
for (let e = err, depth = 0; e && depth < 4; e = e.cause, depth++) {
|
|
953
|
+
const status = httpStatusOf(e);
|
|
954
|
+
if (status !== undefined)
|
|
955
|
+
return status >= 400;
|
|
956
|
+
const code = e.code;
|
|
957
|
+
if (typeof code === 'string' && NOT_SENT_CODES.has(code))
|
|
958
|
+
return true;
|
|
959
|
+
}
|
|
960
|
+
return false;
|
|
961
|
+
}
|
|
962
|
+
/** API error type (e.g. 'charge-not-found') of a client error, if the server sent one. */
|
|
963
|
+
function apiErrorTypeOf(err) {
|
|
964
|
+
const e = err;
|
|
965
|
+
const t = e?.type ?? e?.data?.error?.type ?? e?.response?.data?.error?.type;
|
|
966
|
+
return typeof t === 'string' ? t : undefined;
|
|
967
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "scrapely",
|
|
3
|
-
"version": "1.2.
|
|
3
|
+
"version": "1.2.10",
|
|
4
4
|
"description": "Scrapely SDK for building JavaScript and TypeScript Actors",
|
|
5
5
|
"main": "dist/index.js",
|
|
6
6
|
"types": "dist/index.d.ts",
|
|
@@ -19,6 +19,7 @@
|
|
|
19
19
|
"scripts": {
|
|
20
20
|
"test": "npx tsx tests/test-mvp.ts",
|
|
21
21
|
"test:charging": "npx tsx --test tests/charging-pending.test.ts",
|
|
22
|
+
"test:stdio-drain": "npx tsx --test tests/stdio-drain.test.ts",
|
|
22
23
|
"test:charging-client": "npx tsx --test tests/charging-client.test.ts",
|
|
23
24
|
"test:proxy": "npx tsx --test tests/proxy-configuration.test.ts",
|
|
24
25
|
"test:browser-pool": "npx tsx --test tests/patch-browser-pool.test.ts",
|
|
@@ -30,6 +31,7 @@
|
|
|
30
31
|
"prod:update": "bash scripts/deploy-prod.sh --update-only",
|
|
31
32
|
"migrate": "npx tsx scripts/run-migration.ts",
|
|
32
33
|
"test:local-run": "npx tsx --test tests/local-run.test.ts",
|
|
34
|
+
"test:input-from-kv": "npx tsx --test tests/input-from-kv.test.ts",
|
|
33
35
|
"test:init-rollback": "npx tsx --test tests/init-rollback.test.ts",
|
|
34
36
|
"test:apify-parity": "npx tsx --test tests/apify-parity.test.ts",
|
|
35
37
|
"test:run-control": "npx tsx --test tests/run-control.test.ts",
|