22
33import asyncio
44import inspect
5- import logging
65import time
76from typing import TYPE_CHECKING , TypeVar , cast , overload
87
1110if TYPE_CHECKING :
1211 from collections .abc import Awaitable , Callable
1312
14- logger = logging .getLogger (__name__ )
15-
1613T = TypeVar ('T' )
1714
1815
1916async 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
7827async 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
8636async 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 : ...
9344async 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
0 commit comments