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/__init__.py +272 -0
- python_wrap/_core.py +212 -0
- python_wrap/aio.py +620 -0
- python_wrap/batching.py +556 -0
- python_wrap/bulkhead.py +431 -0
- python_wrap/caching.py +1613 -0
- python_wrap/circuit.py +553 -0
- python_wrap/concurrency.py +1886 -0
- python_wrap/config.py +217 -0
- python_wrap/deadline.py +168 -0
- python_wrap/decorators.py +7 -0
- python_wrap/devtools.py +385 -0
- python_wrap/errors.py +86 -0
- python_wrap/events.py +161 -0
- python_wrap/hedging.py +192 -0
- python_wrap/logs.py +767 -0
- python_wrap/metrics.py +242 -0
- python_wrap/periodic.py +491 -0
- python_wrap/policy.py +263 -0
- python_wrap/py.typed +0 -0
- python_wrap/retry.py +1324 -0
- python_wrap/state.py +500 -0
- python_wrap/testing.py +662 -0
- python_wrap/timing.py +535 -0
- python_wrap/validation.py +662 -0
- python_wrap-0.2.1.dist-info/METADATA +485 -0
- python_wrap-0.2.1.dist-info/RECORD +29 -0
- python_wrap-0.2.1.dist-info/WHEEL +4 -0
- python_wrap-0.2.1.dist-info/licenses/LICENSE +21 -0
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
|