Skip to content

Commit fffc7eb

Browse files
committed
test: consolidate polling into a single shared helper and deflake tests
Replace the two integration polling helpers with a single poll_until_condition in a shared tests/_utils.py, matching the helper in apify-client-python (with backoff_factor covering the exponential-backoff use case). Use it across integration, e2e, and unit tests instead of hand-rolled retry loops and fixed sleeps, and fix the flaky webhook e2e test by keeping the client run alive briefly after add_webhook so the run-succeeded event cannot outrun the webhook registration.
1 parent ee51886 commit fffc7eb

12 files changed

Lines changed: 236 additions & 235 deletions

tests/__init__.py

Whitespace-only changes.
Lines changed: 14 additions & 59 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,6 @@
22

33
import asyncio
44
import inspect
5-
import logging
65
import time
76
from typing import TYPE_CHECKING, TypeVar, cast, overload
87

@@ -11,76 +10,27 @@
1110
if TYPE_CHECKING:
1211
from collections.abc import Awaitable, Callable
1312

14-
logger = logging.getLogger(__name__)
15-
1613
T = TypeVar('T')
1714

1815

1916
async def _maybe_await(value: Awaitable[T] | T) -> T:
2017
"""Await `value` if it is awaitable, otherwise return it unchanged.
2118
22-
Lets `call_with_exp_backoff` and `poll_until_condition` accept both sync and async callables.
19+
Lets `poll_until_condition` accept both sync and async callables.
2320
"""
2421
if inspect.isawaitable(value):
2522
return await cast('Awaitable[T]', value)
2623
return cast('T', value)
2724

2825

