Skip to content

Commit 3f25d4a

Browse files
authored
test: consolidate test utilities into a shared tests/_utils.py and deflake via polling (#920)
## Summary The SDK counterpart of apify/apify-client-python#844: introduce a single shared `tests/_utils.py` with one polling helper, and migrate the whole test suite to it — replacing fixed sleeps and hand-rolled retry loops that caused flakiness. ## Changes - **Shared `tests/_utils.py`.** One `poll_until_condition(fn, condition=bool, *, timeout, poll_interval, backoff_factor)` helper — identical to the one in apify-client-python#844 — polls a sync-or-async callable until a condition holds or a wall-clock timeout expires. `backoff_factor=2` subsumes the former `call_with_exp_backoff`; `timeout=0` is the "call once" case. The `maybe_await` adapter, `generate_unique_resource_name`, and the shared RSA crypto test keys move here too. The per-package `tests/integration/_utils.py` and `tests/e2e/_utils.py` are removed, and cross-test imports (e.g. the crypto keys, previously imported from the `test_crypto` module) now go through this single module. - **Request queue tests.** ~50 polling call sites in `test_request_queue.py` migrated to `poll_until_condition`. The single/shared timeout is centralized in an `rq_poll_timeout` fixture (`timeout=0` single, `timeout=30` shared), backed by an `rq_access_mode` fixture — replacing the access-mode derivation block previously copy-pasted into every test. The shared-mode request-ordering relaxations from #931 are preserved on top. - **Deflaked tests.** Fixed the flaky `test_actor_adds_webhook_and_receives_event` e2e test (the client now stays alive 5 s after `add_webhook`, and the unbounded `INITIALIZED` startup loop becomes bounded polling) — replaces #930. Replaced the `retry_counter` loops in `test_actor_charge.py` (4×) and fixed sleeps in `test_actor_lifecycle`, `test_actor_key_value_store`, and `test_apify_event_manager` with condition polling. Fixed sleeps that are semantically required are intentionally kept: negative checks ("event must NOT fire"), mtime-granularity checks, simulated latency, and sleeps inside deployed Actor `main` bodies (where the helper is unavailable). Verified: lint, type-check, and the full unit suite pass; integration/e2e require a platform token and run in CI.
1 parent 792fcc6 commit 3f25d4a

15 files changed

Lines changed: 344 additions & 299 deletions

tests/__init__.py

Whitespace-only changes.

tests/_utils.py

Lines changed: 99 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,99 @@
1+
from __future__ import annotations
2+
3+
import asyncio
4+
import inspect
5+
import time
6+
from typing import TYPE_CHECKING, TypeVar, cast, overload
7+
8+
from crawlee._utils.crypto import crypto_random_object_id
9+
10+
from apify._crypto import _load_public_key, load_private_key
11+
12+
if TYPE_CHECKING:
13+
from collections.abc import Awaitable, Callable
14+
15+
T = TypeVar('T')
16+
17+
18+
async def maybe_await(value: Awaitable[T] | T) -> T:
19+
"""Await `value` if it is awaitable, otherwise return it unchanged.
20+
21+
Lets `poll_until_condition` accept both sync and async callables.
22+
"""
23+
if inspect.isawaitable(value):
24+
return await cast('Awaitable[T]', value)
25+
return cast('T', value)
26+
27+
28+
@overload
29+
async def poll_until_condition(
30+
fn: Callable[[], Awaitable[T]],
31+
condition: Callable[[T], bool] = ...,
32+
*,
33+
timeout: float = ...,
34+
poll_interval: float = ...,
35+
backoff_factor: float = ...,
36+
) -> T: ...
37+
@overload
38+
async def poll_until_condition(
39+
fn: Callable[[], T],
40+
condition: Callable[[T], bool] = ...,
41+
*,
42+
timeout: float = ...,
43+
poll_interval: float = ...,
44+
backoff_factor: float = ...,
45+
) -> T: ...
46+
async def poll_until_condition(
47+
fn: Callable[[], Awaitable[T] | T],
48+
condition: Callable[[T], bool] = bool,
49+
*,
50+
timeout: float = 5,
51+
poll_interval: float = 1,
52+
backoff_factor: float = 1,
53+
) -> T:
54+
"""Poll `fn` until `condition(result)` is True or the timeout expires.
55+
56+
Polls `fn` at `poll_interval`-second intervals until `condition` is satisfied or `timeout` seconds have elapsed.
57+
Returns the last polled result regardless of whether the condition was met, so the caller can run its own
58+
assertion. The default condition checks for a truthy result. Pass `timeout=0` to call `fn` exactly once.
59+
60+
Use this instead of a fixed `asyncio.sleep` when waiting for eventually-consistent state (e.g. a freshly
61+
created resource appearing in a listing) that may take a variable amount of time to propagate. For highly
62+
variable wait times (e.g. an Actor run container starting up), pass `backoff_factor` > 1 to multiply the
63+
interval after each poll, covering a long timeout with few calls.
64+
"""
65+
deadline = time.monotonic() + timeout
66+
delay = poll_interval
67+
result = await maybe_await(fn())
68+
while not condition(result):
69+
remaining = deadline - time.monotonic()
70+
if remaining <= 0:
71+
break
72+
await asyncio.sleep(min(delay, remaining))
73+
delay *= backoff_factor
74+
result = await maybe_await(fn())
75+
return result
76+
77+
78+
def generate_unique_resource_name(label: str) -> str:
79+
"""Generates a unique resource name, which will contain the given label."""
80+
name_template = 'python-sdk-tests-{}-generated-{}'
81+
template_length = len(name_template.format('', ''))
82+
api_name_limit = 63
83+
generated_random_id_length = 8
84+
label_length_limit = api_name_limit - template_length - generated_random_id_length
85+
86+
label = label.replace('_', '-')
87+
assert len(label) <= label_length_limit, f'Max label length is {label_length_limit}, but got {len(label)}'
88+
89+
return name_template.format(label, crypto_random_object_id(generated_random_id_length))
90+
91+
92+
# RSA test key material shared across crypto-related tests.
93+
# NOTE: Uses the same keys as in:
94+
# https://github.com/apify/apify-shared-js/blob/master/test/crypto.test.ts
95+
PRIVATE_KEY_PEM_BASE64 = 'LS0tLS1CRUdJTiBSU0EgUFJJVkFURSBLRVktLS0tLQpQcm9jLVR5cGU6IDQsRU5DUllQVEVECkRFSy1JbmZvOiBERVMtRURFMy1DQkMsNTM1QURERjIzNUQ4QkFGOQoKMXFWUzl0S0FhdkVhVUVFMktESnpjM3plMk1lZkc1dmVEd2o1UVJ0ZkRaMXdWNS9VZmIvcU5sVThTSjlNaGhKaQp6RFdrWExueUUzSW0vcEtITVZkS0czYWZkcFRtcis2TmtidXptd0dVMk0vSWpzRjRJZlpad0lGbGJoY09jUnp4CmZmWVIvTlVyaHNrS1RpNGhGV0lBUDlLb3Z6VDhPSzNZY3h6eVZQWUxYNGVWbWt3UmZzeWkwUU5Xb0tGT3d0ZC8KNm9HYzFnd2piRjI5ZDNnUThZQjFGWmRLa1AyMTJGbkt1cTIrUWgvbE1zTUZrTHlTQTRLTGJ3ZG1RSXExbE1QUwpjbUNtZnppV3J1MlBtNEZoM0dmWlQyaE1JWHlIRFdEVzlDTkxKaERodExOZ2RRamFBUFpVT1E4V2hwSkE5MS9vCjJLZzZ3MDd5Z2RCcVd5dTZrc0pXcjNpZ1JpUEJ5QmVNWEpEZU5HY3NhaUZ3Q2c5eFlja1VORXR3NS90WlRsTjIKSEdZV0NpVU5Ed0F2WllMUHR1SHpIOFRFMGxsZm5HR0VuVC9QQlp1UHV4andlZlRleE1mdzFpbGJRU3lkcy9HMgpOOUlKKzkydms0N0ZXR2NOdGh1Q3lCbklva0NpZ0c1ZlBlV2IwQTdpdjk0UGtwRTRJZ3plc0hGQ0ZFQWoxWldLCnpQdFRBQlkwZlJrUzBNc3UwMHYxOXloTTUrdFUwYkVCZWo2eWpzWHRoYzlwS01hcUNIZWlQTC9TSHRkaWsxNVMKQmU4Sml4dVJxZitUeGlYWWVuNTg2aDlzTFpEYzA3cGpkUGp2NVNYRnBYQjhIMlVxQ0tZY2p4R3RvQWpTV0pjWApMNHc3RHNEby80bVg1N0htR09iamlCN1ZyOGhVWEJDdFh2V0dmQXlmcEFZNS9vOXowdm4zREcxaDc1NVVwdDluCkF2MFZrbm9qcmJVYjM1ZlJuU1lYTVltS01LSnpNRlMrdmFvRlpwV0ZjTG10cFRWSWNzc0JGUEYyZEo3V1c0WHMKK0d2Vkl2eFl3S2wyZzFPTE1TTXRZa09vekdlblBXTzdIdU0yMUVKVGIvbHNEZ25GaTkrYWRGZHBLY3R2cm0zdgpmbW1HeG5pRmhLU05GU0xtNms5YStHL2pjK3NVQVBhb2FZNEQ3NHVGajh0WGp0eThFUHdRRGxVUGRVZld3SE9PClF3bVgyMys1REh4V0VoQy91Tm8yNHNNY2ZkQzFGZUpBV281bUNuVU5vUVVmMStNRDVhMzNJdDhhMmlrNUkxUWoKeSs1WGpRaG0xd3RBMWhWTWE4aUxBR0toT09lcFRuK1VBZHpyS0hvNjVtYzNKbGgvSFJDUXJabnVxWkErK0F2WgpjeWU0dWZGWC8xdmRQSTdLb2Q0MEdDM2dlQnhweFFNYnp1OFNUcGpOcElJRkJvRVc5dFRhemUzeHZXWnV6dDc0CnFjZS8xWURuUHBLeW5lM0xGMk94VWoyYWVYUW5YQkpYcGhTZTBVTGJMcWJtUll4bjJKWkl1d09RNHV5dm94NjUKdG9TWGNac054dUs4QTErZXNXR3JSN3pVc0djdU9QQTFERE9Ja2JjcGtmRUxMNjk4RTJRckdqTU9JWnhrcWdxZQoySE5VNktWRmV2NzdZeEJDbm1VcVdXZEhYMjcyU2NPMUYzdWpUdFVnRVBNWGN0aEdBckYzTWxEaUw1Q0k0RkhqCnhHc3pVemxzalRQTmpiY2MzdUE2MjVZS3VVZEI2c1h1Rk5NUHk5UDgwTzBpRWJGTXl3MWxmN2VpdFhvaUUxWVoKc3NhMDVxTUx4M3pPUXZTLzFDdFpqaFp4cVJMRW5pQ3NWa2JVRlVYclpodEU4dG94bGpWSUtpQ25qbitORmtqdwo2bTZ1anpBSytZZHd2Nk5WMFB4S0gwUk5NYVhwb1lmQk1oUmZ3dGlaS3V3Y2hyRFB5UEhBQ2J3WXNZOXdtUE9rCnpwdDNxWi9JdDVYTmVqNDI0RzAzcGpMbk1sd1B1T1VzYmFQUWQ2VHU4TFhsckZReUVjTXJDNHdjUTA1SzFVN3kKM1NNN3RFaTlnbjV3RjY1YVI5eEFBR0grTUtMMk5WNnQrUmlTazJVaWs1clNmeDE4Mk9wYmpSQ2grdmQ4UXhJdwotLS0tLUVORCBSU0EgUFJJVkFURSBLRVktLS0tLQo=' # noqa: E501
96+
PRIVATE_KEY_PASSWORD = 'pwd1234'
97+
PUBLIC_KEY_PEM_BASE64 = 'LS0tLS1CRUdJTiBQVUJMSUMgS0VZLS0tLS0KTUlJQklqQU5CZ2txaGtpRzl3MEJBUUVGQUFPQ0FROEFNSUlCQ2dLQ0FRRUF0dis3NlNXbklhOFFKWC94RUQxRQpYdnBBQmE3ajBnQnVYenJNUU5adjhtTW1RU0t2VUF0TmpOL2xacUZpQ0haZUQxU2VDcGV1MnFHTm5XbGRxNkhUCnh5cXJpTVZEbFNKaFBNT09QSENISVNVdFI4Tk5lR1Y1MU0wYkxJcENabHcyTU9GUjdqdENWejVqZFRpZ1NvYTIKQWxrRUlRZWQ4UVlDKzk1aGJoOHk5bGcwQ0JxdEdWN1FvMFZQR2xKQ0hGaWNuaWxLVFFZay9MZzkwWVFnUElPbwozbUppeFl5bWFGNmlMZTVXNzg1M0VHWUVFVWdlWmNaZFNjaGVBMEdBMGpRSFVTdnYvMEZjay9adkZNZURJOTVsCmJVQ0JoQjFDbFg4OG4wZUhzUmdWZE5vK0NLMDI4T2IvZTZTK1JLK09VaHlFRVdPTi90alVMdGhJdTJkQWtGcmkKOFFJREFRQUIKLS0tLS1FTkQgUFVCTElDIEtFWS0tLS0tCg==' # noqa: E501
98+
PRIVATE_KEY = load_private_key(PRIVATE_KEY_PEM_BASE64, PRIVATE_KEY_PASSWORD)
99+
PUBLIC_KEY = _load_public_key(PUBLIC_KEY_PEM_BASE64)

tests/e2e/_utils.py

Lines changed: 0 additions & 17 deletions
This file was deleted.

tests/e2e/conftest.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@
1717
from crawlee import service_locator
1818

1919
import apify._actor
20-
from ._utils import generate_unique_resource_name
20+
from .._utils import generate_unique_resource_name
2121
from apify._models import ActorRun
2222
from apify.storage_clients._apify._alias_resolving import AliasResolver
2323

tests/e2e/test_actor_api_helpers.py

Lines changed: 26 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@
77
from apify_shared.consts import ActorPermissionLevel
88
from crawlee._utils.crypto import crypto_random_object_id
99

10-
from ._utils import generate_unique_resource_name
10+
from .._utils import generate_unique_resource_name, poll_until_condition
1111
from apify import Actor
1212
from apify._models import ActorRun
1313

@@ -393,6 +393,7 @@ async def test_actor_adds_webhook_and_receives_event(
393393
) -> None:
394394
async def main_server() -> None:
395395
import os
396+
import time
396397
from http.server import BaseHTTPRequestHandler, HTTPServer
397398

398399
from apify_shared.consts import ActorEnvVars
@@ -419,12 +420,19 @@ def do_POST(self) -> None:
419420
container_port = int(os.getenv(ActorEnvVars.WEB_SERVER_PORT, ''))
420421
with HTTPServer(('', container_port), WebhookHandler) as server:
421422
await Actor.set_value('INITIALIZED', value=True)
422-
while not webhook_body:
423+
# Bound the wait so that a webhook that never fires (e.g. one that did not propagate before the
424+
# client run finished) surfaces as an empty WEBHOOK_BODY in the test instead of blocking here
425+
# until the run times out.
426+
server.timeout = 5
427+
deadline = time.monotonic() + 300
428+
while not webhook_body and time.monotonic() < deadline:
423429
server.handle_request()
424430

425431
await Actor.set_value('WEBHOOK_BODY', webhook_body)
426432

427433
async def main_client() -> None:
434+
import asyncio
435+
428436
from apify import Webhook, WebhookEventType
429437

430438
async with Actor:
@@ -438,6 +446,12 @@ async def main_client() -> None:
438446
)
439447
)
440448

