From 0faf061005f77970967dc9006e5f9e8b480d4939 Mon Sep 17 00:00:00 2001 From: Vlada Dusek Date: Wed, 22 Apr 2026 09:47:12 +0200 Subject: [PATCH 1/3] fix: preserve count=0 in RunClient.charge MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The billing endpoint body was built with `count or 1`, which silently rewrote a legitimate `count=0` to `count=1` — on a pay-per-event endpoint, the difference is financially material. Use an explicit `None` check instead. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/apify_client/_resource_clients/run.py | 10 ++- tests/unit/test_run_charge.py | 89 +++++++++++++++++++++++ 2 files changed, 95 insertions(+), 4 deletions(-) create mode 100644 tests/unit/test_run_charge.py diff --git a/src/apify_client/_resource_clients/run.py b/src/apify_client/_resource_clients/run.py index 9f8a69c4..ffcea254 100644 --- a/src/apify_client/_resource_clients/run.py +++ b/src/apify_client/_resource_clients/run.py @@ -385,7 +385,8 @@ def charge( Args: event_name: The name of the event to charge for. - count: The number of events to charge. Defaults to 1 if not provided. + count: The number of events to charge. Defaults to 1 when `None`; other values, + including 0, are sent to the server as-is. idempotency_key: A unique key to ensure idempotent charging. If not provided, one will be auto-generated. timeout: Timeout for the API HTTP request. @@ -411,7 +412,7 @@ def charge( data=json.dumps( { 'eventName': event_name, - 'count': count or 1, + 'count': count if count is not None else 1, } ), timeout=timeout, @@ -811,7 +812,8 @@ async def charge( Args: event_name: The name of the event to charge for. - count: The number of events to charge. Defaults to 1 if not provided. + count: The number of events to charge. Defaults to 1 when `None`; other values, + including 0, are sent to the server as-is. idempotency_key: A unique key to ensure idempotent charging. If not provided, one will be auto-generated. timeout: Timeout for the API HTTP request. @@ -837,7 +839,7 @@ async def charge( data=json.dumps( { 'eventName': event_name, - 'count': count or 1, + 'count': count if count is not None else 1, } ), timeout=timeout, diff --git a/tests/unit/test_run_charge.py b/tests/unit/test_run_charge.py new file mode 100644 index 00000000..d007033e --- /dev/null +++ b/tests/unit/test_run_charge.py @@ -0,0 +1,89 @@ +from __future__ import annotations + +import gzip +import json +from typing import TYPE_CHECKING + +import pytest +from werkzeug import Request, Response + +from apify_client import ApifyClient, ApifyClientAsync + +if TYPE_CHECKING: + from pytest_httpserver import HTTPServer + +_MOCKED_RUN_ID = 'test_run_id' +_CHARGE_PATH = f'/v2/actor-runs/{_MOCKED_RUN_ID}/charge' + + +def _decode_body(request: Request) -> dict: + raw = request.get_data() + if request.headers.get('Content-Encoding') == 'gzip': + raw = gzip.decompress(raw) + return json.loads(raw) + + +@pytest.mark.parametrize( + ('count', 'expected'), + [ + (None, 1), + (0, 0), + (1, 1), + (5, 5), + ], +) +def test_run_charge_preserves_count_sync( + httpserver: HTTPServer, + count: int | None, + expected: int, +) -> None: + """Ensure `count` is sent as-is; only `None` falls back to 1 (in particular, `0` is preserved).""" + captured_requests: list[Request] = [] + + def capture_request(request: Request) -> Response: + captured_requests.append(request) + return Response(status=200, mimetype='application/json') + + httpserver.expect_request(_CHARGE_PATH, method='POST').respond_with_handler(capture_request) + + api_url = httpserver.url_for('/').removesuffix('/') + client = ApifyClient(token='test_token', api_url=api_url) + + client.run(_MOCKED_RUN_ID).charge(event_name='test-event', count=count) + + assert len(captured_requests) == 1 + body = _decode_body(captured_requests[0]) + assert body['count'] == expected + + +@pytest.mark.parametrize( + ('count', 'expected'), + [ + (None, 1), + (0, 0), + (1, 1), + (5, 5), + ], +) +async def test_run_charge_preserves_count_async( + httpserver: HTTPServer, + count: int | None, + expected: int, +) -> None: + """Async variant of `test_run_charge_preserves_count_sync`.""" + captured_requests: list[Request] = [] + + def capture_request(request: Request) -> Response: + captured_requests.append(request) + return Response(status=200, mimetype='application/json') + + httpserver.expect_request(_CHARGE_PATH, method='POST').respond_with_handler(capture_request) + + api_url = httpserver.url_for('/').removesuffix('/') + client = ApifyClientAsync(token='test_token', api_url=api_url) + + await client.run(_MOCKED_RUN_ID).charge(event_name='test-event', count=count) + + assert len(captured_requests) == 1 + body = _decode_body(captured_requests[0]) + assert body['count'] == expected From 8c7ad3df6ffaada62937adb664fa50220d3f6a93 Mon Sep 17 00:00:00 2001 From: Vlada Dusek Date: Mon, 27 Apr 2026 09:57:30 +0200 Subject: [PATCH 2/3] fix: simplify count default in RunClient.charge to int = 1 Per review on PR #751, replace `count: int | None = None` + sentinel check with a plain `count: int = 1` default, so `0` is preserved naturally and the `None` branch disappears. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/apify_client/_resource_clients/run.py | 14 +++++------ tests/unit/test_run_charge.py | 30 +++++++---------------- 2 files changed, 15 insertions(+), 29 deletions(-) diff --git a/src/apify_client/_resource_clients/run.py b/src/apify_client/_resource_clients/run.py index ffcea254..6cb26dc7 100644 --- a/src/apify_client/_resource_clients/run.py +++ b/src/apify_client/_resource_clients/run.py @@ -375,7 +375,7 @@ def get_streamed_log( def charge( self, event_name: str, - count: int | None = None, + count: int = 1, idempotency_key: str | None = None, timeout: Timeout = 'short', ) -> None: @@ -385,8 +385,7 @@ def charge( Args: event_name: The name of the event to charge for. - count: The number of events to charge. Defaults to 1 when `None`; other values, - including 0, are sent to the server as-is. + count: The number of events to charge. Sent to the server as-is, including 0. idempotency_key: A unique key to ensure idempotent charging. If not provided, one will be auto-generated. timeout: Timeout for the API HTTP request. @@ -412,7 +411,7 @@ def charge( data=json.dumps( { 'eventName': event_name, - 'count': count if count is not None else 1, + 'count': count, } ), timeout=timeout, @@ -802,7 +801,7 @@ async def get_streamed_log( async def charge( self, event_name: str, - count: int | None = None, + count: int = 1, idempotency_key: str | None = None, timeout: Timeout = 'short', ) -> None: @@ -812,8 +811,7 @@ async def charge( Args: event_name: The name of the event to charge for. - count: The number of events to charge. Defaults to 1 when `None`; other values, - including 0, are sent to the server as-is. + count: The number of events to charge. Sent to the server as-is, including 0. idempotency_key: A unique key to ensure idempotent charging. If not provided, one will be auto-generated. timeout: Timeout for the API HTTP request. @@ -839,7 +837,7 @@ async def charge( data=json.dumps( { 'eventName': event_name, - 'count': count if count is not None else 1, + 'count': count, } ), timeout=timeout, diff --git a/tests/unit/test_run_charge.py b/tests/unit/test_run_charge.py index d007033e..9fd8ea3f 100644 --- a/tests/unit/test_run_charge.py +++ b/tests/unit/test_run_charge.py @@ -24,20 +24,14 @@ def _decode_body(request: Request) -> dict: @pytest.mark.parametrize( - ('count', 'expected'), - [ - (None, 1), - (0, 0), - (1, 1), - (5, 5), - ], + 'count', + [0, 1, 5], ) def test_run_charge_preserves_count_sync( httpserver: HTTPServer, - count: int | None, - expected: int, + count: int, ) -> None: - """Ensure `count` is sent as-is; only `None` falls back to 1 (in particular, `0` is preserved).""" + """Ensure `count` is sent as-is (in particular, `0` is preserved).""" captured_requests: list[Request] = [] def capture_request(request: Request) -> Response: @@ -53,22 +47,16 @@ def capture_request(request: Request) -> Response: assert len(captured_requests) == 1 body = _decode_body(captured_requests[0]) - assert body['count'] == expected + assert body['count'] == count @pytest.mark.parametrize( - ('count', 'expected'), - [ - (None, 1), - (0, 0), - (1, 1), - (5, 5), - ], + 'count', + [0, 1, 5], ) async def test_run_charge_preserves_count_async( httpserver: HTTPServer, - count: int | None, - expected: int, + count: int, ) -> None: """Async variant of `test_run_charge_preserves_count_sync`.""" captured_requests: list[Request] = [] @@ -86,4 +74,4 @@ def capture_request(request: Request) -> Response: assert len(captured_requests) == 1 body = _decode_body(captured_requests[0]) - assert body['count'] == expected + assert body['count'] == count From d6ea93b1a9b069269762b64401b031215034b596 Mon Sep 17 00:00:00 2001 From: Vlada Dusek Date: Mon, 27 Apr 2026 10:00:15 +0200 Subject: [PATCH 3/3] improve --- src/apify_client/_resource_clients/run.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/apify_client/_resource_clients/run.py b/src/apify_client/_resource_clients/run.py index 6cb26dc7..a7677467 100644 --- a/src/apify_client/_resource_clients/run.py +++ b/src/apify_client/_resource_clients/run.py @@ -385,7 +385,7 @@ def charge( Args: event_name: The name of the event to charge for. - count: The number of events to charge. Sent to the server as-is, including 0. + count: The number of events to charge. idempotency_key: A unique key to ensure idempotent charging. If not provided, one will be auto-generated. timeout: Timeout for the API HTTP request. @@ -811,7 +811,7 @@ async def charge( Args: event_name: The name of the event to charge for. - count: The number of events to charge. Sent to the server as-is, including 0. + count: The number of events to charge. idempotency_key: A unique key to ensure idempotent charging. If not provided, one will be auto-generated. timeout: Timeout for the API HTTP request.