Skip to content

Commit 88e6ba3

Browse files
authored
fix: Fix Actor.charge behavior when the budget is overdrawn (#668)
- follow up to #665 - I believe this fixes the original issue for good :disappointed_relieved:
1 parent 966f32a commit 88e6ba3

File tree

2 files changed

+178
-1
lines changed

2 files changed

+178
-1
lines changed

src/apify/_charging.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -313,7 +313,7 @@ def calculate_max_event_charge_count_within_limit(self, event_name: str) -> int
313313
return None
314314

315315
result = (self._max_total_charge_usd - self.calculate_total_charged_amount()) / price
316-
return math.floor(result) if result.is_finite() else None
316+
return max(0, math.floor(result)) if result.is_finite() else None
317317

318318
@ensure_context
319319
def get_pricing_info(self) -> ActorPricingInfo:
Lines changed: 177 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,177 @@
1+
from collections.abc import AsyncGenerator
2+
from contextlib import asynccontextmanager
3+
from decimal import Decimal
4+
from typing import NamedTuple
5+
from unittest.mock import AsyncMock, Mock, patch
6+
7+
from apify import Actor, Configuration
8+
from apify._charging import ChargingManagerImplementation, PricingInfoItem
9+
from apify._models import PayPerEventActorPricingInfo
10+
11+
12+
class MockedChargingSetup(NamedTuple):
13+
"""Container for mocked charging components."""
14+
15+
charging_mgr: ChargingManagerImplementation
16+
mock_charge: AsyncMock
17+
mock_client: Mock
18+
19+
20+
@asynccontextmanager
21+
async def setup_mocked_charging(configuration: Configuration) -> AsyncGenerator[MockedChargingSetup]:
22+
"""Context manager that sets up an Actor with mocked charging on Apify platform.
23+
24+
Usage:
25+
async with setup_mocked_charging(Decimal('10.0')) as setup:
26+
# Add pricing info for events
27+
setup.charging_mgr._pricing_info['event'] = PricingInfoItem(Decimal('1.0'), 'Event')
28+
29+
result = await Actor.charge('event', count=1)
30+
setup.mock_charge.assert_called_once_with('event', 1)
31+
"""
32+
# Mock the ApifyClientAsync
33+
mock_client = Mock()
34+
mock_run_client = Mock()
35+
mock_charge = AsyncMock()
36+
37+
mock_run_client.charge = mock_charge
38+
mock_client.run = Mock(return_value=mock_run_client)
39+
40+
async with Actor(configuration):
41+
charging_mgr_impl: ChargingManagerImplementation = Actor.get_charging_manager() # type: ignore[assignment]
42+
43+
# Patch the charging manager to simulate running on Apify platform
44+
with (
45+
patch.object(charging_mgr_impl, '_is_at_home', new=True),
46+
patch.object(charging_mgr_impl, '_actor_run_id', 'test-run-id'),
47+
patch.object(charging_mgr_impl, '_client', mock_client),
48+
):
49+
yield MockedChargingSetup(
50+
charging_mgr=charging_mgr_impl,
51+
mock_charge=mock_charge,
52+
mock_client=mock_client,
53+
)
54+
55+
56+
async def test_actor_charge_push_data_with_no_remaining_budget() -> None:
57+
"""Test that the API client is NOT called when budget is exhausted during push_data.
58+
59+
When push_data can't afford to charge for any items, it correctly avoids calling the API.
60+
"""
61+
async with setup_mocked_charging(
62+
Configuration(max_total_charge_usd=Decimal('1.5'), test_pay_per_event=True)
63+
) as setup:
64+
# Add pricing info for the events
65+
setup.charging_mgr._pricing_info['some-event'] = PricingInfoItem(Decimal('1.0'), 'Some Event')
66+
setup.charging_mgr._pricing_info['another-event'] = PricingInfoItem(Decimal('1.0'), 'Another Event')
67+
68+
# Exhaust most of the budget (events cost $1 each)
69+
result1 = await Actor.charge('some-event', count=1) # Costs $1, leaving $0.5
70+
71+
# Verify the first charge call was made correctly
72+
setup.mock_charge.assert_called_once_with('some-event', 1)
73+
setup.mock_charge.reset_mock()
74+
75+
assert result1.charged_count == 1
76+
77+
# Now try to push data - we can't afford even 1 more event
78+
# This will call charge(event_name, count=0) because max_charged_count=0
79+
result = await Actor.push_data([{'hello': 'world'} for _ in range(10)], 'another-event')
80+
81+
# The API should NOT be called when count=0
82+
setup.mock_charge.assert_not_called()
83+
84+
# Correctly returns result with charged_count=0
85+
assert result is not None
86+
assert result.charged_count == 0
87+
assert result.event_charge_limit_reached is True
88+
89+
# Verify no items were pushed
90+
dataset = await Actor.open_dataset()
91+
items = await dataset.get_data()
92+
assert len(items.items) == 0
93+
94+
95+
async def test_actor_charge_api_call_verification() -> None:
96+
"""Verify that charge() makes API calls correctly."""
97+
async with setup_mocked_charging(
98+
Configuration(max_total_charge_usd=Decimal('10.0'), test_pay_per_event=True)
99+
) as setup:
100+
# Add pricing info for the event
101+
setup.charging_mgr._pricing_info['test-event'] = PricingInfoItem(Decimal('1.0'), 'Test Event')
102+
103+
# Call charge directly with count=0 - this should NOT call the API
104+
result1 = await Actor.charge('test-event', count=0)
105+
setup.mock_charge.assert_not_called()
106+
assert result1.charged_count == 0
107+
108+
# Call charge with count=1 - this SHOULD call the API
109+
result2 = await Actor.charge('test-event', count=1)
110+
setup.mock_charge.assert_called_once_with('test-event', 1)
111+
assert result2.charged_count == 1
112+
113+
114+
async def test_max_event_charge_count_within_limit_tolerates_overdraw() -> None:
115+
"""Test that calculate_max_event_charge_count_within_limit does not return nonsensical (e.g., negative) values when
116+
the total number of charged events overdraws the max_total_charge_usd limit."""
117+
118+
configuration = Configuration(
119+
max_total_charge_usd=Decimal('0.00025'),
120+
actor_pricing_info=PayPerEventActorPricingInfo.model_validate(
121+
{
122+
'pricingModel': 'PAY_PER_EVENT',
123+
'pricingPerEvent': {
124+
'actorChargeEvents': {
125+
'event': {
126+
'eventPriceUsd': 0.0003,
127+
'eventTitle': 'Event',
128+
},
129+
'apify-actor-start': {
130+
'eventPriceUsd': 0.00005,
131+
'eventTitle': 'Actor start',
132+
},
133+
}
134+
},
135+
}
136+
),
137+
charged_event_counts={'event': 1, 'apify-actor-start': 1}, # Already charged 2 events worth $0.00035
138+
test_pay_per_event=True,
139+
)
140+
141+
async with setup_mocked_charging(configuration) as setup:
142+
max_count = setup.charging_mgr.calculate_max_event_charge_count_within_limit('event')
143+
assert max_count == 0
144+
145+
146+
async def test_charge_with_overdrawn_budget() -> None:
147+
configuration = Configuration(
148+
max_total_charge_usd=Decimal('0.00025'),
149+
actor_pricing_info=PayPerEventActorPricingInfo.model_validate(
150+
{
151+
'pricingModel': 'PAY_PER_EVENT',
152+
'pricingPerEvent': {
153+
'actorChargeEvents': {
154+
'event': {
155+
'eventPriceUsd': 0.0003,
156+
'eventTitle': 'Event',
157+
},
158+
'apify-actor-start': {
159+
'eventPriceUsd': 0.00005,
160+
'eventTitle': 'Actor start',
161+
},
162+
}
163+
},
164+
}
165+
),
166+
charged_event_counts={'event': 0, 'apify-actor-start': 1},
167+
test_pay_per_event=True,
168+
)
169+
170+
async with setup_mocked_charging(configuration) as setup:
171+
charge_result = await Actor.charge('event', 1)
172+
assert charge_result.charged_count == 0 # The budget doesn't allow another event
173+
174+
push_result = await Actor.push_data([{'hello': 'world'}], 'event')
175+
assert push_result.charged_count == 0 # Nor does the budget allow this
176+
177+
setup.mock_charge.assert_not_called()

0 commit comments

Comments
 (0)