Skip to content

Commit 7d17dfa

Browse files
authored
fix: Fix Actor.charge behavior when the budget is overdrawn (#504)
- Porting over the fix and tests from https://github.com/apify/apify-sdk-python/pulls/668
1 parent 57f7a2a commit 7d17dfa

File tree

3 files changed

+253
-11
lines changed

3 files changed

+253
-11
lines changed

packages/apify/src/charging.ts

Lines changed: 8 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -425,15 +425,14 @@ export class ChargingManager {
425425
return Infinity;
426426
}
427427

428+
// The raw number of events allowed by the budget
429+
const unroundedResult =
430+
(this.maxTotalChargeUsd - this.calculateTotalChargedAmount()) /
431+
price;
432+
428433
// First round as Math.floor(4.9999999999999999) will incorrectly return 5
429-
return Math.floor(
430-
Number(
431-
(
432-
(this.maxTotalChargeUsd -
433-
this.calculateTotalChargedAmount()) /
434-
price
435-
).toFixed(4),
436-
),
437-
);
434+
const roundedResult = Math.floor(Number(unroundedResult.toFixed(4)));
435+
436+
return Math.max(0, roundedResult);
438437
}
439438
}

test/MemoryStorageEmulator.ts

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -18,22 +18,30 @@ const LOCAL_EMULATION_DIR = resolve(
1818

1919
export class MemoryStorageEmulator {
2020
protected localStorageDirectories: string[] = [];
21+
protected storage?: MemoryStorage;
2122

2223
async init(dirName = cryptoRandomObjectId(10)) {
2324
StorageManager.clearCache();
2425
const localStorageDir = resolve(LOCAL_EMULATION_DIR, dirName);
2526
this.localStorageDirectories.push(localStorageDir);
2627
await ensureDir(localStorageDir);
2728

28-
const storage = new MemoryStorage({
29+
this.storage = new MemoryStorage({
2930
localDataDirectory: localStorageDir,
3031
});
31-
Configuration.getGlobalConfig().useStorageClient(storage);
32+
Configuration.getGlobalConfig().useStorageClient(this.storage);
3233
log.debug(
3334
`Initialized emulated memory storage in folder ${localStorageDir}`,
3435
);
3536
}
3637

38+
reapplyStorageClient() {
39+
if (!this.storage) {
40+
throw new Error('Storage not initialized. Call init() first.');
41+
}
42+
Configuration.getGlobalConfig().useStorageClient(this.storage);
43+
}
44+
3745
async destroy() {
3846
const promises = this.localStorageDirectories.map(async (dir) => {
3947
return rm(dir, { force: true, recursive: true });

test/apify/charging.test.ts

Lines changed: 235 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
import { afterEach, beforeEach, describe, expect, test } from 'vitest';
2+
13
import { Actor } from '../../packages/apify/src/index.js';
24
import { MemoryStorageEmulator } from '../MemoryStorageEmulator.js';
35

@@ -24,6 +26,9 @@ describe('ChargingManager', () => {
2426
delete process.env.ACTOR_MAX_TOTAL_CHARGE_USD;
2527
delete process.env.APIFY_ACTOR_PRICING_INFO;
2628
delete process.env.APIFY_CHARGED_ACTOR_EVENT_COUNTS;
29+
delete process.env.APIFY_IS_AT_HOME;
30+
delete process.env.APIFY_TOKEN;
31+
delete process.env.ACTOR_RUN_ID;
2732
});
2833

2934
describe('charge()', () => {
@@ -66,6 +71,7 @@ describe('ChargingManager', () => {
6671
process.env.APIFY_CHARGED_ACTOR_EVENT_COUNTS = JSON.stringify({});
6772

6873
await Actor.init();
74+
localStorageEmulator.reapplyStorageClient();
6975

7076
const chargeResult = await Actor.charge({
7177
eventName: 'foobar',
@@ -75,5 +81,234 @@ describe('ChargingManager', () => {
7581
expect(chargeResult.chargedCount).toBe(4);
7682
expect(chargeResult.eventChargeLimitReached).toBe(false);
7783
});
84+
85+
test('should not call API when budget is exhausted during pushData', async () => {
86+
// Don't use ACTOR_TEST_PAY_PER_EVENT when simulating Apify platform
87+
delete process.env.ACTOR_TEST_PAY_PER_EVENT;
88+
89+
process.env.ACTOR_MAX_TOTAL_CHARGE_USD = '1.5';
90+
process.env.APIFY_IS_AT_HOME = '1';
91+
process.env.APIFY_TOKEN = 'this-wont-work';
92+
process.env.ACTOR_RUN_ID = 'test-run-id';
93+
94+
// Set pricing info via env vars (as if coming from platform)
95+
process.env.APIFY_ACTOR_PRICING_INFO = JSON.stringify({
96+
pricingModel: 'PAY_PER_EVENT',
97+
pricingPerEvent: {
98+
actorChargeEvents: {
99+
'some-event': {
100+
eventTitle: 'Some Event',
101+
eventPriceUsd: 1.0,
102+
},
103+
'another-event': {
104+
eventTitle: 'Another Event',
105+
eventPriceUsd: 1.0,
106+
},
107+
},
108+
},
109+
});
110+
process.env.APIFY_CHARGED_ACTOR_EVENT_COUNTS = JSON.stringify({});
111+
112+
await Actor.init();
113+
localStorageEmulator.reapplyStorageClient();
114+
115+
// Mock the API client charge method
116+
const chargeSpy = vitest.fn().mockResolvedValue(undefined);
117+
vitest.spyOn(Actor.apifyClient, 'run').mockReturnValue({
118+
charge: chargeSpy,
119+
} as any);
120+
121+
// Exhaust most of the budget (events cost $1 each)
122+
const result1 = await Actor.charge({
123+
eventName: 'some-event',
124+
count: 1,
125+
}); // Costs $1, leaving $0.5
126+
expect(result1.chargedCount).toBe(1);
127+
expect(chargeSpy).toHaveBeenCalledTimes(1);
128+
expect(chargeSpy).toHaveBeenCalledWith({
129+
eventName: 'some-event',
130+
count: 1,
131+
});
132+
133+
chargeSpy.mockClear();
134+
135+
// Now try to push data - we can't afford even 1 more event
136+
const result = await Actor.pushData(
137+
Array(10).fill({ hello: 'world' }),
138+
'another-event',
139+
);
140+
141+
// The API should NOT be called when count=0
142+
expect(chargeSpy).not.toHaveBeenCalled();
143+
144+
// Correctly returns result with chargedCount=0
145+
expect(result).toBeDefined();
146+
expect(result!.chargedCount).toBe(0);
147+
// Note: eventChargeLimitReached is false because count=0 was passed to charge()
148+
// This is by design - see charging.ts line 318
149+
expect(result!.eventChargeLimitReached).toBe(false);
150+
151+
// Verify no items were pushed (the important part)
152+
const dataset = await Actor.openDataset();
153+
const items = await dataset.getData();
154+
expect(items.items).toHaveLength(0);
155+
156+
delete process.env.APIFY_IS_AT_HOME;
157+
delete process.env.ACTOR_RUN_ID;
158+
});
159+
160+
test('should verify charge API call with count=0 vs count>0', async () => {
161+
// Don't use ACTOR_TEST_PAY_PER_EVENT when simulating Apify platform
162+
delete process.env.ACTOR_TEST_PAY_PER_EVENT;
163+
164+
process.env.ACTOR_MAX_TOTAL_CHARGE_USD = '10.0';
165+
process.env.APIFY_IS_AT_HOME = '1';
166+
process.env.APIFY_TOKEN = 'this-wont-work';
167+
process.env.ACTOR_RUN_ID = 'test-run-id';
168+
169+
// Set pricing info via env vars
170+
process.env.APIFY_ACTOR_PRICING_INFO = JSON.stringify({
171+
pricingModel: 'PAY_PER_EVENT',
172+
pricingPerEvent: {
173+
actorChargeEvents: {
174+
'test-event': {
175+
eventTitle: 'Test Event',
176+
eventPriceUsd: 1.0,
177+
},
178+
},
179+
},
180+
});
181+
process.env.APIFY_CHARGED_ACTOR_EVENT_COUNTS = JSON.stringify({});
182+
183+
await Actor.init();
184+
localStorageEmulator.reapplyStorageClient();
185+
186+
// Mock the API client charge method
187+
const chargeSpy = vitest.fn().mockResolvedValue(undefined);
188+
vitest.spyOn(Actor.apifyClient, 'run').mockReturnValue({
189+
charge: chargeSpy,
190+
} as any);
191+
192+
// Call charge with count=0 - this should NOT call the API
193+
const result1 = await Actor.charge({
194+
eventName: 'test-event',
195+
count: 0,
196+
});
197+
expect(chargeSpy).not.toHaveBeenCalled();
198+
expect(result1.chargedCount).toBe(0);
199+
200+
// Call charge with count=1 - this SHOULD call the API
201+
const result2 = await Actor.charge({
202+
eventName: 'test-event',
203+
count: 1,
204+
});
205+
expect(chargeSpy).toHaveBeenCalledTimes(1);
206+
expect(chargeSpy).toHaveBeenCalledWith({
207+
eventName: 'test-event',
208+
count: 1,
209+
});
210+
expect(result2.chargedCount).toBe(1);
211+
});
212+
});
213+
214+
describe('calculateMaxEventChargeCountWithinLimit()', () => {
215+
test('should not return negative values when budget is overdrawn', async () => {
216+
// Don't use ACTOR_TEST_PAY_PER_EVENT when simulating Apify platform
217+
delete process.env.ACTOR_TEST_PAY_PER_EVENT;
218+
219+
process.env.ACTOR_MAX_TOTAL_CHARGE_USD = '0.00025';
220+
221+
// Set pricing info and already charged events that overdraw the budget
222+
process.env.APIFY_ACTOR_PRICING_INFO = JSON.stringify({
223+
pricingModel: 'PAY_PER_EVENT',
224+
pricingPerEvent: {
225+
actorChargeEvents: {
226+
event: {
227+
eventTitle: 'Event',
228+
eventPriceUsd: 0.0003,
229+
},
230+
'apify-actor-start': {
231+
eventTitle: 'Actor start',
232+
eventPriceUsd: 0.00005,
233+
},
234+
},
235+
},
236+
});
237+
// Already charged 2 events worth $0.00035, which exceeds the $0.00025 budget
238+
process.env.APIFY_CHARGED_ACTOR_EVENT_COUNTS = JSON.stringify({
239+
event: 1,
240+
'apify-actor-start': 1,
241+
});
242+
243+
await Actor.init();
244+
245+
const chargingManager = Actor.getChargingManager();
246+
const maxCount =
247+
chargingManager.calculateMaxEventChargeCountWithinLimit(
248+
'event',
249+
);
250+
251+
expect(maxCount).toBe(0);
252+
});
253+
});
254+
255+
describe('charge() with overdrawn budget', () => {
256+
test('should handle charging when budget is already overdrawn', async () => {
257+
// Don't use ACTOR_TEST_PAY_PER_EVENT when simulating Apify platform
258+
delete process.env.ACTOR_TEST_PAY_PER_EVENT;
259+
260+
process.env.ACTOR_MAX_TOTAL_CHARGE_USD = '0.00025';
261+
process.env.APIFY_IS_AT_HOME = '1';
262+
process.env.APIFY_TOKEN = 'this-wont-work';
263+
process.env.ACTOR_RUN_ID = 'test-run-id';
264+
265+
// Set pricing info
266+
process.env.APIFY_ACTOR_PRICING_INFO = JSON.stringify({
267+
pricingModel: 'PAY_PER_EVENT',
268+
pricingPerEvent: {
269+
actorChargeEvents: {
270+
event: {
271+
eventTitle: 'Event',
272+
eventPriceUsd: 0.0003,
273+
},
274+
'apify-actor-start': {
275+
eventTitle: 'Actor start',
276+
eventPriceUsd: 0.00005,
277+
},
278+
},
279+
},
280+
});
281+
// Already charged actor-start, leaving only $0.0002 which is less than the event cost
282+
process.env.APIFY_CHARGED_ACTOR_EVENT_COUNTS = JSON.stringify({
283+
event: 0,
284+
'apify-actor-start': 1,
285+
});
286+
287+
await Actor.init();
288+
localStorageEmulator.reapplyStorageClient();
289+
290+
// Mock the API client charge method
291+
const chargeSpy = vitest.fn().mockResolvedValue(undefined);
292+
vitest.spyOn(Actor.apifyClient, 'run').mockReturnValue({
293+
charge: chargeSpy,
294+
} as any);
295+
296+
// Try to charge - the budget doesn't allow another event
297+
const chargeResult = await Actor.charge({
298+
eventName: 'event',
299+
count: 1,
300+
});
301+
expect(chargeResult.chargedCount).toBe(0);
302+
303+
// Try to push data - the budget doesn't allow this either
304+
const pushResult = await Actor.pushData(
305+
[{ hello: 'world' }],
306+
'event',
307+
);
308+
expect(pushResult!.chargedCount).toBe(0);
309+
310+
// The API should NOT have been called in either case
311+
expect(chargeSpy).not.toHaveBeenCalled();
312+
});
78313
});
79314
});

0 commit comments

Comments
 (0)