From 9bf8c92de1b27854bede48488694d49738717eee Mon Sep 17 00:00:00 2001 From: Vlada Dusek Date: Mon, 20 Apr 2026 14:41:31 +0200 Subject: [PATCH 1/2] fix: preserve `max_total_charge_usd=0` instead of treating it as unlimited `Decimal(0) or Decimal('inf')` evaluates to `Decimal('inf')` because `Decimal(0)` is falsy, silently inverting the user's intent when `0` is explicitly set as a kill-switch. Replace with an explicit `is not None` check at the three affected sites, matching the correct pattern already used on the API-fetch path. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/apify/_charging.py | 16 +++++++++++++--- 1 file changed, 13 insertions(+), 3 deletions(-) diff --git a/src/apify/_charging.py b/src/apify/_charging.py index 9efa700d..07fdd04b 100644 --- a/src/apify/_charging.py +++ b/src/apify/_charging.py @@ -159,7 +159,9 @@ class ChargingManagerImplementation(ChargingManager): LOCAL_CHARGING_LOG_DATASET_NAME = 'charging-log' def __init__(self, configuration: Configuration, client: ApifyClientAsync) -> None: - self._max_total_charge_usd = configuration.max_total_charge_usd or Decimal('inf') + self._max_total_charge_usd = ( + configuration.max_total_charge_usd if configuration.max_total_charge_usd is not None else Decimal('inf') + ) self._configuration = configuration self._is_at_home = configuration.is_at_home self._actor_run_id = configuration.actor_run_id @@ -413,7 +415,11 @@ async def _fetch_pricing_info(self) -> _FetchedPricingInfoDict: return _FetchedPricingInfoDict( pricing_info=self._configuration.actor_pricing_info, charged_event_counts=self._configuration.charged_event_counts, - max_total_charge_usd=self._configuration.max_total_charge_usd or Decimal('inf'), + max_total_charge_usd=( + self._configuration.max_total_charge_usd + if self._configuration.max_total_charge_usd is not None + else Decimal('inf') + ), ) # Fall back to API call @@ -436,7 +442,11 @@ async def _fetch_pricing_info(self) -> _FetchedPricingInfoDict: return _FetchedPricingInfoDict( pricing_info=None, charged_event_counts={}, - max_total_charge_usd=self._configuration.max_total_charge_usd or Decimal('inf'), + max_total_charge_usd=( + self._configuration.max_total_charge_usd + if self._configuration.max_total_charge_usd is not None + else Decimal('inf') + ), ) def _get_event_price(self, event_name: str) -> Decimal: From ce7bcbd08fe18e8a4c4cb00271a76ee55bdd1794 Mon Sep 17 00:00:00 2001 From: Vlada Dusek Date: Mon, 20 Apr 2026 15:55:25 +0200 Subject: [PATCH 2/2] test: cover `max_total_charge_usd=0` at the charging manager boundary Add two regression tests matching the three fix sites in `ChargingManagerImplementation`: - with PPE pricing env vars set, `max_total_charge_usd=0` blocks all charging (exercises `__init__` + the env-var `_fetch_pricing_info` path). - without pricing, the zero value is still preserved through the fallback path. Both would have failed before the fix (the `or Decimal('inf')` coalescence silently promoted `Decimal(0)` to unlimited). Co-Authored-By: Claude Opus 4.7 (1M context) --- tests/unit/actor/test_charging_manager.py | 30 +++++++++++++++++++++++ 1 file changed, 30 insertions(+) diff --git a/tests/unit/actor/test_charging_manager.py b/tests/unit/actor/test_charging_manager.py index 10a8474b..6fb84b2c 100644 --- a/tests/unit/actor/test_charging_manager.py +++ b/tests/unit/actor/test_charging_manager.py @@ -247,6 +247,36 @@ async def test_get_max_total_charge_usd(mock_client: MagicMock) -> None: assert cm.get_max_total_charge_usd() == Decimal('42.50') +async def test_max_total_charge_usd_zero_blocks_charging(mock_client: MagicMock) -> None: + """Test max_total_charge_usd=0 is preserved and blocks all charging (not silently converted to inf).""" + pricing_info = _make_ppe_pricing_info({'search': Decimal('0.01')}) + config = _make_config( + test_pay_per_event=True, + actor_pricing_info=pricing_info, + charged_event_counts={}, + max_total_charge_usd=Decimal(0), + ) + cm = ChargingManagerImplementation(config, mock_client) + async with cm: + assert cm.get_max_total_charge_usd() == Decimal(0) + assert cm.is_event_charge_limit_reached('search') is True + result = await cm.charge('search', count=5) + assert result.charged_count == 0 + assert result.event_charge_limit_reached is True + + +async def test_max_total_charge_usd_zero_preserved_without_pricing(mock_client: MagicMock) -> None: + """Test max_total_charge_usd=0 is preserved through the no-pricing fallback path.""" + config = _make_config( + max_total_charge_usd=Decimal(0), + actor_pricing_info=None, + charged_event_counts={}, + ) + cm = ChargingManagerImplementation(config, mock_client) + async with cm: + assert cm.get_max_total_charge_usd() == Decimal(0) + + async def test_compute_push_data_limit_no_ppe(mock_client: MagicMock) -> None: """Returns items_count when no PPE pricing is configured (prices are zero).""" config = _make_config(actor_pricing_info=None, charged_event_counts={})