python-backoff 2.3.1__tar.gz → 2.4.0__tar.gz
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_backoff-2.3.1 → python_backoff-2.4.0}/.gitignore +1 -1
- {python_backoff-2.3.1 → python_backoff-2.4.0}/PKG-INFO +3 -5
- {python_backoff-2.3.1 → python_backoff-2.4.0}/README.rst +1 -1
- {python_backoff-2.3.1 → python_backoff-2.4.0}/backoff/__init__.py +9 -7
- python_backoff-2.4.0/backoff/_async.py +301 -0
- python_backoff-2.4.0/backoff/_common.py +291 -0
- {python_backoff-2.3.1 → python_backoff-2.4.0}/backoff/_decorator.py +201 -30
- python_backoff-2.4.0/backoff/_sync.py +238 -0
- python_backoff-2.4.0/backoff/_typing.py +68 -0
- {python_backoff-2.3.1 → python_backoff-2.4.0}/backoff/_wait_gen.py +37 -39
- {python_backoff-2.3.1 → python_backoff-2.4.0}/pyproject.toml +100 -46
- python_backoff-2.4.0/tests/common.py +45 -0
- {python_backoff-2.3.1 → python_backoff-2.4.0}/tests/test_backoff.py +223 -151
- {python_backoff-2.3.1 → python_backoff-2.4.0}/tests/test_backoff_async.py +157 -97
- {python_backoff-2.3.1 → python_backoff-2.4.0}/tests/test_integration.py +6 -6
- {python_backoff-2.3.1 → python_backoff-2.4.0}/tests/test_jitter.py +2 -2
- {python_backoff-2.3.1 → python_backoff-2.4.0}/tests/test_package.py +9 -10
- python_backoff-2.4.0/tests/test_retry_context.py +348 -0
- {python_backoff-2.3.1 → python_backoff-2.4.0}/tests/test_types.py +0 -1
- {python_backoff-2.3.1 → python_backoff-2.4.0}/tests/test_typing.py +0 -1
- {python_backoff-2.3.1 → python_backoff-2.4.0}/tests/test_wait_gen.py +30 -20
- python_backoff-2.3.1/backoff/_async.py +0 -201
- python_backoff-2.3.1/backoff/_common.py +0 -121
- python_backoff-2.3.1/backoff/_sync.py +0 -144
- python_backoff-2.3.1/backoff/_typing.py +0 -43
- python_backoff-2.3.1/tests/common.py +0 -23
- {python_backoff-2.3.1 → python_backoff-2.4.0}/LICENSE +0 -0
- {python_backoff-2.3.1 → python_backoff-2.4.0}/backoff/_jitter.py +0 -0
- {python_backoff-2.3.1 → python_backoff-2.4.0}/backoff/py.typed +0 -0
- {python_backoff-2.3.1 → python_backoff-2.4.0}/backoff/types.py +0 -0
- {python_backoff-2.3.1 → python_backoff-2.4.0}/tests/__init__.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: python-backoff
|
|
3
|
-
Version: 2.
|
|
3
|
+
Version: 2.4.0
|
|
4
4
|
Summary: Function decoration for backoff and retry
|
|
5
5
|
Project-URL: Documentation, https://python-backoff.github.io/backoff
|
|
6
6
|
Project-URL: Repository, https://github.com/python-backoff/backoff
|
|
@@ -11,12 +11,10 @@ License-File: LICENSE
|
|
|
11
11
|
Keywords: backoff,decorators,retry
|
|
12
12
|
Classifier: Development Status :: 5 - Production/Stable
|
|
13
13
|
Classifier: Intended Audience :: Developers
|
|
14
|
-
Classifier: License :: OSI Approved :: MIT License
|
|
15
14
|
Classifier: Natural Language :: English
|
|
16
15
|
Classifier: Operating System :: OS Independent
|
|
17
16
|
Classifier: Programming Language :: Python
|
|
18
17
|
Classifier: Programming Language :: Python :: 3
|
|
19
|
-
Classifier: Programming Language :: Python :: 3.8
|
|
20
18
|
Classifier: Programming Language :: Python :: 3.9
|
|
21
19
|
Classifier: Programming Language :: Python :: 3.10
|
|
22
20
|
Classifier: Programming Language :: Python :: 3.11
|
|
@@ -27,7 +25,7 @@ Classifier: Programming Language :: Python :: 3.15
|
|
|
27
25
|
Classifier: Topic :: Internet :: WWW/HTTP
|
|
28
26
|
Classifier: Topic :: Software Development :: Libraries :: Python Modules
|
|
29
27
|
Classifier: Topic :: Utilities
|
|
30
|
-
Requires-Python: >=3.
|
|
28
|
+
Requires-Python: >=3.9
|
|
31
29
|
Description-Content-Type: text/x-rst
|
|
32
30
|
|
|
33
31
|
backoff
|
|
@@ -406,7 +404,7 @@ asynchronous HTTP client/server library.
|
|
|
406
404
|
max_time=60,
|
|
407
405
|
)
|
|
408
406
|
async def get_url(url):
|
|
409
|
-
async with aiohttp.ClientSession(raise_for_status=True) as session: #
|
|
407
|
+
async with aiohttp.ClientSession(raise_for_status=True) as session: # ruff:ignore[multiple-with-statements]
|
|
410
408
|
async with session.get(url) as response:
|
|
411
409
|
return await response.text()
|
|
412
410
|
|
|
@@ -374,7 +374,7 @@ asynchronous HTTP client/server library.
|
|
|
374
374
|
max_time=60,
|
|
375
375
|
)
|
|
376
376
|
async def get_url(url):
|
|
377
|
-
async with aiohttp.ClientSession(raise_for_status=True) as session: #
|
|
377
|
+
async with aiohttp.ClientSession(raise_for_status=True) as session: # ruff:ignore[multiple-with-statements]
|
|
378
378
|
async with session.get(url) as response:
|
|
379
379
|
return await response.text()
|
|
380
380
|
|
|
@@ -12,20 +12,22 @@ For examples and full documentation see the README at
|
|
|
12
12
|
https://github.com/python-backoff/backoff
|
|
13
13
|
"""
|
|
14
14
|
|
|
15
|
-
from backoff._decorator import on_exception, on_predicate
|
|
15
|
+
from backoff._decorator import aretry_context, on_exception, on_predicate, retry_context
|
|
16
16
|
from backoff._jitter import full_jitter, random_jitter
|
|
17
|
-
from backoff._wait_gen import constant, expo, fibo, runtime
|
|
17
|
+
from backoff._wait_gen import constant, decay, expo, fibo, runtime
|
|
18
18
|
|
|
19
19
|
__all__ = [
|
|
20
|
-
"
|
|
21
|
-
"on_exception",
|
|
20
|
+
"aretry_context",
|
|
22
21
|
"constant",
|
|
23
|
-
"expo",
|
|
24
22
|
"decay",
|
|
23
|
+
"expo",
|
|
25
24
|
"fibo",
|
|
26
|
-
"runtime",
|
|
27
25
|
"full_jitter",
|
|
26
|
+
"on_exception",
|
|
27
|
+
"on_predicate",
|
|
28
28
|
"random_jitter",
|
|
29
|
+
"retry_context",
|
|
30
|
+
"runtime",
|
|
29
31
|
]
|
|
30
32
|
|
|
31
|
-
__version__ = "2.
|
|
33
|
+
__version__ = "2.4.0"
|
|
@@ -0,0 +1,301 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
import functools
|
|
5
|
+
import inspect
|
|
6
|
+
from typing import TYPE_CHECKING, Any, Callable, TypeVar
|
|
7
|
+
|
|
8
|
+
from backoff._common import _Attempt, _RetryState
|
|
9
|
+
|
|
10
|
+
if TYPE_CHECKING:
|
|
11
|
+
import sys
|
|
12
|
+
from collections.abc import AsyncGenerator, Coroutine, Iterable
|
|
13
|
+
|
|
14
|
+
from backoff._typing import (
|
|
15
|
+
ContextDetails,
|
|
16
|
+
Details,
|
|
17
|
+
_BaseDetails,
|
|
18
|
+
_CallDetails,
|
|
19
|
+
_ContextHandler,
|
|
20
|
+
_Handler,
|
|
21
|
+
_Jitterer,
|
|
22
|
+
_MaybeCallable,
|
|
23
|
+
_MaybeTuple,
|
|
24
|
+
_Predicate,
|
|
25
|
+
_WaitGenerator,
|
|
26
|
+
)
|
|
27
|
+
|
|
28
|
+
if sys.version_info >= (3, 10):
|
|
29
|
+
from typing import ParamSpec
|
|
30
|
+
else:
|
|
31
|
+
from typing_extensions import ParamSpec
|
|
32
|
+
|
|
33
|
+
if sys.version_info >= (3, 11):
|
|
34
|
+
from typing import Unpack
|
|
35
|
+
else:
|
|
36
|
+
from typing_extensions import Unpack
|
|
37
|
+
|
|
38
|
+
T = TypeVar("T")
|
|
39
|
+
P = ParamSpec("P")
|
|
40
|
+
|
|
41
|
+
_AsyncHandler = Callable[["Details"], "Coroutine[Any, Any, None]"]
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def _ensure_coroutine(
|
|
45
|
+
coro_or_func: Callable[..., Any],
|
|
46
|
+
) -> Callable[..., Coroutine[Any, Any, Any]]:
|
|
47
|
+
if inspect.iscoroutinefunction(coro_or_func):
|
|
48
|
+
return coro_or_func
|
|
49
|
+
|
|
50
|
+
@functools.wraps(coro_or_func)
|
|
51
|
+
async def f(*args: Any, **kwargs: Any) -> Any: # ruff:ignore[unused-async]
|
|
52
|
+
return coro_or_func(*args, **kwargs)
|
|
53
|
+
|
|
54
|
+
return f
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def _ensure_coroutines(
|
|
58
|
+
coros_or_funcs: Iterable[Callable[..., Any]],
|
|
59
|
+
) -> list[Callable[..., Coroutine[Any, Any, Any]]]:
|
|
60
|
+
return [_ensure_coroutine(f) for f in coros_or_funcs]
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
async def _call_handlers(
|
|
64
|
+
handlers: Iterable[_AsyncHandler],
|
|
65
|
+
*,
|
|
66
|
+
target: Callable[..., Any],
|
|
67
|
+
args: tuple[Any, ...],
|
|
68
|
+
kwargs: dict[str, Any],
|
|
69
|
+
tries: int,
|
|
70
|
+
elapsed: float,
|
|
71
|
+
**extra: Unpack[_CallDetails],
|
|
72
|
+
) -> None:
|
|
73
|
+
details: Details = {
|
|
74
|
+
"target": target,
|
|
75
|
+
"args": args,
|
|
76
|
+
"kwargs": kwargs,
|
|
77
|
+
"tries": tries,
|
|
78
|
+
"elapsed": elapsed,
|
|
79
|
+
}
|
|
80
|
+
# pyrefly: ignore [no-matching-overload]
|
|
81
|
+
details.update(extra)
|
|
82
|
+
for handler in handlers:
|
|
83
|
+
await handler(details)
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def retry_predicate(
|
|
87
|
+
target: Callable[P, T],
|
|
88
|
+
wait_gen: _WaitGenerator,
|
|
89
|
+
predicate: _Predicate[T],
|
|
90
|
+
*,
|
|
91
|
+
max_tries: _MaybeCallable[int] | None,
|
|
92
|
+
max_time: _MaybeCallable[float] | None,
|
|
93
|
+
jitter: _Jitterer | None,
|
|
94
|
+
on_success: Iterable[_Handler],
|
|
95
|
+
on_backoff: Iterable[_Handler],
|
|
96
|
+
on_giveup: Iterable[_Handler],
|
|
97
|
+
wait_gen_kwargs: dict[str, Any],
|
|
98
|
+
) -> Callable[P, T]:
|
|
99
|
+
on_success = _ensure_coroutines(on_success)
|
|
100
|
+
on_backoff = _ensure_coroutines(on_backoff)
|
|
101
|
+
on_giveup = _ensure_coroutines(on_giveup)
|
|
102
|
+
|
|
103
|
+
# Easy to implement, please report if you need this.
|
|
104
|
+
assert not inspect.iscoroutinefunction(max_tries)
|
|
105
|
+
assert not inspect.iscoroutinefunction(jitter)
|
|
106
|
+
|
|
107
|
+
assert inspect.iscoroutinefunction(target)
|
|
108
|
+
|
|
109
|
+
@functools.wraps(target)
|
|
110
|
+
async def retry(*args: P.args, **kwargs: P.kwargs) -> T:
|
|
111
|
+
state = _RetryState(
|
|
112
|
+
wait_gen,
|
|
113
|
+
wait_gen_kwargs,
|
|
114
|
+
max_tries=max_tries,
|
|
115
|
+
max_time=max_time,
|
|
116
|
+
)
|
|
117
|
+
while True:
|
|
118
|
+
state.start_attempt()
|
|
119
|
+
ret = await target(*args, **kwargs)
|
|
120
|
+
details: _BaseDetails = {
|
|
121
|
+
"target": target,
|
|
122
|
+
"args": args,
|
|
123
|
+
"kwargs": kwargs,
|
|
124
|
+
"tries": state.tries,
|
|
125
|
+
"elapsed": state.record_elapsed(),
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
if predicate(ret):
|
|
129
|
+
if state.exhausted():
|
|
130
|
+
await _call_handlers(on_giveup, **details, value=ret)
|
|
131
|
+
break
|
|
132
|
+
|
|
133
|
+
try:
|
|
134
|
+
seconds = state.next_wait(ret, jitter)
|
|
135
|
+
except StopIteration:
|
|
136
|
+
await _call_handlers(on_giveup, **details, value=ret)
|
|
137
|
+
break
|
|
138
|
+
|
|
139
|
+
await _call_handlers(on_backoff, **details, value=ret, wait=seconds)
|
|
140
|
+
|
|
141
|
+
# Note: there is no convenient way to pass explicit event
|
|
142
|
+
# loop to decorator, so here we assume that either default
|
|
143
|
+
# thread event loop is set and correct (it mostly is
|
|
144
|
+
# by default), or Python >= 3.5.3 or Python >= 3.6 is used
|
|
145
|
+
# where loop.get_event_loop() in coroutine guaranteed to
|
|
146
|
+
# return correct value.
|
|
147
|
+
# See for details:
|
|
148
|
+
# <https://groups.google.com/forum/#!topic/python-tulip/yF9C-rFpiKk>
|
|
149
|
+
# <https://bugs.python.org/issue28613>
|
|
150
|
+
await asyncio.sleep(seconds)
|
|
151
|
+
continue
|
|
152
|
+
await _call_handlers(on_success, **details, value=ret)
|
|
153
|
+
break
|
|
154
|
+
|
|
155
|
+
return ret
|
|
156
|
+
|
|
157
|
+
return retry # type: ignore[return-value] # ty:ignore[invalid-return-type]
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def _adapt_context_handlers(
|
|
161
|
+
handlers: Iterable[_AsyncHandler],
|
|
162
|
+
target: Callable[..., Any],
|
|
163
|
+
args: tuple[Any, ...],
|
|
164
|
+
kwargs: dict[str, Any],
|
|
165
|
+
) -> list[_ContextHandler]:
|
|
166
|
+
async def adapted(details: ContextDetails) -> None:
|
|
167
|
+
full_details: Details = {
|
|
168
|
+
"target": target,
|
|
169
|
+
"args": args,
|
|
170
|
+
"kwargs": kwargs,
|
|
171
|
+
**details,
|
|
172
|
+
}
|
|
173
|
+
for hdlr in handlers:
|
|
174
|
+
await hdlr(full_details)
|
|
175
|
+
|
|
176
|
+
return [adapted]
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
def retry_exception(
|
|
180
|
+
target: Callable[P, T],
|
|
181
|
+
wait_gen: _WaitGenerator,
|
|
182
|
+
exception: _MaybeTuple[type[Exception]],
|
|
183
|
+
*,
|
|
184
|
+
max_tries: _MaybeCallable[int] | None,
|
|
185
|
+
max_time: _MaybeCallable[float] | None,
|
|
186
|
+
jitter: _Jitterer | None,
|
|
187
|
+
giveup: _Predicate[Exception],
|
|
188
|
+
on_success: Iterable[_Handler],
|
|
189
|
+
on_backoff: Iterable[_Handler],
|
|
190
|
+
on_giveup: Iterable[_Handler],
|
|
191
|
+
raise_on_giveup: bool,
|
|
192
|
+
wait_gen_kwargs: dict[str, Any],
|
|
193
|
+
) -> Callable[P, T]:
|
|
194
|
+
on_success = _ensure_coroutines(on_success)
|
|
195
|
+
on_backoff = _ensure_coroutines(on_backoff)
|
|
196
|
+
on_giveup = _ensure_coroutines(on_giveup)
|
|
197
|
+
giveup = _ensure_coroutine(giveup)
|
|
198
|
+
|
|
199
|
+
# Easy to implement, please report if you need this.
|
|
200
|
+
assert not inspect.iscoroutinefunction(max_tries)
|
|
201
|
+
assert not inspect.iscoroutinefunction(jitter)
|
|
202
|
+
|
|
203
|
+
@functools.wraps(target)
|
|
204
|
+
async def retry(
|
|
205
|
+
*args: P.args,
|
|
206
|
+
**kwargs: P.kwargs,
|
|
207
|
+
) -> T:
|
|
208
|
+
ret: T = None # type: ignore[assignment] # ty:ignore[invalid-assignment]
|
|
209
|
+
|
|
210
|
+
async for attempt in aretry_context(
|
|
211
|
+
exception,
|
|
212
|
+
wait_gen,
|
|
213
|
+
max_tries=max_tries,
|
|
214
|
+
max_time=max_time,
|
|
215
|
+
jitter=jitter,
|
|
216
|
+
giveup=giveup,
|
|
217
|
+
on_success=_adapt_context_handlers(on_success, target, args, kwargs),
|
|
218
|
+
on_backoff=_adapt_context_handlers(on_backoff, target, args, kwargs),
|
|
219
|
+
on_giveup=_adapt_context_handlers(on_giveup, target, args, kwargs),
|
|
220
|
+
raise_on_giveup=raise_on_giveup,
|
|
221
|
+
wait_gen_kwargs=wait_gen_kwargs,
|
|
222
|
+
):
|
|
223
|
+
with attempt:
|
|
224
|
+
ret = await target(*args, **kwargs) # type: ignore[misc] # ty:ignore[invalid-await]
|
|
225
|
+
|
|
226
|
+
return ret
|
|
227
|
+
|
|
228
|
+
return retry # type: ignore[return-value] # ty:ignore[invalid-return-type]
|
|
229
|
+
|
|
230
|
+
|
|
231
|
+
async def _dispatch_handlers(
|
|
232
|
+
handlers: Iterable[_ContextHandler], **details: Any
|
|
233
|
+
) -> None:
|
|
234
|
+
for hdlr in _ensure_coroutines(handlers):
|
|
235
|
+
await hdlr(details)
|
|
236
|
+
|
|
237
|
+
|
|
238
|
+
async def aretry_context(
|
|
239
|
+
exception: _MaybeTuple[type[Exception]],
|
|
240
|
+
wait_gen: _WaitGenerator,
|
|
241
|
+
*,
|
|
242
|
+
max_tries: _MaybeCallable[int] | None,
|
|
243
|
+
max_time: _MaybeCallable[float] | None,
|
|
244
|
+
jitter: _Jitterer | None,
|
|
245
|
+
giveup: _Predicate[BaseException],
|
|
246
|
+
on_success: Iterable[_ContextHandler],
|
|
247
|
+
on_backoff: Iterable[_ContextHandler],
|
|
248
|
+
on_giveup: Iterable[_ContextHandler],
|
|
249
|
+
raise_on_giveup: bool,
|
|
250
|
+
wait_gen_kwargs: dict[str, Any],
|
|
251
|
+
) -> AsyncGenerator[_Attempt, None]:
|
|
252
|
+
giveup = _ensure_coroutine(giveup)
|
|
253
|
+
|
|
254
|
+
state = _RetryState(
|
|
255
|
+
wait_gen,
|
|
256
|
+
wait_gen_kwargs,
|
|
257
|
+
max_tries=max_tries,
|
|
258
|
+
max_time=max_time,
|
|
259
|
+
)
|
|
260
|
+
while True:
|
|
261
|
+
state.start_attempt()
|
|
262
|
+
attempt = _Attempt(exception)
|
|
263
|
+
yield attempt
|
|
264
|
+
elapsed = state.record_elapsed()
|
|
265
|
+
|
|
266
|
+
exc = attempt.exception
|
|
267
|
+
if exc is None:
|
|
268
|
+
await _dispatch_handlers(on_success, tries=state.tries, elapsed=elapsed)
|
|
269
|
+
return
|
|
270
|
+
|
|
271
|
+
if await giveup(exc) or state.exhausted():
|
|
272
|
+
await _dispatch_handlers(
|
|
273
|
+
on_giveup,
|
|
274
|
+
tries=state.tries,
|
|
275
|
+
elapsed=elapsed,
|
|
276
|
+
exception=exc,
|
|
277
|
+
)
|
|
278
|
+
if raise_on_giveup:
|
|
279
|
+
raise exc
|
|
280
|
+
return
|
|
281
|
+
|
|
282
|
+
try:
|
|
283
|
+
seconds = state.next_wait(exc, jitter)
|
|
284
|
+
except StopIteration:
|
|
285
|
+
await _dispatch_handlers(
|
|
286
|
+
on_giveup,
|
|
287
|
+
tries=state.tries,
|
|
288
|
+
elapsed=elapsed,
|
|
289
|
+
exception=exc,
|
|
290
|
+
)
|
|
291
|
+
raise exc from None
|
|
292
|
+
|
|
293
|
+
await _dispatch_handlers(
|
|
294
|
+
on_backoff,
|
|
295
|
+
tries=state.tries,
|
|
296
|
+
elapsed=elapsed,
|
|
297
|
+
wait=seconds,
|
|
298
|
+
exception=exc,
|
|
299
|
+
)
|
|
300
|
+
|
|
301
|
+
await asyncio.sleep(seconds)
|
|
@@ -0,0 +1,291 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import functools
|
|
4
|
+
import logging
|
|
5
|
+
import time
|
|
6
|
+
import traceback
|
|
7
|
+
import warnings
|
|
8
|
+
from typing import TYPE_CHECKING, Any, Callable, TypeVar
|
|
9
|
+
|
|
10
|
+
if TYPE_CHECKING:
|
|
11
|
+
import sys
|
|
12
|
+
from collections.abc import Generator, Iterable
|
|
13
|
+
|
|
14
|
+
from backoff._typing import (
|
|
15
|
+
ContextDetails,
|
|
16
|
+
Details,
|
|
17
|
+
_ContextHandler,
|
|
18
|
+
_Jitterer,
|
|
19
|
+
_MaybeCallable,
|
|
20
|
+
_WaitGenerator,
|
|
21
|
+
)
|
|
22
|
+
|
|
23
|
+
if sys.version_info >= (3, 11):
|
|
24
|
+
from typing import Self
|
|
25
|
+
else:
|
|
26
|
+
from typing_extensions import Self
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
# Use module-specific logger with a default null handler.
|
|
30
|
+
_logger = logging.getLogger("backoff")
|
|
31
|
+
_logger.addHandler(logging.NullHandler()) # pragma: no cover
|
|
32
|
+
_logger.setLevel(logging.INFO)
|
|
33
|
+
|
|
34
|
+
T = TypeVar("T")
|
|
35
|
+
_HandlerT = TypeVar("_HandlerT")
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
# Evaluate arg that can be either a fixed value or a callable.
|
|
39
|
+
def _maybe_call(f: _MaybeCallable[T] | None, *args: Any, **kwargs: Any) -> T | None:
|
|
40
|
+
if callable(f):
|
|
41
|
+
try:
|
|
42
|
+
return f(*args, **kwargs) # ty:ignore[call-top-callable, invalid-return-type]
|
|
43
|
+
except TypeError:
|
|
44
|
+
return f # type: ignore[return-value] # ty:ignore[invalid-return-type]
|
|
45
|
+
else:
|
|
46
|
+
return f
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def _init_wait_gen(
|
|
50
|
+
wait_gen: _WaitGenerator,
|
|
51
|
+
wait_gen_kwargs: dict[str, Any],
|
|
52
|
+
) -> Generator[float, Any, None]:
|
|
53
|
+
kwargs = {k: _maybe_call(v) for k, v in wait_gen_kwargs.items()}
|
|
54
|
+
initialized = wait_gen(**kwargs)
|
|
55
|
+
initialized.send(None) # Initialize with an empty send
|
|
56
|
+
return initialized
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def _next_wait(
|
|
60
|
+
wait: Generator[float, None, None],
|
|
61
|
+
send_value: Any,
|
|
62
|
+
jitter: _Jitterer | None,
|
|
63
|
+
elapsed: float,
|
|
64
|
+
max_time: float | None,
|
|
65
|
+
) -> float:
|
|
66
|
+
value = wait.send(send_value)
|
|
67
|
+
try:
|
|
68
|
+
seconds = jitter(value) if jitter is not None else value
|
|
69
|
+
except TypeError:
|
|
70
|
+
warnings.warn(
|
|
71
|
+
"Nullary jitter function signature is deprecated. Use "
|
|
72
|
+
"unary signature accepting a wait value in seconds and "
|
|
73
|
+
"returning a jittered version of it.",
|
|
74
|
+
DeprecationWarning,
|
|
75
|
+
stacklevel=2,
|
|
76
|
+
)
|
|
77
|
+
|
|
78
|
+
seconds = value + jitter() # type: ignore[call-arg, misc] # ty:ignore[missing-argument, call-non-callable]
|
|
79
|
+
|
|
80
|
+
# don't sleep longer than remaining allotted max_time
|
|
81
|
+
if max_time is not None:
|
|
82
|
+
seconds = min(seconds, max_time - elapsed)
|
|
83
|
+
|
|
84
|
+
return seconds
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
class _RetryState:
|
|
88
|
+
"""Bookkeeping shared by the sync and async retry loops.
|
|
89
|
+
|
|
90
|
+
Tracks try count and elapsed time, and owns the initialized wait
|
|
91
|
+
generator, so `retry_predicate`/`retry_exception` only need to drive
|
|
92
|
+
the call/check/sleep sequence around it.
|
|
93
|
+
"""
|
|
94
|
+
|
|
95
|
+
__slots__ = (
|
|
96
|
+
"elapsed",
|
|
97
|
+
"max_time",
|
|
98
|
+
"max_tries",
|
|
99
|
+
"start",
|
|
100
|
+
"tries",
|
|
101
|
+
"wait",
|
|
102
|
+
)
|
|
103
|
+
|
|
104
|
+
def __init__(
|
|
105
|
+
self,
|
|
106
|
+
wait_gen: _WaitGenerator,
|
|
107
|
+
wait_gen_kwargs: dict[str, Any],
|
|
108
|
+
*,
|
|
109
|
+
max_tries: _MaybeCallable[int] | None = None,
|
|
110
|
+
max_time: _MaybeCallable[float] | None = None,
|
|
111
|
+
) -> None:
|
|
112
|
+
self.tries = 0
|
|
113
|
+
self.elapsed: float = 0
|
|
114
|
+
self.start = time.monotonic()
|
|
115
|
+
self.max_tries = _maybe_call(max_tries)
|
|
116
|
+
self.max_time = _maybe_call(max_time)
|
|
117
|
+
self.wait = _init_wait_gen(wait_gen, wait_gen_kwargs)
|
|
118
|
+
|
|
119
|
+
def start_attempt(self) -> None:
|
|
120
|
+
self.tries += 1
|
|
121
|
+
|
|
122
|
+
def record_elapsed(self) -> float:
|
|
123
|
+
self.elapsed = time.monotonic() - self.start
|
|
124
|
+
return self.elapsed
|
|
125
|
+
|
|
126
|
+
def exhausted(self) -> bool:
|
|
127
|
+
max_tries_exceeded = self.tries == self.max_tries
|
|
128
|
+
max_time_exceeded = self.max_time is not None and self.elapsed >= self.max_time
|
|
129
|
+
return max_tries_exceeded or max_time_exceeded
|
|
130
|
+
|
|
131
|
+
def next_wait(self, send_value: Any, jitter: _Jitterer | None) -> float:
|
|
132
|
+
return _next_wait(self.wait, send_value, jitter, self.elapsed, self.max_time)
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
class _Attempt:
|
|
136
|
+
"""A single attempt yielded by `retry_context`/`aretry_context`.
|
|
137
|
+
|
|
138
|
+
Used as `with attempt: ...`. Exceptions matching `exception_types` are
|
|
139
|
+
caught here so the driving generator (not this object) decides whether
|
|
140
|
+
to retry, sleep, or let the exception propagate; anything else, or no
|
|
141
|
+
exception at all, is left for the `with` block's normal exit behavior.
|
|
142
|
+
"""
|
|
143
|
+
|
|
144
|
+
__slots__ = (
|
|
145
|
+
"exception",
|
|
146
|
+
"exception_types",
|
|
147
|
+
)
|
|
148
|
+
|
|
149
|
+
def __init__(
|
|
150
|
+
self,
|
|
151
|
+
exception_types: type[Exception] | tuple[type[Exception], ...],
|
|
152
|
+
) -> None:
|
|
153
|
+
self.exception_types = exception_types
|
|
154
|
+
self.exception: BaseException | None = None
|
|
155
|
+
|
|
156
|
+
def __enter__(self) -> Self:
|
|
157
|
+
return self
|
|
158
|
+
|
|
159
|
+
def __exit__(
|
|
160
|
+
self,
|
|
161
|
+
exc_type: type[BaseException] | None,
|
|
162
|
+
exc: BaseException | None,
|
|
163
|
+
tb: object,
|
|
164
|
+
) -> bool:
|
|
165
|
+
if exc_type is None:
|
|
166
|
+
# self.outcome = _Success()
|
|
167
|
+
return False
|
|
168
|
+
|
|
169
|
+
if not issubclass(exc_type, self.exception_types):
|
|
170
|
+
return False # not ours; propagate immediately
|
|
171
|
+
|
|
172
|
+
self.exception = exc
|
|
173
|
+
return True # suppress for now; the driving generator decides next
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
def _dispatch_handlers(handlers: Iterable[_ContextHandler], **details: Any) -> None:
|
|
177
|
+
for hdlr in handlers:
|
|
178
|
+
hdlr(details) # type: ignore[arg-type] # ty:ignore[invalid-argument-type]
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
def _prepare_logger(
|
|
182
|
+
logger: str | logging.Logger | logging.LoggerAdapter | None,
|
|
183
|
+
) -> logging.Logger | logging.LoggerAdapter | None:
|
|
184
|
+
if isinstance(logger, str):
|
|
185
|
+
logger = logging.getLogger(logger)
|
|
186
|
+
return logger
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
# Configure handler list with user specified handler and optionally
|
|
190
|
+
# with a default handler bound to the specified logger.
|
|
191
|
+
def _config_handlers(
|
|
192
|
+
user_handlers: _HandlerT | Iterable[_HandlerT] | None,
|
|
193
|
+
*,
|
|
194
|
+
default_handler: Callable[..., None] | None = None,
|
|
195
|
+
logger: logging.Logger | logging.LoggerAdapter | None = None,
|
|
196
|
+
log_level: int | None = None,
|
|
197
|
+
) -> list[_HandlerT]:
|
|
198
|
+
handlers: list[_HandlerT] = []
|
|
199
|
+
if logger is not None:
|
|
200
|
+
assert log_level is not None, "Log level is not specified"
|
|
201
|
+
assert default_handler is not None, "Default handler is not specified"
|
|
202
|
+
# bind the specified logger to the default log handler
|
|
203
|
+
log_handler = functools.partial(
|
|
204
|
+
default_handler,
|
|
205
|
+
logger=logger,
|
|
206
|
+
log_level=log_level,
|
|
207
|
+
)
|
|
208
|
+
handlers.append(log_handler) # type: ignore[arg-type] # ty:ignore[invalid-argument-type]
|
|
209
|
+
|
|
210
|
+
if user_handlers is None:
|
|
211
|
+
return handlers
|
|
212
|
+
|
|
213
|
+
# user specified handlers can either be an iterable of handlers
|
|
214
|
+
# or a single handler. either way append them to the list.
|
|
215
|
+
if hasattr(user_handlers, "__iter__"):
|
|
216
|
+
# add all handlers in the iterable
|
|
217
|
+
# pyrefly: ignore [bad-argument-type]
|
|
218
|
+
handlers += list(user_handlers) # ty:ignore[invalid-argument-type]
|
|
219
|
+
else:
|
|
220
|
+
# append a single handler
|
|
221
|
+
# pyrefly: ignore [bad-argument-type]
|
|
222
|
+
handlers.append(user_handlers)
|
|
223
|
+
|
|
224
|
+
return handlers
|
|
225
|
+
|
|
226
|
+
|
|
227
|
+
# Default backoff handler
|
|
228
|
+
def _log_backoff(
|
|
229
|
+
details: Details,
|
|
230
|
+
logger: logging.Logger | logging.LoggerAdapter,
|
|
231
|
+
log_level: int,
|
|
232
|
+
) -> None:
|
|
233
|
+
msg = "Backing off %s(...) for %.1fs (%s)"
|
|
234
|
+
log_args = [details["target"].__name__, details["wait"]] # ty:ignore[unresolved-attribute]
|
|
235
|
+
|
|
236
|
+
exc = details.get("exception")
|
|
237
|
+
if exc is not None:
|
|
238
|
+
exc_fmt = traceback.format_exception_only(type(exc), exc)[-1]
|
|
239
|
+
log_args.append(exc_fmt.rstrip("\n"))
|
|
240
|
+
else:
|
|
241
|
+
log_args.append(details["value"])
|
|
242
|
+
logger.log(log_level, msg, *log_args)
|
|
243
|
+
|
|
244
|
+
|
|
245
|
+
# Default giveup handler
|
|
246
|
+
def _log_giveup(
|
|
247
|
+
details: Details,
|
|
248
|
+
logger: logging.Logger | logging.LoggerAdapter,
|
|
249
|
+
log_level: int,
|
|
250
|
+
) -> None:
|
|
251
|
+
msg = "Giving up %s(...) after %d tries (%s)"
|
|
252
|
+
log_args = [details["target"].__name__, details["tries"]] # ty:ignore[unresolved-attribute]
|
|
253
|
+
|
|
254
|
+
exc = details.get("exception")
|
|
255
|
+
if exc is not None:
|
|
256
|
+
exc_fmt = traceback.format_exception_only(type(exc), exc)[-1]
|
|
257
|
+
log_args.append(exc_fmt.rstrip("\n"))
|
|
258
|
+
else:
|
|
259
|
+
log_args.append(details["value"])
|
|
260
|
+
|
|
261
|
+
logger.log(log_level, msg, *log_args)
|
|
262
|
+
|
|
263
|
+
|
|
264
|
+
# Default backoff handler for retry_context/aretry_context (no wrapped
|
|
265
|
+
# callable, so no name to log; the exception is read from `details`
|
|
266
|
+
# directly since it's no longer the active exception by this point).
|
|
267
|
+
def _log_backoff_context(
|
|
268
|
+
details: ContextDetails,
|
|
269
|
+
logger: logging.Logger | logging.LoggerAdapter,
|
|
270
|
+
log_level: int,
|
|
271
|
+
) -> None:
|
|
272
|
+
logger.log(
|
|
273
|
+
log_level,
|
|
274
|
+
"Backing off retry_context(...) for %.1fs (%s)",
|
|
275
|
+
details["wait"],
|
|
276
|
+
details.get("exception"),
|
|
277
|
+
)
|
|
278
|
+
|
|
279
|
+
|
|
280
|
+
# Default giveup handler for retry_context/aretry_context.
|
|
281
|
+
def _log_giveup_context(
|
|
282
|
+
details: ContextDetails,
|
|
283
|
+
logger: logging.Logger | logging.LoggerAdapter,
|
|
284
|
+
log_level: int,
|
|
285
|
+
) -> None:
|
|
286
|
+
logger.log(
|
|
287
|
+
log_level,
|
|
288
|
+
"Giving up retry_context(...) after %d tries (%s)",
|
|
289
|
+
details["tries"],
|
|
290
|
+
details.get("exception"),
|
|
291
|
+
)
|