Skip to content
10 changes: 10 additions & 0 deletions docs/02_concepts/11_pay_per_event.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,16 @@ If you can split your work into individual units, for example scraping one page

If you use the `count` parameter, always check the returned `charged_count`. It tells you how many events were charged, which may be less than what you requested.

### Retry charges safely

When an operation may be retried, for example after a network error, pass an `idempotency_key` to `Actor.charge()`. A repeated call under the same key isn't charged again. It reports the `charged_count` of the original call:

```python
await Actor.charge(event_name='search-result', idempotency_key=result_id)
```

Keys are remembered for the lifetime of the Actor process, and each key belongs to a single event. Reusing a key for a different event raises a `ValueError`.

### Monitor charging

For both custom and synthetic events, every `Actor.charge` call returns a <ApiLink to="class/ChargeResult">`ChargeResult`</ApiLink>. Inspect its fields to learn how much was charged.
Expand Down
8 changes: 6 additions & 2 deletions src/apify/_actor.py
Original file line number Diff line number Diff line change
Expand Up @@ -788,18 +788,22 @@ def get_charging_manager(self) -> ChargingManager:
return self._charging_manager_implementation

@_ensure_context
async def charge(self, event_name: str, *, count: int = 1) -> ChargeResult:
async def charge(self, event_name: str, *, count: int = 1, idempotency_key: str | None = None) -> ChargeResult:
"""Charge for a specified number of events - sub-operations of the Actor.

This is relevant only for the pay-per-event pricing model.

Args:
event_name: Name of the event to be charged for.
count: Number of events to charge for.
idempotency_key: A unique key preventing a retried operation from being charged for twice. A repeat
under the same key is not sent to the API and reports the `charged_count` of the original call.
Keys are remembered for the lifetime of the Actor process. A key belongs to a single event, so
reusing one for a different event raises `ValueError`, as does passing a blank key.
"""
# charging_manager.charge() acquires charge_lock internally.
charging_manager = self.get_charging_manager()
return await charging_manager.charge(event_name, count=count)
return await charging_manager.charge(event_name, count=count, idempotency_key=idempotency_key)

@overload
def on(
Expand Down
62 changes: 54 additions & 8 deletions src/apify/_charging.py
Original file line number Diff line number Diff line change
Expand Up @@ -210,14 +210,18 @@ class ChargingManager(Protocol):
charge_lock: ReentrantLock
"""Lock to synchronize charge operations. Prevents race conditions between `charge` and `push_data` calls."""

async def charge(self, event_name: str, *, count: int = 1) -> ChargeResult:
async def charge(self, event_name: str, *, count: int = 1, idempotency_key: str | None = None) -> ChargeResult:
"""Charge for a specified number of events - sub-operations of the Actor.

This is relevant only for the pay-per-event pricing model.

Args:
event_name: Name of the event to be charged for.
count: Number of events to charge for.
idempotency_key: A unique key preventing a retried operation from being charged for twice. A repeat
under the same key is not sent to the API and reports the `charged_count` of the original call.
Keys are remembered for the lifetime of the Actor process. A key belongs to a single event, so
reusing one for a different event raises `ValueError`, as does passing a blank key.
"""

def calculate_total_charged_amount(self) -> Decimal:
Expand Down Expand Up @@ -329,6 +333,7 @@ def __init__(self, configuration: Configuration, client: ApifyClientAsync) -> No
self._charging_state: dict[str, ChargingStateItem] = {}
self._pricing_info: dict[str, PricingInfoItem] = {}
self._tier_priced_events: set[str] = set()
self._idempotent_charges: dict[str, IdempotentChargeItem] = {}

self._not_ppe_warning_printed = False
self.active = False
Expand Down Expand Up @@ -412,7 +417,10 @@ async def __aexit__(
self.active = False

@_ensure_context
async def charge(self, event_name: str, *, count: int = 1) -> ChargeResult:
async def charge(self, event_name: str, *, count: int = 1, idempotency_key: str | None = None) -> ChargeResult:
if idempotency_key is not None and not idempotency_key.strip():
raise ValueError('idempotency_key must not be blank')

# For runs that do not use the pay-per-event pricing model, just print a warning and return
if self._pricing_model != 'PAY_PER_EVENT':
if not self._not_ppe_warning_printed:
Expand All @@ -435,6 +443,24 @@ async def charge(self, event_name: str, *, count: int = 1) -> ChargeResult:
)

async with self.charge_lock():
# A repeat is resolved from this registry rather than left to the platform, whose own idempotency
# record expires after a few minutes: a late repeat would charge a second time, and counting it here
# would inflate the charging state and make the run hit `max_total_charge_usd` early.
if idempotency_key is not None and (previous := self._idempotent_charges.get(idempotency_key)):
if previous.event_name != event_name:
raise ValueError(
f"Idempotency key '{idempotency_key}' was already used to charge for event "
f"'{previous.event_name}', so it cannot be reused for event '{event_name}'."
)

logger.debug(f"Skipped a repeated charge of event '{event_name}' under key '{idempotency_key}'.")

return ChargeResult(
event_charge_limit_reached=self.is_event_charge_limit_reached(event_name),
charged_count=previous.charged_count,
chargeable_within_limit=self.compute_chargeable(),
)

# Determine the maximum amount of events that can be charged within the budget
max_chargeable = self.calculate_max_event_charge_count_within_limit(event_name)
charged_count = min(count, max_chargeable if max_chargeable is not None else count)
Expand All @@ -455,11 +481,6 @@ async def charge(self, event_name: str, *, count: int = 1) -> ChargeResult:
),
)

