python-wrap 0.2.1__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
python_wrap/hedging.py ADDED
@@ -0,0 +1,192 @@
1
+ """Hedged requests: `hedge` starts a backup attempt when the first one is slow."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ import concurrent.futures
7
+ import contextvars
8
+ import functools
9
+ import threading
10
+ from collections.abc import Awaitable, Callable
11
+ from typing import Any, TypeVar, cast
12
+
13
+ from python_wrap import _core
14
+
15
+ __all__ = ["hedge"]
16
+
17
+ F = TypeVar("F", bound=Callable[..., Any])
18
+
19
+ ExceptionTypes = type[BaseException] | tuple[type[BaseException], ...]
20
+
21
+
22
+ def _start_thread(
23
+ func: Callable[..., Any], args: Any, kwargs: Any
24
+ ) -> concurrent.futures.Future[Any]:
25
+ """Run one attempt in a new daemon thread (with the caller's context variables)."""
26
+ future: concurrent.futures.Future[Any] = concurrent.futures.Future()
27
+ context = contextvars.copy_context()
28
+
29
+ def target() -> None:
30
+ try:
31
+ result = context.run(func, *args, **kwargs)
32
+ except BaseException as exc:
33
+ future.set_exception(exc)
34
+ else:
35
+ future.set_result(result)
36
+
37
+ threading.Thread(target=target, name=f"hedge-{_core.qualname(func)}", daemon=True).start()
38
+ return future
39
+
40
+
41
+ def hedge(
42
+ delay: float,
43
+ *,
44
+ max_extra: int = 1,
45
+ on: ExceptionTypes = Exception,
46
+ ) -> Callable[[F], F]:
47
+ """Start a backup attempt when a call is slow, and return the first success.
48
+
49
+ !!! warning "Only for idempotent operations"
50
+ The function may run up to `1 + max_extra` times for one call, concurrently.
51
+ Use it only where running the operation more than once is harmless: reads,
52
+ lookups, idempotent writes. Never on payments, sends, or increments.
53
+
54
+ The call starts once. If it has not finished after `delay` seconds, a second attempt
55
+ starts alongside it, and so on, up to `max_extra` extra attempts (each `delay` after
56
+ the previous one). The first attempt to **succeed** wins. If an attempt fails with an
57
+ exception matching `on` while others are still running, the next attempt starts
58
+ immediately instead of waiting out the delay. An exception not matching `on` is
59
+ raised at once. If every attempt fails, the last exception is raised.
60
+
61
+ * `async def` functions: each attempt is a task. As soon as there is a winner (or a
62
+ non-matching error), the other attempts are cancelled and awaited, so the wrapper
63
+ never leaves tasks behind. Cancelling the caller cancels every attempt.
64
+ * Sync functions: each attempt runs in its own daemon thread and the caller blocks
65
+ until one succeeds. **Threads cannot be cancelled**: losing attempts run to
66
+ completion in the background and their results are discarded. Their side effects
67
+ still happen, and they keep the thread (and whatever it holds) busy until done.
68
+
69
+ Context variables are copied into each attempt.
70
+
71
+ Args:
72
+ delay: Seconds to wait before starting each extra attempt (>= 0).
73
+ max_extra: Maximum number of extra attempts (>= 0; 0 disables hedging).
74
+ on: Exception type(s) that count as a failed attempt and trigger the next hedge.
75
+ Anything else propagates at once.
76
+
77
+ Returns:
78
+ A decorator that keeps the function's signature.
79
+
80
+ Raises:
81
+ ValueError: If `delay` < 0 or `max_extra` < 0.
82
+
83
+ Example:
84
+ >>> @hedge(0.05, max_extra=2)
85
+ ... async def get_price(sku: str) -> float:
86
+ ... ... # a read from a service with an occasional slow replica
87
+ """
88
+ if delay < 0:
89
+ raise ValueError(f"hedge delay must be non-negative, got {delay!r}")
90
+ if type(max_extra) is not int or max_extra < 0:
91
+ raise ValueError(f"hedge max_extra must be an integer >= 0, got {max_extra!r}")
92
+ retry_on = on
93
+ max_attempts = 1 + max_extra
94
+
95
+ def decorate(func: F) -> F:
96
+ if _core.is_async(func):
97
+ async_func = cast(Callable[..., Awaitable[Any]], func)
98
+
99
+ async def attempt(args: Any, kwargs: Any) -> Any:
100
+ return await async_func(*args, **kwargs)
101
+
102
+ @functools.wraps(func)
103
+ async def async_wrapper(*args: Any, **kwargs: Any) -> Any:
104
+ loop = asyncio.get_running_loop()
105
+ attempts: list[asyncio.Task[Any]] = [] # in start order
106
+ running: set[asyncio.Task[Any]] = set()
107
+ timer: asyncio.Task[None] | None = None
108
+ last_exc: BaseException | None = None
109
+
110
+ def launch() -> None:
111
+ nonlocal timer
112
+ task = loop.create_task(attempt(args, kwargs))
113
+ attempts.append(task)
114
+ running.add(task)
115
+ if timer is not None:
116
+ timer.cancel()
117
+ timer = (
118
+ loop.create_task(asyncio.sleep(delay)) # real time
119
+ if len(attempts) < max_attempts
120
+ else None
121
+ )
122
+
123
+ try:
124
+ launch()
125
+ while running:
126
+ waiting: set[asyncio.Future[Any]] = set(running)
127
+ if timer is not None:
128
+ waiting.add(timer)
129
+ done, _ = await asyncio.wait(waiting, return_when=asyncio.FIRST_COMPLETED)
130
+ for task in [t for t in attempts if t in done]:
131
+ running.discard(task)
132
+ if task.cancelled(): # cancelled by someone else
133
+ raise asyncio.CancelledError
134
+ exc = task.exception()
135
+ if exc is None:
136
+ return task.result()
137
+ if not isinstance(exc, retry_on):
138
+ raise exc
139
+ last_exc = exc
140
+ if len(attempts) < max_attempts:
141
+ launch()
142
+ if timer is not None and timer in done:
143
+ timer = None
144
+ if len(attempts) < max_attempts:
145
+ launch()
146
+ assert last_exc is not None
147
+ raise last_exc
148
+ finally:
149
+ leftovers: list[asyncio.Future[Any]] = list(running)
150
+ if timer is not None:
151
+ leftovers.append(timer)
152
+ for t in leftovers:
153
+ t.cancel()
154
+ if leftovers:
155
+ await asyncio.wait(leftovers)
156
+ for t in leftovers:
157
+ if not t.cancelled():
158
+ t.exception() # retrieve: a loser's late error is moot
159
+
160
+ return cast(F, async_wrapper)
161
+
162
+ @functools.wraps(func)
163
+ def wrapper(*args: Any, **kwargs: Any) -> Any:
164
+ attempts: list[concurrent.futures.Future[Any]] = [_start_thread(func, args, kwargs)]
165
+ running = set(attempts)
166
+ last_exc: BaseException | None = None
167
+ timeout: float | None = delay if max_attempts > 1 else None
168
+ while running:
169
+ done, _ = concurrent.futures.wait(
170
+ running, timeout=timeout, return_when=concurrent.futures.FIRST_COMPLETED
171
+ )
172
+ launches = 0 if done else 1 # nothing finished: the delay expired
173
+ for future in [f for f in attempts if f in done]:
174
+ running.discard(future)
175
+ exc = future.exception()
176
+ if exc is None:
177
+ return future.result()
178
+ if not isinstance(exc, retry_on):
179
+ raise exc
180
+ last_exc = exc
181
+ launches += 1
182
+ for _ in range(min(launches, max_attempts - len(attempts))):
183
+ future = _start_thread(func, args, kwargs)
184
+ attempts.append(future)
185
+ running.add(future)
186
+ timeout = delay if len(attempts) < max_attempts else None
187
+ assert last_exc is not None
188
+ raise last_exc
189
+
190
+ return cast(F, wrapper)
191
+
192
+ return decorate