29-
@overload
30-
async def call_with_exp_backoff(
31-
fn: Callable[[], Awaitable[T]],
32-
condition: Callable[[T], bool] = ...,
33-
*,
34-
max_retries: int = ...,
35-
base_delay: float = ...,
36-
) -> T: ...
37-
@overload
38-
async def call_with_exp_backoff(
39-
fn: Callable[[], T],
40-
condition: Callable[[T], bool] = ...,
41-
*,
42-
max_retries: int = ...,
43-
base_delay: float = ...,
44-
) -> T: ...
45-
async def call_with_exp_backoff(
46-
fn: Callable[[], Awaitable[T] | T],
47-
condition: Callable[[T], bool] = bool,
48-
*,
49-
max_retries: int = 5,
50-
base_delay: float = 1.0,
51-
) -> T:
52-
"""Call `fn`, retrying with exponential backoff until `condition(result)` is True.
53-
54-
Calls `fn` and checks whether `condition` holds for its result. If it does not, `fn` is retried up to
55-
`max_retries` times, sleeping `base_delay * 2 ** attempt` seconds before each retry. The last result is
56-
returned regardless of whether the condition was ever satisfied, so the caller can run its own assertion.
57-
58-
This is useful for eventually-consistent APIs where a freshly added, reclaimed, or handled item may take a
59-
moment to become visible (see https://github.com/apify/apify-sdk-python/issues/808). The default condition
60-
checks for a truthy result. Pass `max_retries=0` to call `fn` exactly once without any retries.
61-
62-
Unlike `poll_until_condition`, the delay between attempts grows exponentially rather than staying constant.
63-
"""
64-
result = await _maybe_await(fn())
65-
for attempt in range(max_retries):
66-
if condition(result):
67-
return result
68-
delay = base_delay * 2**attempt
69-
logger.info(
70-
'Condition not met for %r, retrying in %ss (attempt %d/%d).', result, delay, attempt + 1, max_retries
71-
)
72-
await asyncio.sleep(delay)
73-
result = await _maybe_await(fn())
74-
return result
75-
76-
7726
@overload
7827
async def poll_until_condition(
7928
fn: Callable[[], Awaitable[T]],
8029
condition: Callable[[T], bool] = ...,
8130
*,
8231
timeout: float = ...,
8332
poll_interval: float = ...,
33+
backoff_factor: float = ...,
8434
) -> T: ...
8535
@overload
8636
async def poll_until_condition(
@@ -89,31 +39,36 @@ async def poll_until_condition(
8939
*,
9040
timeout: float = ...,
9141
poll_interval: float = ...,
42+
backoff_factor: float = ...,
9243
) -> T: ...
9344
async def poll_until_condition(
9445
fn: Callable[[], Awaitable[T] | T],
9546
condition: Callable[[T], bool] = bool,
9647
*,
97-
timeout: float = 60,
98-
poll_interval: float = 5,
48+
timeout: float = 5,
49+
poll_interval: float = 1,
50+
backoff_factor: float = 1,
9951
) -> T:
10052
"""Poll `fn` until `condition(result)` is True or the timeout expires.
10153
10254
Polls `fn` at `poll_interval`-second intervals until `condition` is satisfied or `timeout` seconds have elapsed.
10355
Returns the last polled result regardless of whether the condition was met, so the caller can run its own
104-
assertion. The default condition checks for a truthy result.
56+
assertion. The default condition checks for a truthy result. Pass `timeout=0` to call `fn` exactly once.
10557
106-
Use this instead of a fixed `asyncio.sleep` when waiting for eventually-consistent state (e.g. request queue
107-
stats) that may take a variable amount of time to propagate. Unlike `call_with_exp_backoff`, the interval
108-
between polls stays constant.
58+
Use this instead of a fixed `asyncio.sleep` when waiting for eventually-consistent state (e.g. a freshly
59+
created resource appearing in a listing) that may take a variable amount of time to propagate. For highly
60+
variable wait times (e.g. an Actor run container starting up), pass `backoff_factor` > 1 to multiply the
61+
interval after each poll, covering a long timeout with few calls.
10962
"""
11063
deadline = time.monotonic() + timeout
64+
delay = poll_interval
11165
result = await _maybe_await(fn())
11266
while not condition(result):
11367
remaining = deadline - time.monotonic()
11468
if remaining <= 0:
11569
break
116-
await asyncio.sleep(min(poll_interval, remaining))
70+
await asyncio.sleep(min(delay, remaining))
71+
delay *= backoff_factor
11772
result = await _maybe_await(fn())
11873
return result
11974

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: 18 additions & 5 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

@@ -425,6 +425,8 @@ def do_POST(self) -> None:
425425
await Actor.set_value('WEBHOOK_BODY', webhook_body)
426426

427427
async def main_client() -> None:
428+
import asyncio
429+
428430
from apify import Webhook, WebhookEventType
429431

430432
async with Actor:
@@ -438,6 +440,12 @@ async def main_client() -> None:
438440
)
439441
)
440442

443+
# Keep the run alive for a moment after registering the webhook. Without this, the run finishes
444+
# just milliseconds later and the platform may process the run-succeeded event before the freshly
445+
# added ad-hoc webhook has propagated, in which case the webhook never fires and the server Actor
446+
# waits until it times out.
447+
await asyncio.sleep(5)
448+
441449
server_actor, client_actor = await asyncio.gather(
442450
make_actor(label='add-webhook-server', main_func=main_server),
443451
make_actor(label='add-webhook-client', main_func=main_client),
@@ -446,10 +454,15 @@ async def main_client() -> None:
446454
server_actor_run = await server_actor.start()
447455
server_actor_container_url = server_actor_run['containerUrl']
448456

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)
457+
# Wait for the server Actor's container to start up and bind its HTTP server. The startup time is highly
458+
# variable (image pull, container creation), so poll with a growing interval instead of a fixed sleep.
459+
server_actor_initialized = await poll_until_condition(
460+
lambda: server_actor.last_run().key_value_store().get_record('INITIALIZED'),
461+
timeout=300,
462+
poll_interval=1,
463+
backoff_factor=1.5,
464+
)
465+
assert server_actor_initialized is not None, 'The server Actor did not initialize in time.'
453466

454467
ac_run_result = await run_actor(
455468
client_actor,

0 commit comments

Comments
 (0)