Skip to content

Commit 0a8942c

Browse files
vdusekclaude
andauthored
fix: Preserve count=0 in RunClient's charge (#751)
## Summary `RunClient.charge` (sync + async) built the request body with `'count': count or 1`. Because `or` treats `0` as falsy, a caller passing `count=0` — e.g. `count=len(batch)` for an empty batch, or a defensive no-op — was silently sent as `count=1`. On a pay-per-event billing endpoint that's a real overcharge, not a cosmetic bug. ## Fix Changed the signature from `count: int | None = None` to `count: int = 1` and pass `count` through to the request body unchanged. `0` now reaches the server as `0`; the default `1` is expressed in the signature instead of via a fallback. Docstring updated to reflect the new contract. ## Tests `tests/unit/test_run_charge.py` parametrizes the sync and async clients over `count` values `0`, `1`, `5` and asserts the value lands in the request body verbatim. The `0` case fails on `master` and passes here. --------- Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 4b426dd commit 0a8942c

2 files changed

Lines changed: 83 additions & 6 deletions

File tree

src/apify_client/_resource_clients/run.py

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -375,7 +375,7 @@ def get_streamed_log(
375375
def charge(
376376
self,
377377
event_name: str,
378-
count: int | None = None,
378+
count: int = 1,
379379
idempotency_key: str | None = None,
380380
timeout: Timeout = 'short',
381381
) -> None:
@@ -385,7 +385,7 @@ def charge(
385385
386386
Args:
387387
event_name: The name of the event to charge for.
388-
count: The number of events to charge. Defaults to 1 if not provided.
388+
count: The number of events to charge.
389389
idempotency_key: A unique key to ensure idempotent charging. If not provided,
390390
one will be auto-generated.
391391
timeout: Timeout for the API HTTP request.
@@ -411,7 +411,7 @@ def charge(
411411
data=json.dumps(
412412
{
413413
'eventName': event_name,
414-
'count': count or 1,
414+
'count': count,
415415
}
416416
),
417417
timeout=timeout,
@@ -801,7 +801,7 @@ async def get_streamed_log(
801801
async def charge(
802802
self,
803803
event_name: str,
804-
count: int | None = None,
804+
count: int = 1,
805805
idempotency_key: str | None = None,
806806
timeout: Timeout = 'short',
807807
) -> None:
@@ -811,7 +811,7 @@ async def charge(
811811
812812
Args:
813813
event_name: The name of the event to charge for.
814-
count: The number of events to charge. Defaults to 1 if not provided.
814+
count: The number of events to charge.
815815
idempotency_key: A unique key to ensure idempotent charging. If not provided,
816816
one will be auto-generated.
817817
timeout: Timeout for the API HTTP request.
@@ -837,7 +837,7 @@ async def charge(
837837
data=json.dumps(
838838
{
839839
'eventName': event_name,
840-
'count': count or 1,
840+
'count': count,
841841
}
842842
),
843843
timeout=timeout,

tests/unit/test_run_charge.py

Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,77 @@
1+
from __future__ import annotations
2+
3+
import gzip
4+
import json
5+
from typing import TYPE_CHECKING
6+
7+
import pytest
8+
from werkzeug import Request, Response
9+
10+
from apify_client import ApifyClient, ApifyClientAsync
11+
12+
if TYPE_CHECKING:
13+
from pytest_httpserver import HTTPServer
14+
15+
_MOCKED_RUN_ID = 'test_run_id'
16+
_CHARGE_PATH = f'/v2/actor-runs/{_MOCKED_RUN_ID}/charge'
17+
18+
19+
def _decode_body(request: Request) -> dict:
20+
raw = request.get_data()
21+
if request.headers.get('Content-Encoding') == 'gzip':
22+
raw = gzip.decompress(raw)
23+
return json.loads(raw)
24+
25+
26+
@pytest.mark.parametrize(
27+
'count',
28+
[0, 1, 5],
29+
)
30+
def test_run_charge_preserves_count_sync(
31+
httpserver: HTTPServer,
32+
count: int,
33+
) -> None:
34+
"""Ensure `count` is sent as-is (in particular, `0` is preserved)."""
35+
captured_requests: list[Request] = []
36+
37+
def capture_request(request: Request) -> Response:
38+
captured_requests.append(request)
39+
return Response(status=200, mimetype='application/json')
40+
41+
httpserver.expect_request(_CHARGE_PATH, method='POST').respond_with_handler(capture_request)
42+
43+
api_url = httpserver.url_for('/').removesuffix('/')
44+
client = ApifyClient(token='test_token', api_url=api_url)
45+
46+
client.run(_MOCKED_RUN_ID).charge(event_name='test-event', count=count)
47+
48+
assert len(captured_requests) == 1
49+
body = _decode_body(captured_requests[0])
50+
assert body['count'] == count
51+
52+
53+
@pytest.mark.parametrize(
54+
'count',
55+
[0, 1, 5],
56+
)
57+
async def test_run_charge_preserves_count_async(
58+
httpserver: HTTPServer,
59+
count: int,
60+
) -> None:
61+
"""Async variant of `test_run_charge_preserves_count_sync`."""
62+
captured_requests: list[Request] = []
63+
64+
def capture_request(request: Request) -> Response:
65+
captured_requests.append(request)
66+
return Response(status=200, mimetype='application/json')
67+
68+
httpserver.expect_request(_CHARGE_PATH, method='POST').respond_with_handler(capture_request)
69+
70+
api_url = httpserver.url_for('/').removesuffix('/')
71+
client = ApifyClientAsync(token='test_token', api_url=api_url)
72+
73+
await client.run(_MOCKED_RUN_ID).charge(event_name='test-event', count=count)
74+
75+
assert len(captured_requests) == 1
76+
body = _decode_body(captured_requests[0])
77+
assert body['count'] == count

0 commit comments

Comments
 (0)