# Update the charging state
self._charging_state.setdefault(event_name, ChargingStateItem(0, Decimal()))
self._charging_state[event_name].charge_count += charged_count
self._charging_state[event_name].total_charged_amount += charged_count * pricing_info.price

# If running on the platform, call the charge endpoint
if self._is_at_home:
if self._actor_run_id is None:
Expand All @@ -470,7 +491,11 @@ async def charge(self, event_name: str, *, count: int = 1) -> ChargeResult:
# the platform handles them automatically based on dataset writes.
pass
elif event_name in self._pricing_info:
await self._client.run(self._actor_run_id).charge(event_name, count=charged_count)
await self._client.run(self._actor_run_id).charge(
event_name,
count=charged_count,
idempotency_key=idempotency_key,
)
logger.debug(f"Charged {charged_count} occurrence(s) of event '{event_name}'.")
elif event_name in self._tier_priced_events:
logger.warning(
Expand All @@ -479,6 +504,20 @@ async def charge(self, event_name: str, *, count: int = 1) -> ChargeResult:
else:
logger.warning(f"Attempting to charge for an unknown event '{event_name}'")

# Count the charge only after the API call returns, so a request the platform never received leaves
# no local trace.
self._charging_state.setdefault(event_name, ChargingStateItem(0, Decimal()))
self._charging_state[event_name].charge_count += charged_count
self._charging_state[event_name].total_charged_amount += charged_count * pricing_info.price

# Remember the key for every charge that was counted, including events the API never receives, such as
# synthetic and tier-priced ones - those are counted locally and a repeat would count them twice.
if idempotency_key is not None:
self._idempotent_charges[idempotency_key] = IdempotentChargeItem(
event_name=event_name,
charged_count=charged_count,
)

# Log the charged operation (if enabled)
if self._charging_log_dataset:
await self._charging_log_dataset.push_data(
Expand All @@ -487,6 +526,7 @@ async def charge(self, event_name: str, *, count: int = 1) -> ChargeResult:
'event_title': pricing_info.title,
'event_price_usd': float(round(pricing_info.price, 3)),
'charged_count': charged_count,
'idempotency_key': idempotency_key,
'timestamp': datetime.now(UTC).isoformat(),
}
)
Expand Down Expand Up @@ -636,6 +676,12 @@ class PricingInfoItem:
title: str


@dataclass(frozen=True)
class IdempotentChargeItem:
event_name: str
charged_count: int


class _FetchedPricingInfoDict(TypedDict):
pricing_info: ActorPricingInfoModel | None
charged_event_counts: dict[str, int]
Expand Down
23 changes: 20 additions & 3 deletions tests/unit/actor/test_actor_charge.py
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,7 @@ async def setup_mocked_charging(
setup.charging_mgr._pricing_info['event'] = PricingInfoItem(Decimal('1.0'), 'Event')

result = await Actor.charge('event', count=1)
setup.mock_charge.assert_called_once_with('event', count=1)
setup.mock_charge.assert_called_once_with('event', count=1, idempotency_key=None)
"""
# Mock the ApifyClientAsync
mock_client = Mock()
Expand Down Expand Up @@ -82,7 +82,7 @@ async def test_actor_charge_push_data_with_no_remaining_budget() -> None:
result1 = await Actor.charge('some-event', count=1) # Costs $1, leaving $0.5

# Verify the first charge call was made correctly
setup.mock_charge.assert_called_once_with('some-event', count=1)
setup.mock_charge.assert_called_once_with('some-event', count=1, idempotency_key=None)
setup.mock_charge.reset_mock()

assert result1.charged_count == 1
Expand Down Expand Up @@ -117,10 +117,27 @@ async def test_actor_charge_api_call_verification() -> None:

# Call charge with count=1 - this SHOULD call the API
result2 = await Actor.charge('test-event', count=1)
setup.mock_charge.assert_called_once_with('test-event', count=1)
setup.mock_charge.assert_called_once_with('test-event', count=1, idempotency_key=None)
assert result2.charged_count == 1


async def test_actor_charge_forwards_idempotency_key() -> None:
"""Verify that Actor.charge passes the idempotency key down to the API and deduplicates repeats."""
async with setup_mocked_charging(
Configuration(max_total_charge_usd=Decimal('10.0'), test_pay_per_event=True), {'test-event': Decimal('1.0')}
) as setup:
result1 = await Actor.charge('test-event', count=1, idempotency_key='key-1')
setup.mock_charge.assert_called_once_with('test-event', count=1, idempotency_key='key-1')
assert result1.charged_count == 1

setup.mock_charge.reset_mock()

result2 = await Actor.charge('test-event', count=1, idempotency_key='key-1')
setup.mock_charge.assert_not_called()
assert result2.charged_count == 1
assert setup.charging_mgr.get_charged_event_count('test-event') == 1


async def test_max_event_charge_count_within_limit_tolerates_overdraw() -> None:
"""Test that calculate_max_event_charge_count_within_limit does not return nonsensical (e.g., negative) values when
the total number of charged events overdraws the max_total_charge_usd limit."""
Expand Down
Loading
Loading