449+
# Keep the run alive for a moment after registering the webhook. Without this, the run finishes
450+
# just milliseconds later and the platform may process the run-succeeded event before the freshly
451+
# added ad-hoc webhook has propagated, in which case the webhook never fires and the server Actor
452+
# waits until it times out.
453+
await asyncio.sleep(5)
454+
441455
server_actor, client_actor = await asyncio.gather(
442456
make_actor(label='add-webhook-server', main_func=main_server),
443457
make_actor(label='add-webhook-client', main_func=main_client),
@@ -446,10 +460,15 @@ async def main_client() -> None:
446460
server_actor_run = await server_actor.start()
447461
server_actor_container_url = server_actor_run['containerUrl']
448462

449-
server_actor_initialized = await server_actor.last_run().key_value_store().get_record('INITIALIZED')
450-
while not server_actor_initialized:
451-
server_actor_initialized = await server_actor.last_run().key_value_store().get_record('INITIALIZED')
452-
await asyncio.sleep(1)
463+
# Wait for the server Actor's container to start up and bind its HTTP server. The startup time is highly
464+
# variable (image pull, container creation), so poll with a growing interval instead of a fixed sleep.
465+
server_actor_initialized = await poll_until_condition(
466+
lambda: server_actor.last_run().key_value_store().get_record('INITIALIZED'),
467+
timeout=300,
468+
poll_interval=1,
469+
backoff_factor=1.5,
470+
)
471+
assert server_actor_initialized is not None, 'The server Actor did not initialize in time.'
453472

454473
ac_run_result = await run_actor(
455474
client_actor,
@@ -465,7 +484,7 @@ async def main_client() -> None:
465484

466485
webhook_body_record = await server_actor.last_run().key_value_store().get_record('WEBHOOK_BODY')
467486
assert webhook_body_record is not None
468-
assert webhook_body_record['value'] != ''
487+
assert webhook_body_record['value'] != '', 'The ad-hoc webhook never fired (it likely did not propagate in time).'
469488
parsed_webhook_body = json.loads(webhook_body_record['value'])
470489

471490
assert parsed_webhook_body['eventData']['actorId'] == ac_run_result.act_id

tests/e2e/test_actor_charge.py

Lines changed: 59 additions & 70 deletions
Original file line numberDiff line numberDiff line change
@@ -1,25 +1,29 @@
11
from __future__ import annotations
22

3-
import asyncio
43
from decimal import Decimal
4+
from functools import partial
55
from typing import TYPE_CHECKING
66

77
import pytest_asyncio
88

99
from apify_shared.consts import ActorJobStatus
1010

11+
from .._utils import poll_until_condition
1112
from apify import Actor
1213
from apify._models import ActorRun
1314

1415
if TYPE_CHECKING:
15-
from collections.abc import Iterable
16-
1716
from apify_client import ApifyClientAsync
1817
from apify_client.clients import ActorClientAsync
1918

2019
from .conftest import MakeActorFunction, RunActorFunction
2120

2221

22+
async def _get_run(apify_client_async: ApifyClientAsync, run_id: str) -> ActorRun:
23+
"""Fetch the current state of the given run from the platform."""
24+
return ActorRun.model_validate(await apify_client_async.run(run_id).get())
25+
26+
2327
@pytest_asyncio.fixture(scope='module', loop_scope='module')
2428
async def ppe_push_data_actor_build(make_actor: MakeActorFunction) -> str:
2529
async def main() -> None:
@@ -112,33 +116,23 @@ async def ppe_actor(
112116
return apify_client_async.actor(ppe_actor_build)
113117

114118

115-
def retry_counter(total_attempts: int) -> Iterable[tuple[bool, int]]:
116-
for retry in range(total_attempts - 1):
117-
yield False, retry
118-
119-
yield True, total_attempts - 1
120-
121-
122119
async def test_actor_charge_basic(
123120
ppe_actor: ActorClientAsync,
124121
run_actor: RunActorFunction,
125122
apify_client_async: ApifyClientAsync,
126123
) -> None:
127124
run = await run_actor(ppe_actor)
128125

129-
# Refetch until the platform gets its act together
130-
for is_last_attempt, _ in retry_counter(30):
131-
await asyncio.sleep(1)
132-
updated_run = await apify_client_async.run(run.id).get()
133-
run = ActorRun.model_validate(updated_run)
126+
# Refetch until the charged event counts propagate on the platform.
127+
run = await poll_until_condition(
128+
partial(_get_run, apify_client_async, run.id),
129+
lambda r: r.status == ActorJobStatus.SUCCEEDED and r.charged_event_counts == {'foobar': 4},
130+
timeout=30,
131+
poll_interval=1,
132+
)
134133

135-
try:
136-
assert run.status == ActorJobStatus.SUCCEEDED
137-
assert run.charged_event_counts == {'foobar': 4}
138-
break
139-
except AssertionError:
140-
if is_last_attempt:
141-
raise
134+
assert run.status == ActorJobStatus.SUCCEEDED
135+
assert run.charged_event_counts == {'foobar': 4}
142136

143137

144138
async def test_actor_charge_limit(
@@ -148,19 +142,16 @@ async def test_actor_charge_limit(
148142
) -> None:
149143
run = await run_actor(ppe_actor, max_total_charge_usd=Decimal('0.2'))
150144

151-
# Refetch until the platform gets its act together
152-
for is_last_attempt, _ in retry_counter(30):
153-
await asyncio.sleep(1)
154-
updated_run = await apify_client_async.run(run.id).get()
155-
run = ActorRun.model_validate(updated_run)
145+
# Refetch until the charged event counts propagate on the platform.
146+
run = await poll_until_condition(
147+
partial(_get_run, apify_client_async, run.id),
148+
lambda r: r.status == ActorJobStatus.SUCCEEDED and r.charged_event_counts == {'foobar': 2},
149+
timeout=30,
150+
poll_interval=1,
151+
)
156152

157-
try:
158-
assert run.status == ActorJobStatus.SUCCEEDED
159-
assert run.charged_event_counts == {'foobar': 2}
160-
break
161-
except AssertionError:
162-
if is_last_attempt:
163-
raise
153+
assert run.status == ActorJobStatus.SUCCEEDED
154+
assert run.charged_event_counts == {'foobar': 2}
164155

165156

166157
async def test_actor_push_data_charges_both_events(
@@ -171,24 +162,23 @@ async def test_actor_push_data_charges_both_events(
171162
"""Test that push_data charges both the explicit event and the synthetic apify-default-dataset-item event."""
172163
run = await run_actor(ppe_push_data_actor)
173164

174-
# Use a longer retry window (120 attempts x 1 s) for synthetic events like `apify-default-dataset-item`:
175-
# the platform computes them from dataset writes asynchronously, so they propagate more slowly than
176-
# explicit charges (which are reflected immediately via the charge endpoint).
177-
for is_last_attempt, _ in retry_counter(120):
178-
await asyncio.sleep(1)
179-
updated_run = await apify_client_async.run(run.id).get()
180-
run = ActorRun.model_validate(updated_run)
181-
182-
try:
183-
assert run.status == ActorJobStatus.SUCCEEDED
184-
assert run.charged_event_counts == {
185-
'push-item': 5,
186-
'apify-default-dataset-item': 5,
187-
}
188-
break
189-
except AssertionError:
190-
if is_last_attempt:
191-
raise
165+
expected_counts = {
166+
'push-item': 5,
167+
'apify-default-dataset-item': 5,
168+
}
169+
170+
# Use a longer timeout for synthetic events like `apify-default-dataset-item`: the platform computes them
171+
# from dataset writes asynchronously, so they propagate more slowly than explicit charges (which are
172+
# reflected immediately via the charge endpoint).
173+
run = await poll_until_condition(
174+
partial(_get_run, apify_client_async, run.id),
175+
lambda r: r.status == ActorJobStatus.SUCCEEDED and r.charged_event_counts == expected_counts,
176+
timeout=120,
177+
poll_interval=1,
178+
)
179+
180+
assert run.status == ActorJobStatus.SUCCEEDED
181+
assert run.charged_event_counts == expected_counts
192182

193183

194184
async def test_actor_push_data_combined_budget_limit(
@@ -202,21 +192,20 @@ async def test_actor_push_data_combined_budget_limit(
202192
"""
203193
run = await run_actor(ppe_push_data_actor, max_total_charge_usd=Decimal('0.20'))
204194

205-
# Use a longer retry window (120 attempts x 1 s) for synthetic events like `apify-default-dataset-item`:
206-
# the platform computes them from dataset writes asynchronously, so they propagate more slowly than
207-
# explicit charges (which are reflected immediately via the charge endpoint).
208-
for is_last_attempt, _ in retry_counter(120):
209-
await asyncio.sleep(1)
210-
updated_run = await apify_client_async.run(run.id).get()
211-
run = ActorRun.model_validate(updated_run)
212-
213-
try:
214-
assert run.status == ActorJobStatus.SUCCEEDED
215-
assert run.charged_event_counts == {
216-
'push-item': 2,
217-
'apify-default-dataset-item': 2,
218-
}
219-
break
220-
except AssertionError:
221-
if is_last_attempt:
222-
raise
195+
expected_counts = {
196+
'push-item': 2,
197+
'apify-default-dataset-item': 2,
198+
}
199+
200+
# Use a longer timeout for synthetic events like `apify-default-dataset-item`: the platform computes them
201+
# from dataset writes asynchronously, so they propagate more slowly than explicit charges (which are
202+
# reflected immediately via the charge endpoint).
203+
run = await poll_until_condition(
204+
partial(_get_run, apify_client_async, run.id),
205+
lambda r: r.status == ActorJobStatus.SUCCEEDED and r.charged_event_counts == expected_counts,
206+
timeout=120,
207+
poll_interval=1,
208+
)
209+
210+
assert run.status == ActorJobStatus.SUCCEEDED
211+
assert run.charged_event_counts == expected_counts

0 commit comments

Comments
 (0)