Skip to content

Commit aebbbab

Browse files
committed
feat: wait for actions using ActionsClient.wait_for
This function allows the users to wait for multiple actions in an efficient way. All actions are queried using a single call, which reduce the potential for running into rate limits. In addition, users may also configure a duration based timeout when waiting for actions using: action.wait_until_finished(timeout=10) # 10 seconds # or client.actions.wait_for(..., timeout=10)
1 parent 314cd63 commit aebbbab

5 files changed

Lines changed: 412 additions & 35 deletions

File tree

hcloud/_client.py

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -145,6 +145,7 @@ def __init__(
145145
application_version: str | None = None,
146146
poll_interval: int | float | BackoffFunction = 1.0,
147147
poll_max_retries: int = 120,
148+
poll_timeout: float | None = None,
148149
timeout: float | tuple[float, float] | None = None,
149150
*,
150151
api_endpoint_hetzner: str = "https://api.hetzner.com/v1",
@@ -161,6 +162,8 @@ def __init__(
161162
You may pass a function to compute a custom poll interval.
162163
:param poll_max_retries:
163164
Max retries before timeout when polling actions from the API.
165+
:param poll_timeout:
166+
Duration in seconds before timeout when polling actions from the API.
164167
:param timeout: Requests timeout in seconds
165168
"""
166169
self._client = ClientBase(
@@ -170,6 +173,7 @@ def __init__(
170173
application_version=application_version,
171174
poll_interval=poll_interval,
172175
poll_max_retries=poll_max_retries,
176+
poll_timeout=poll_timeout,
173177
timeout=timeout,
174178
)
175179
self._client_hetzner = ClientBase(
@@ -179,6 +183,7 @@ def __init__(
179183
application_version=application_version,
180184
poll_interval=poll_interval,
181185
poll_max_retries=poll_max_retries,
186+
poll_timeout=poll_timeout,
182187
timeout=timeout,
183188
)
184189

@@ -336,6 +341,7 @@ def __init__(
336341
application_version: str | None = None,
337342
poll_interval: int | float | BackoffFunction = 1.0,
338343
poll_max_retries: int = 120,
344+
poll_timeout: float | None = None,
339345
timeout: float | tuple[float, float] | None = None,
340346
):
341347
self._token = token
@@ -355,6 +361,7 @@ def __init__(
355361

356362
self._poll_interval_func = poll_interval_func
357363
self._poll_max_retries = poll_max_retries
364+
self._poll_timeout = poll_timeout
358365

359366
self._retry_interval_func = exponential_backoff_function(
360367
base=1.0, multiplier=2, cap=60.0, jitter=True

hcloud/_utils.py

Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,74 @@
1+
from __future__ import annotations
2+
3+
import time
4+
from collections.abc import Callable, Iterable, Iterator
5+
from itertools import islice
6+
from typing import TypeVar
7+
8+
T = TypeVar("T")
9+
10+
11+
def batched(iterable: Iterable[T], size: int) -> Iterator[list[T]]:
12+
"""
13+
Returns a batch of the provided size from the provided iterable.
14+
"""
15+
iterator = iter(iterable)
16+
while True:
17+
batch = list(islice(iterator, size))
18+
if not batch:
19+
break
20+
yield batch
21+
22+
23+
def waiter(timeout: float | None = None) -> Callable[[float], bool]:
24+
"""
25+
Waiter returns a wait function that sleeps the specified amount of seconds, and
26+
handles timeouts.
27+
28+
The wait function returns True if the timeout was reached, False otherwise.
29+
30+
:param timeout: Timeout in seconds, defaults to None.
31+
:return: Wait function.
32+
"""
33+
34+
if timeout:
35+
deadline = time.time() + timeout
36+
37+
def wait(seconds: float) -> bool:
38+
now = time.time()
39+
40+
# Timeout if the deadline exceeded.
41+
if deadline < now:
42+
return True
43+
44+
# The deadline is not exceeded after the sleep time.
45+
if now + seconds < deadline:
46+
sleep(seconds)
47+
return False
48+
49+
# The deadline is exceeded after the sleep time, clamp sleep time to
50+
# deadline, and allow one last attempt until next wait call.
51+
sleep(deadline - now)
52+
return False
53+
54+
else:
55+
56+
def wait(seconds: float) -> bool:
57+
sleep(seconds)
58+
return False
59+
60+
return wait
61+
62+
63+
def sleep(seconds: float) -> None:
64+
"""
65+
An interruptable sleep function that does not lock the entire thread.
66+
67+
:param seconds: Seconds to sleep.
68+
"""
69+
if seconds < 1:
70+
time.sleep(seconds)
71+
else:
72+
for _ in range(int(seconds)):
73+
time.sleep(1)
74+
time.sleep(seconds - int(seconds))

hcloud/actions/client.py

Lines changed: 183 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,17 @@
11
from __future__ import annotations
22

3-
import time
43
import warnings
4+
from collections.abc import Callable
55
from typing import TYPE_CHECKING, Any, Literal, NamedTuple
66

7+
from .._utils import batched, waiter
78
from ..core import BoundModelBase, Meta, ResourceClientBase
8-
from .domain import Action, ActionFailedException, ActionStatus, ActionTimeoutException
9+
from .domain import (
10+
Action,
11+
ActionFailedException,
12+
ActionStatus,
13+
ActionTimeoutException,
14+
)
915

1016
if TYPE_CHECKING:
1117
from .._client import Client
@@ -25,33 +31,44 @@ class BoundAction(BoundModelBase[Action], Action):
2531

2632
model = Action
2733

28-
def wait_until_finished(self, max_retries: int | None = None) -> None:
29-
"""Wait until the specific action has status=finished.
30-
31-
:param max_retries: int Specify how many retries will be performed before an ActionTimeoutException will be raised.
32-
:raises: ActionFailedException when action is finished with status==error
33-
:raises: ActionTimeoutException when Action is still in status==running after max_retries is reached.
34+
def wait_until_finished(
35+
self,
36+
max_retries: int | None = None,
37+
*,
38+
timeout: float | None = None,
39+
) -> None:
3440
"""
35-
if max_retries is None:
36-
# pylint: disable=protected-access
37-
max_retries = self._client._client._poll_max_retries
41+
Waits until the Action is finished by polling the API at the interval defined by
42+
the client's poll interval and function. An Action is considered as finished
43+
when its status is either "success" or "error".
3844
39-
retries = 0
40-
while True:
41-
self.reload()
42-
if self.status != Action.STATUS_RUNNING:
43-
break
45+
If the Action fails (its status is "error"), the function will stop waiting
46+
and raise ActionFailedException.
4447
45-
retries += 1
46-
if retries < max_retries:
47-
# pylint: disable=protected-access
48-
time.sleep(self._client._client._poll_interval_func(retries))
49-
continue
48+
:param timeout:
49+
Duration in seconds before an ActionTimeoutException will be raised when polling actions from the API.
50+
:param max_retries:
51+
Max retries before an ActionTimeoutException will be raised when polling actions from the API.
52+
53+
:raises: ActionTimeoutException when an Action is still running after max_retries or timeout is reached.
54+
:raises: ActionFailedException when an Action failed.
55+
"""
56+
57+
def handle_update(update: BoundAction) -> None:
58+
self.data_model = update.data_model
5059

51-
raise ActionTimeoutException(action=self)
60+
if update.status == Action.STATUS_ERROR:
61+
raise ActionFailedException(action=update)
5262

53-
if self.status == Action.STATUS_ERROR:
54-
raise ActionFailedException(action=self)
63+
try:
64+
self._client.wait_for_function(
65+
handle_update,
66+
[self],
67+
timeout=timeout,
68+
max_retries=max_retries,
69+
)
70+
except* ActionTimeoutException as group:
71+
raise group.exceptions[0]
5572

5673

5774
ActionSort = Literal[
@@ -189,6 +206,148 @@ class ActionsClient(ResourceActionsClient):
189206
def __init__(self, client: Client):
190207
super().__init__(client, None)
191208

209+
def _get_list_by_ids(self, ids: list[int]) -> list[BoundAction]:
210+
"""
211+
Get a list of Actions by their IDs.
212+
213+
:param ids: List of Action IDs to get.
214+
:raises ValueError: Raise when Action IDs were not found.
215+
:return: List of Actions.
216+
"""
217+
actions: list[BoundAction] = []
218+
219+
for ids_batch in batched(ids, 25):
220+
params: dict[str, Any] = {
221+
"id": ids_batch,
222+
"sort": ["status", "id"],
223+
}
224+
225+
response = self._client.request(
226+
method="GET",
227+
url="/actions",
228+
params=params,
229+
)
230+
231+
actions.extend(
232+
BoundAction(self._parent.actions, o) for o in response["actions"]
233+
)
234+
235+
if len(ids) != len(actions):
236+
found_ids = [a.id for a in actions]
237+
not_found_ids = list(set(ids) - set(found_ids))
238+
239+
raise ValueError(
240+
f"actions not found: {', '.join(str(o) for o in not_found_ids)}"
241+
)
242+
243+
return actions
244+
245+
def wait_for_function(
246+
self,
247+
handle_update: Callable[[BoundAction], None],
248+
actions: list[Action | BoundAction],
249+
*,
250+
timeout: float | None = None,
251+
max_retries: int | None = None,
252+
) -> list[BoundAction]:
253+
"""
254+
Waits until all Actions are finished by polling the API at the interval defined
255+
by the client's poll interval and function. An Action is considered as finished
256+
when its status is either "success" or "error".
257+
258+
The handle_update callback is called every time an Action is updated.
259+
260+
:param handle_update:
261+
Function called every time an Action is updated.
262+
:param actions:
263+
List of Actions to wait for.
264+
:param timeout:
265+
Duration in seconds before an ActionTimeoutException will be raised when polling actions from the API.
266+
:param max_retries:
267+
Max retries before an ActionTimeoutException will be raised when polling actions from the API.
268+
269+
:raises: ActionTimeoutException when an Action is still running after max_retries or timeout is reached.
270+
271+
:return: List of finished Actions.
272+
"""
273+
if timeout is None:
274+
# pylint: disable=protected-access
275+
timeout = self._client._poll_timeout
276+
if max_retries is None:
277+
# pylint: disable=protected-access
278+
max_retries = self._client._poll_max_retries
279+
280+
running: list[BoundAction] = actions.copy() # type: ignore[assignment]
281+
completed: list[BoundAction] = []
282+
283+
retries = 0
284+
wait = waiter(timeout)
285+
while len(running) > 0:
286+
if max_retries is not None and retries > max_retries:
287+
raise ExceptionGroup(
288+
"The actions timed out after",
289+
[ActionTimeoutException(action) for action in running],
290+
)
291+
292+
# pylint: disable=protected-access
293+
if wait(self._client._poll_interval_func(retries)):
294+
raise ExceptionGroup(
295+
"The actions timed out",
296+
[ActionTimeoutException(action) for action in running],
297+
)
298+
299+
retries += 1
300+
301+
running = self._get_list_by_ids([a.id for a in running])
302+
303+
for update in running:
304+
if update.status != Action.STATUS_RUNNING:
305+
running.remove(update)
306+
completed.append(update)
307+
308+
handle_update(update)
309+
310+
return completed
311+
312+
def wait_for(
313+
self,
314+
actions: list[Action | BoundAction],
315+
*,
316+
timeout: float | None = None,
317+
max_retries: int | None = None,
318+
) -> list[BoundAction]:
319+
"""
320+
Waits until all Actions are finished by polling the API at the interval defined
321+
by the client's poll interval and function. An Action is considered as finished
322+
when its status is either "success" or "error".
323+
324+
If a single Action fails (its status is "error"), the function will stop waiting
325+
and raise ActionFailedException.
326+
327+
:param actions:
328+
List of Actions to wait for.
329+
:param timeout:
330+
Duration in seconds before an ActionTimeoutException will be raised when polling actions from the API.
331+
:param max_retries:
332+
Max retries before an ActionTimeoutException will be raised when polling actions from the API.
333+
334+
:raises: ActionTimeoutException when an Action is still running after max_retries or timeout is reached.
335+
:raises: ActionFailedException when an Action failed.
336+
337+
:return: List of succeeded Actions.
338+
"""
339+
340+
def handle_update(update: BoundAction) -> None:
341+
if update.status == Action.STATUS_ERROR:
342+
raise ActionFailedException(action=update)
343+
344+
return self.wait_for_function(
345+
handle_update,
346+
actions,
347+
timeout=timeout,
348+
max_retries=max_retries,
349+
)
350+
192351
def get_list(
193352
self,
194353
status: list[ActionStatus] | None = None,

0 commit comments

Comments
 (0)