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/retry.py
ADDED
|
@@ -0,0 +1,1324 @@
|
|
|
1
|
+
"""Retry, fallback and suppress decorators."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
import datetime
|
|
7
|
+
import functools
|
|
8
|
+
import inspect
|
|
9
|
+
import logging
|
|
10
|
+
import math
|
|
11
|
+
import random
|
|
12
|
+
import sys
|
|
13
|
+
import threading
|
|
14
|
+
import time
|
|
15
|
+
import warnings
|
|
16
|
+
from collections.abc import AsyncIterator, Awaitable, Callable, Coroutine, Iterator
|
|
17
|
+
from dataclasses import dataclass
|
|
18
|
+
from types import TracebackType
|
|
19
|
+
from typing import Any, NoReturn, Protocol, TypeVar, cast, overload
|
|
20
|
+
|
|
21
|
+
from python_wrap import _core, config
|
|
22
|
+
from python_wrap._core import F, LoggerLike, P, R
|
|
23
|
+
from python_wrap.deadline import remaining as _deadline_remaining
|
|
24
|
+
|
|
25
|
+
T = TypeVar("T")
|
|
26
|
+
T_co = TypeVar("T_co", covariant=True)
|
|
27
|
+
|
|
28
|
+
ExceptionTypes = type[BaseException] | tuple[type[BaseException], ...]
|
|
29
|
+
|
|
30
|
+
__all__ = [
|
|
31
|
+
"Attempt",
|
|
32
|
+
"RetryBudget",
|
|
33
|
+
"RetryBudgetStats",
|
|
34
|
+
"RetryState",
|
|
35
|
+
"RetryStats",
|
|
36
|
+
"Retrying",
|
|
37
|
+
"fallback",
|
|
38
|
+
"retry",
|
|
39
|
+
"retry_after_header",
|
|
40
|
+
"retry_on_exception",
|
|
41
|
+
"retry_stats",
|
|
42
|
+
"retrying",
|
|
43
|
+
"suppress",
|
|
44
|
+
]
|
|
45
|
+
|
|
46
|
+
_MISSING: Any = object()
|
|
47
|
+
_UNSET: Any = config.UNSET # resolved by `_resolve_defaults`
|
|
48
|
+
|
|
49
|
+
ExceptionPredicate = Callable[[BaseException], bool] | Callable[[BaseException], Awaitable[bool]]
|
|
50
|
+
ResultPredicate = Callable[[Any], bool] | Callable[[Any], Awaitable[bool]]
|
|
51
|
+
RetryCallback = Callable[["RetryState"], None] | Callable[["RetryState"], Awaitable[None]]
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
@dataclass(frozen=True)
|
|
55
|
+
class RetryState:
|
|
56
|
+
"""Snapshot passed to the `on_retry` callback before each wait.
|
|
57
|
+
|
|
58
|
+
Attributes:
|
|
59
|
+
attempt: Number of the attempt that just failed (1-based).
|
|
60
|
+
exception: The exception that attempt raised, or `None` when the attempt
|
|
61
|
+
returned a value that `retry_if_result` rejected.
|
|
62
|
+
next_delay: Seconds about to be slept before the next attempt (jitter included).
|
|
63
|
+
elapsed: Seconds since the first attempt started.
|
|
64
|
+
result: The rejected return value when `exception` is `None`, else `None`.
|
|
65
|
+
"""
|
|
66
|
+
|
|
67
|
+
attempt: int
|
|
68
|
+
exception: BaseException | None
|
|
69
|
+
next_delay: float
|
|
70
|
+
elapsed: float
|
|
71
|
+
result: Any = None
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def _validate(
|
|
75
|
+
max_attempts: int | None,
|
|
76
|
+
delay: float,
|
|
77
|
+
backoff: float,
|
|
78
|
+
max_delay: float | None,
|
|
79
|
+
jitter: float,
|
|
80
|
+
max_time: float | None = None,
|
|
81
|
+
) -> None:
|
|
82
|
+
if max_attempts is not None and max_attempts < 1:
|
|
83
|
+
raise ValueError(f"max_attempts must be >= 1 or None, got {max_attempts!r}")
|
|
84
|
+
if delay < 0:
|
|
85
|
+
raise ValueError(f"delay must be >= 0, got {delay!r}")
|
|
86
|
+
if backoff < 1:
|
|
87
|
+
raise ValueError(f"backoff must be >= 1, got {backoff!r}")
|
|
88
|
+
if jitter < 0:
|
|
89
|
+
raise ValueError(f"jitter must be >= 0, got {jitter!r}")
|
|
90
|
+
if max_delay is not None and max_delay < 0:
|
|
91
|
+
raise ValueError(f"max_delay must be >= 0, got {max_delay!r}")
|
|
92
|
+
if max_time is not None and max_time < 0:
|
|
93
|
+
raise ValueError(f"max_time must be >= 0 or None, got {max_time!r}")
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def _compute_delay(
|
|
97
|
+
attempt: int, delay: float, backoff: float, max_delay: float | None, jitter: float
|
|
98
|
+
) -> float:
|
|
99
|
+
try:
|
|
100
|
+
wait = delay * backoff ** (attempt - 1)
|
|
101
|
+
except OverflowError:
|
|
102
|
+
wait = sys.float_info.max
|
|
103
|
+
if max_delay is not None:
|
|
104
|
+
wait = min(wait, max_delay)
|
|
105
|
+
if jitter:
|
|
106
|
+
wait += random.uniform(0, jitter)
|
|
107
|
+
return wait
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
class RetryStats:
|
|
111
|
+
"""Thread-safe counters for one retried function. Read them with `retry_stats`.
|
|
112
|
+
|
|
113
|
+
Attributes:
|
|
114
|
+
calls: Times the wrapped function was called (each call may make several attempts).
|
|
115
|
+
attempts_total: Individual attempts made across all calls.
|
|
116
|
+
retries: Times an attempt failed and another one was scheduled.
|
|
117
|
+
gave_up: Calls that ended by re-raising a retryable exception, or by returning
|
|
118
|
+
a result `retry_if_result` rejected (attempts, time budget, retry budget or
|
|
119
|
+
`retry_if` said stop).
|
|
120
|
+
budget_exhausted: Calls among `gave_up` that stopped because the shared
|
|
121
|
+
`RetryBudget` refused another retry.
|
|
122
|
+
total_sleep: Seconds spent waiting between attempts.
|
|
123
|
+
last_exception: The most recent exception a retried attempt raised, if any.
|
|
124
|
+
"""
|
|
125
|
+
|
|
126
|
+
def __init__(self) -> None:
|
|
127
|
+
self._lock = threading.Lock()
|
|
128
|
+
self.calls = 0
|
|
129
|
+
self.attempts_total = 0
|
|
130
|
+
self.retries = 0
|
|
131
|
+
self.gave_up = 0
|
|
132
|
+
self.budget_exhausted = 0
|
|
133
|
+
self.total_sleep = 0.0
|
|
134
|
+
self.last_exception: BaseException | None = None
|
|
135
|
+
|
|
136
|
+
def snapshot(self) -> dict[str, Any]:
|
|
137
|
+
"""Return a consistent copy of all counters as a dict."""
|
|
138
|
+
with self._lock:
|
|
139
|
+
return {
|
|
140
|
+
"calls": self.calls,
|
|
141
|
+
"attempts_total": self.attempts_total,
|
|
142
|
+
"retries": self.retries,
|
|
143
|
+
"gave_up": self.gave_up,
|
|
144
|
+
"budget_exhausted": self.budget_exhausted,
|
|
145
|
+
"total_sleep": self.total_sleep,
|
|
146
|
+
"last_exception": self.last_exception,
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
def reset(self) -> None:
|
|
150
|
+
"""Set every counter back to zero."""
|
|
151
|
+
with self._lock:
|
|
152
|
+
self.calls = self.attempts_total = self.retries = self.gave_up = 0
|
|
153
|
+
self.budget_exhausted = 0
|
|
154
|
+
self.total_sleep = 0.0
|
|
155
|
+
self.last_exception = None
|
|
156
|
+
|
|
157
|
+
def __repr__(self) -> str:
|
|
158
|
+
s = self.snapshot()
|
|
159
|
+
return (
|
|
160
|
+
f"RetryStats(calls={s['calls']}, attempts_total={s['attempts_total']}, "
|
|
161
|
+
f"retries={s['retries']}, gave_up={s['gave_up']}, "
|
|
162
|
+
f"budget_exhausted={s['budget_exhausted']}, total_sleep={s['total_sleep']:g})"
|
|
163
|
+
)
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
_STATS_ATTR = "__retry_stats__"
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
def retry_stats(func: Callable[..., Any]) -> RetryStats | None:
|
|
170
|
+
"""Return the `RetryStats` of a function decorated with `retry`.
|
|
171
|
+
|
|
172
|
+
Works through other decorators that use `functools.wraps` and on bound methods
|
|
173
|
+
(the counters are shared by all instances of the class).
|
|
174
|
+
|
|
175
|
+
Args:
|
|
176
|
+
func: A function wrapped by `retry`.
|
|
177
|
+
|
|
178
|
+
Returns:
|
|
179
|
+
Its statistics, or `None` if `func` is not retried.
|
|
180
|
+
|
|
181
|
+
Example:
|
|
182
|
+
>>> @retry
|
|
183
|
+
... def f() -> None: ...
|
|
184
|
+
>>> retry_stats(f).calls
|
|
185
|
+
0
|
|
186
|
+
"""
|
|
187
|
+
stats = getattr(func, _STATS_ATTR, None)
|
|
188
|
+
return stats if isinstance(stats, RetryStats) else None
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
@dataclass(frozen=True)
|
|
192
|
+
class RetryBudgetStats:
|
|
193
|
+
"""Snapshot of a `RetryBudget`, returned by `RetryBudget.stats`.
|
|
194
|
+
|
|
195
|
+
Attributes:
|
|
196
|
+
requests: First attempts recorded in the current window.
|
|
197
|
+
retries: Retries granted in the current window.
|
|
198
|
+
rejected: Retries refused since creation (or the last `reset`).
|
|
199
|
+
allowed: Retries the budget would still grant right now.
|
|
200
|
+
"""
|
|
201
|
+
|
|
202
|
+
requests: int
|
|
203
|
+
retries: int
|
|
204
|
+
rejected: int
|
|
205
|
+
allowed: int
|
|
206
|
+
|
|
207
|
+
|
|
208
|
+
_BUDGET_SLOTS = 10
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
class RetryBudget:
|
|
212
|
+
"""A retry allowance shared by many retried functions (Finagle/Envoy style).
|
|
213
|
+
|
|
214
|
+
Every call's first attempt deposits into the budget and every retry withdraws from
|
|
215
|
+
it. A retry is allowed while the retries made in the last `window` seconds stay
|
|
216
|
+
within `ratio` times the requests made in that window, plus a reserve of
|
|
217
|
+
`min_per_second * window` retries so that low-traffic code can still retry. When a
|
|
218
|
+
dependency fails for everyone, retries are capped at about `ratio` of the traffic
|
|
219
|
+
instead of multiplying it by `max_attempts`.
|
|
220
|
+
|
|
221
|
+
One budget can be passed to any number of `retry` decorators and `retrying` blocks;
|
|
222
|
+
it is thread-safe and works across threads and event loops (it never blocks). Time
|
|
223
|
+
comes from `python_wrap._core.monotonic`, so `fake_clock` controls it in tests.
|
|
224
|
+
|
|
225
|
+
Args:
|
|
226
|
+
ratio: Retries allowed per request in the window (`0.1` = 10 %). `>= 0`.
|
|
227
|
+
min_per_second: Retries per second always allowed regardless of traffic. `>= 0`.
|
|
228
|
+
window: Length of the sliding window in seconds. `> 0`.
|
|
229
|
+
|
|
230
|
+
Raises:
|
|
231
|
+
ValueError: If an argument is out of range.
|
|
232
|
+
|
|
233
|
+
Example:
|
|
234
|
+
```python
|
|
235
|
+
from python_wrap.retry import RetryBudget, retry
|
|
236
|
+
|
|
237
|
+
budget = RetryBudget(ratio=0.2, min_per_second=1, window=10)
|
|
238
|
+
|
|
239
|
+
@retry(max_attempts=3, delay=0, budget=budget)
|
|
240
|
+
def fetch() -> str:
|
|
241
|
+
return "ok"
|
|
242
|
+
|
|
243
|
+
assert fetch() == "ok"
|
|
244
|
+
assert budget.stats.requests == 1
|
|
245
|
+
```
|
|
246
|
+
"""
|
|
247
|
+
|
|
248
|
+
def __reduce__(self) -> NoReturn:
|
|
249
|
+
_core.refuse_pickle(self)
|
|
250
|
+
|
|
251
|
+
def __init__(
|
|
252
|
+
self, ratio: float = 0.1, min_per_second: float = 10, window: float = 10.0
|
|
253
|
+
) -> None:
|
|
254
|
+
if not ratio >= 0 or math.isinf(ratio):
|
|
255
|
+
raise ValueError(f"ratio must be a finite number >= 0, got {ratio!r}")
|
|
256
|
+
if not min_per_second >= 0 or math.isinf(min_per_second):
|
|
257
|
+
raise ValueError(f"min_per_second must be a finite number >= 0, got {min_per_second!r}")
|
|
258
|
+
if not window > 0 or math.isinf(window):
|
|
259
|
+
raise ValueError(f"window must be a finite number > 0, got {window!r}")
|
|
260
|
+
self.ratio = float(ratio)
|
|
261
|
+
self.min_per_second = float(min_per_second)
|
|
262
|
+
self.window = float(window)
|
|
263
|
+
self._reserve = self.min_per_second * self.window
|
|
264
|
+
self._width = self.window / _BUDGET_SLOTS
|
|
265
|
+
self._lock = threading.Lock()
|
|
266
|
+
self._epochs = [-1] * _BUDGET_SLOTS
|
|
267
|
+
self._requests = [0] * _BUDGET_SLOTS
|
|
268
|
+
self._retries = [0] * _BUDGET_SLOTS
|
|
269
|
+
self._rejected = 0
|
|
270
|
+
|
|
271
|
+
def _slot(self) -> tuple[int, int]:
|
|
272
|
+
"""Index and epoch of the current slot; clears it if it held an older epoch."""
|
|
273
|
+
epoch = int(_core.monotonic() // self._width)
|
|
274
|
+
index = epoch % _BUDGET_SLOTS
|
|
275
|
+
if self._epochs[index] != epoch:
|
|
276
|
+
self._epochs[index] = epoch
|
|
277
|
+
self._requests[index] = 0
|
|
278
|
+
self._retries[index] = 0
|
|
279
|
+
return index, epoch
|
|
280
|
+
|
|
281
|
+
def _totals(self, epoch: int) -> tuple[int, int]:
|
|
282
|
+
oldest = epoch - _BUDGET_SLOTS
|
|
283
|
+
requests = retries = 0
|
|
284
|
+
for i in range(_BUDGET_SLOTS):
|
|
285
|
+
if self._epochs[i] > oldest:
|
|
286
|
+
requests += self._requests[i]
|
|
287
|
+
retries += self._retries[i]
|
|
288
|
+
return requests, retries
|
|
289
|
+
|
|
290
|
+
def _available(self, requests: int, retries: int) -> float:
|
|
291
|
+
return self.ratio * requests + self._reserve - retries
|
|
292
|
+
|
|
293
|
+
def deposit(self) -> None:
|
|
294
|
+
"""Record one request (a first attempt). `retry` calls this for you."""
|
|
295
|
+
with self._lock:
|
|
296
|
+
index, _ = self._slot()
|
|
297
|
+
self._requests[index] += 1
|
|
298
|
+
|
|
299
|
+
def try_withdraw(self) -> bool:
|
|
300
|
+
"""Take one retry from the budget.
|
|
301
|
+
|
|
302
|
+
`retry` calls this before each retry; call it yourself only in a hand-written
|
|
303
|
+
loop.
|
|
304
|
+
|
|
305
|
+
Returns:
|
|
306
|
+
`True` if the retry is allowed (and was recorded), `False` if the budget is
|
|
307
|
+
exhausted (recorded as rejected).
|
|
308
|
+
"""
|
|
309
|
+
with self._lock:
|
|
310
|
+
index, epoch = self._slot()
|
|
311
|
+
if self._available(*self._totals(epoch)) >= 1:
|
|
312
|
+
self._retries[index] += 1
|
|
313
|
+
return True
|
|
314
|
+
self._rejected += 1
|
|
315
|
+
return False
|
|
316
|
+
|
|
317
|
+
@property
|
|
318
|
+
def stats(self) -> RetryBudgetStats:
|
|
319
|
+
"""A consistent `RetryBudgetStats` snapshot of the current window."""
|
|
320
|
+
with self._lock:
|
|
321
|
+
_, epoch = self._slot()
|
|
322
|
+
requests, retries = self._totals(epoch)
|
|
323
|
+
allowed = max(math.floor(self._available(requests, retries)), 0)
|
|
324
|
+
return RetryBudgetStats(requests, retries, self._rejected, allowed)
|
|
325
|
+
|
|
326
|
+
def reset(self) -> None:
|
|
327
|
+
"""Forget all recorded requests, retries and rejections."""
|
|
328
|
+
with self._lock:
|
|
329
|
+
self._epochs = [-1] * _BUDGET_SLOTS
|
|
330
|
+
self._requests = [0] * _BUDGET_SLOTS
|
|
331
|
+
self._retries = [0] * _BUDGET_SLOTS
|
|
332
|
+
self._rejected = 0
|
|
333
|
+
|
|
334
|
+
def __repr__(self) -> str:
|
|
335
|
+
return (
|
|
336
|
+
f"RetryBudget(ratio={self.ratio:g}, min_per_second={self.min_per_second:g}, "
|
|
337
|
+
f"window={self.window:g})"
|
|
338
|
+
)
|
|
339
|
+
|
|
340
|
+
|
|
341
|
+
async def _maybe_await(value: Any) -> Any:
|
|
342
|
+
if inspect.isawaitable(value):
|
|
343
|
+
return await value
|
|
344
|
+
return value
|
|
345
|
+
|
|
346
|
+
|
|
347
|
+
def _parse_seconds(value: Any) -> float | None:
|
|
348
|
+
if value is None or isinstance(value, bool):
|
|
349
|
+
return None
|
|
350
|
+
if isinstance(value, (int, float)):
|
|
351
|
+
seconds = float(value)
|
|
352
|
+
else:
|
|
353
|
+
text = str(value).strip()
|
|
354
|
+
try:
|
|
355
|
+
seconds = float(text)
|
|
356
|
+
except ValueError:
|
|
357
|
+
import email.utils # only HTTP-date headers need it; keeps import fast
|
|
358
|
+
|
|
359
|
+
moment = email.utils.parsedate_to_datetime(text)
|
|
360
|
+
if moment.tzinfo is None:
|
|
361
|
+
moment = moment.replace(tzinfo=datetime.timezone.utc)
|
|
362
|
+
seconds = moment.timestamp() - time.time()
|
|
363
|
+
if math.isnan(seconds) or math.isinf(seconds):
|
|
364
|
+
return None
|
|
365
|
+
return max(seconds, 0.0)
|
|
366
|
+
|
|
367
|
+
|
|
368
|
+
def retry_after_header(exc: BaseException) -> float | None:
|
|
369
|
+
"""Extract a server-requested wait from an exception, for `delay_from=`.
|
|
370
|
+
|
|
371
|
+
Looks at `exc.retry_after` first, then at `exc.response.headers["Retry-After"]`
|
|
372
|
+
(header names are matched case-insensitively). Values may be seconds or an HTTP-date;
|
|
373
|
+
dates are converted to seconds from now. Works with `httpx`, `requests` and
|
|
374
|
+
`aiohttp`-style exceptions without importing them, and never raises.
|
|
375
|
+
|
|
376
|
+
Args:
|
|
377
|
+
exc: Any exception.
|
|
378
|
+
|
|
379
|
+
Returns:
|
|
380
|
+
Seconds to wait (never negative), or `None` if nothing usable was found.
|
|
381
|
+
|
|
382
|
+
Example:
|
|
383
|
+
>>> class Err(Exception):
|
|
384
|
+
... retry_after = 3
|
|
385
|
+
>>> retry_after_header(Err())
|
|
386
|
+
3.0
|
|
387
|
+
"""
|
|
388
|
+
try:
|
|
389
|
+
found = _parse_seconds(getattr(exc, "retry_after", None))
|
|
390
|
+
if found is not None:
|
|
391
|
+
return found
|
|
392
|
+
headers = exc.response.headers # type: ignore[attr-defined]
|
|
393
|
+
value = headers.get("Retry-After")
|
|
394
|
+
if value is None:
|
|
395
|
+
for key in headers:
|
|
396
|
+
if str(key).lower() == "retry-after":
|
|
397
|
+
value = headers[key]
|
|
398
|
+
break
|
|
399
|
+
return _parse_seconds(value)
|
|
400
|
+
except Exception:
|
|
401
|
+
return None
|
|
402
|
+
|
|
403
|
+
|
|
404
|
+
# Never retried, even with ``on=BaseException``: retrying these hangs shutdown/cancellation.
|
|
405
|
+
_NEVER_RETRY = (asyncio.CancelledError, KeyboardInterrupt, SystemExit, GeneratorExit)
|
|
406
|
+
|
|
407
|
+
|
|
408
|
+
def _parse_hint(value: Any) -> float | None:
|
|
409
|
+
if isinstance(value, bool) or not isinstance(value, (int, float)) or math.isnan(value):
|
|
410
|
+
return None
|
|
411
|
+
return max(float(value), 0.0)
|
|
412
|
+
|
|
413
|
+
|
|
414
|
+
def _resolve_defaults(**given: Any) -> dict[str, Any]:
|
|
415
|
+
"""Fill arguments left unset from `python_wrap.config` (read at decoration time)."""
|
|
416
|
+
return {k: config.retry_default(k, v, _RETRY_FALLBACK[k]) for k, v in given.items()}
|
|
417
|
+
|
|
418
|
+
|
|
419
|
+
_RETRY_FALLBACK: dict[str, Any] = {
|
|
420
|
+
"max_attempts": 3,
|
|
421
|
+
"delay": 1.0,
|
|
422
|
+
"backoff": 1.0,
|
|
423
|
+
"max_delay": None,
|
|
424
|
+
"jitter": 0.0,
|
|
425
|
+
"max_time": None,
|
|
426
|
+
"log_level": logging.WARNING,
|
|
427
|
+
"budget": None,
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
|
|
431
|
+
def _async_hooks(**hooks: Callable[..., Any] | None) -> list[str]:
|
|
432
|
+
"""Names of the given hooks that are async callables."""
|
|
433
|
+
return [name for name, hook in hooks.items() if hook is not None and _core.is_async(hook)]
|
|
434
|
+
|
|
435
|
+
|
|
436
|
+
class _Engine:
|
|
437
|
+
"""The retry state machine shared by `retry` and `retrying`."""
|
|
438
|
+
|
|
439
|
+
def __init__(
|
|
440
|
+
self,
|
|
441
|
+
name: str,
|
|
442
|
+
*,
|
|
443
|
+
max_attempts: int | None,
|
|
444
|
+
delay: float,
|
|
445
|
+
backoff: float,
|
|
446
|
+
max_delay: float | None,
|
|
447
|
+
jitter: float,
|
|
448
|
+
max_time: float | None,
|
|
449
|
+
on: ExceptionTypes,
|
|
450
|
+
retry_if: ExceptionPredicate | None,
|
|
451
|
+
retry_if_result: ResultPredicate | None,
|
|
452
|
+
on_retry: RetryCallback | None,
|
|
453
|
+
delay_from: Callable[[BaseException], float | None] | None,
|
|
454
|
+
budget: RetryBudget | None,
|
|
455
|
+
logger: LoggerLike,
|
|
456
|
+
log_level: int | None,
|
|
457
|
+
log_exc_info: bool,
|
|
458
|
+
stats: RetryStats,
|
|
459
|
+
) -> None:
|
|
460
|
+
_validate(max_attempts, delay, backoff, max_delay, jitter, max_time)
|
|
461
|
+
_check_budget(budget)
|
|
462
|
+
self.name = name
|
|
463
|
+
self.max_attempts = max_attempts
|
|
464
|
+
self.delay = delay
|
|
465
|
+
self.backoff = backoff
|
|
466
|
+
self.max_delay = max_delay
|
|
467
|
+
self.jitter = jitter
|
|
468
|
+
self.max_time = max_time
|
|
469
|
+
self.on = on
|
|
470
|
+
self.retry_if = retry_if
|
|
471
|
+
self.retry_if_result = retry_if_result
|
|
472
|
+
self.on_retry = on_retry
|
|
473
|
+
self.delay_from = delay_from
|
|
474
|
+
self.budget = budget
|
|
475
|
+
self.log = _core.get_logger(logger)
|
|
476
|
+
self.log_level = log_level
|
|
477
|
+
self.log_exc_info = log_exc_info
|
|
478
|
+
self.stats = stats
|
|
479
|
+
if budget is not None: # keeps the budget check off the no-budget hot path
|
|
480
|
+
self.start_call = self._start_call_with_budget # type: ignore[method-assign]
|
|
481
|
+
self.async_hooks = _async_hooks(
|
|
482
|
+
retry_if=retry_if, retry_if_result=retry_if_result, on_retry=on_retry
|
|
483
|
+
)
|
|
484
|
+
|
|
485
|
+
def limit(self) -> int | None:
|
|
486
|
+
"""Attempts allowed for this call, honouring the test override at call time."""
|
|
487
|
+
cap = _core.overrides.max_attempts
|
|
488
|
+
if cap is None:
|
|
489
|
+
return self.max_attempts
|
|
490
|
+
return cap if self.max_attempts is None else min(self.max_attempts, cap)
|
|
491
|
+
|
|
492
|
+
def start_call(self) -> float:
|
|
493
|
+
with self.stats._lock:
|
|
494
|
+
self.stats.calls += 1
|
|
495
|
+
return _core.monotonic()
|
|
496
|
+
|
|
497
|
+
def _start_call_with_budget(self) -> float:
|
|
498
|
+
with self.stats._lock:
|
|
499
|
+
self.stats.calls += 1
|
|
500
|
+
self.budget.deposit() # type: ignore[union-attr] # only installed with a budget
|
|
501
|
+
return _core.monotonic()
|
|
502
|
+
|
|
503
|
+
def start_attempt(self) -> None:
|
|
504
|
+
with self.stats._lock:
|
|
505
|
+
self.stats.attempts_total += 1
|
|
506
|
+
|
|
507
|
+
def success(self, attempts: int) -> None:
|
|
508
|
+
_core.emit("retry.succeeded", self.name, attempts=attempts)
|
|
509
|
+
|
|
510
|
+
def slept(self, seconds: float) -> None:
|
|
511
|
+
with self.stats._lock:
|
|
512
|
+
self.stats.total_sleep += seconds
|
|
513
|
+
|
|
514
|
+
def check_sync(self, *hooks: str) -> None:
|
|
515
|
+
"""Refuse async hooks where nothing can await them."""
|
|
516
|
+
bad = [h for h in hooks if h in self.async_hooks]
|
|
517
|
+
if bad:
|
|
518
|
+
raise TypeError(
|
|
519
|
+
f"{self.name}: {', '.join(f'{h}=' for h in bad)} is an async function, which "
|
|
520
|
+
"only an async retry can await: decorate an `async def` function, or use "
|
|
521
|
+
"`async for attempt in retrying(...)` with `async with attempt:`"
|
|
522
|
+
)
|
|
523
|
+
|
|
524
|
+
def _wait_for(self, attempt: int, exc: BaseException | None) -> float:
|
|
525
|
+
if self.delay_from is not None and exc is not None:
|
|
526
|
+
try:
|
|
527
|
+
hint = _parse_hint(self.delay_from(exc))
|
|
528
|
+
except Exception:
|
|
529
|
+
self.log.debug("%s: delay_from raised; using backoff", self.name, exc_info=True)
|
|
530
|
+
hint = None
|
|
531
|
+
if hint is not None:
|
|
532
|
+
return hint if self.max_delay is None else min(hint, self.max_delay)
|
|
533
|
+
return _compute_delay(attempt, self.delay, self.backoff, self.max_delay, self.jitter)
|
|
534
|
+
|
|
535
|
+
def _plan(
|
|
536
|
+
self, attempt: int, exc: BaseException | None, wanted: bool, started: float
|
|
537
|
+
) -> float | None:
|
|
538
|
+
"""The wait before the next attempt, or None to stop (counted and emitted)."""
|
|
539
|
+
wait: float | None = None
|
|
540
|
+
denied = False
|
|
541
|
+
limit = self.limit()
|
|
542
|
+
if wanted and (limit is None or attempt < limit):
|
|
543
|
+
wait = self._wait_for(attempt, exc)
|
|
544
|
+
if self.max_time is not None and _core.monotonic() - started + wait > self.max_time:
|
|
545
|
+
wait = None
|
|
546
|
+
left = _deadline_remaining()
|
|
547
|
+
if wait is not None and left is not None and wait > left:
|
|
548
|
+
wait = None # never sleep past the surrounding `deadline`
|
|
549
|
+
if wait is not None and self.budget is not None and not self.budget.try_withdraw():
|
|
550
|
+
wait = None
|
|
551
|
+
denied = True
|
|
552
|
+
if wait is None:
|
|
553
|
+
with self.stats._lock:
|
|
554
|
+
self.stats.gave_up += 1
|
|
555
|
+
if denied:
|
|
556
|
+
self.stats.budget_exhausted += 1
|
|
557
|
+
if denied:
|
|
558
|
+
_core.emit("retry.budget_exhausted", self.name, attempts=attempt, exception=exc)
|
|
559
|
+
_core.emit("retry.gave_up", self.name, attempts=attempt, exception=exc)
|
|
560
|
+
return None
|
|
561
|
+
with self.stats._lock:
|
|
562
|
+
self.stats.retries += 1
|
|
563
|
+
return wait
|
|
564
|
+
|
|
565
|
+
def _state(
|
|
566
|
+
self, attempt: int, exc: BaseException | None, result: Any, wait: float, started: float
|
|
567
|
+
) -> RetryState:
|
|
568
|
+
return RetryState(attempt, exc, wait, _core.monotonic() - started, result)
|
|
569
|
+
|
|
570
|
+
def _announce(self, attempt: int, exc: BaseException | None, result: Any, wait: float) -> None:
|
|
571
|
+
if self.log_level is not None:
|
|
572
|
+
limit = self.limit()
|
|
573
|
+
what = repr(exc) if exc is not None else f"returned {result!r}"
|
|
574
|
+
self.log.log(
|
|
575
|
+
self.log_level,
|
|
576
|
+
"%s failed (attempt %d/%s): %s; retrying in %.3fs",
|
|
577
|
+
self.name,
|
|
578
|
+
attempt,
|
|
579
|
+
"inf" if limit is None else limit,
|
|
580
|
+
what,
|
|
581
|
+
wait,
|
|
582
|
+
exc_info=(
|
|
583
|
+
(type(exc), exc, exc.__traceback__)
|
|
584
|
+
if self.log_exc_info and exc is not None
|
|
585
|
+
else None
|
|
586
|
+
),
|
|
587
|
+
)
|
|
588
|
+
_core.emit(
|
|
589
|
+
"retry.attempt_failed", self.name, attempt=attempt, exception=exc, next_delay=wait
|
|
590
|
+
)
|
|
591
|
+
|
|
592
|
+
def _record(self, exc: BaseException) -> None:
|
|
593
|
+
with self.stats._lock:
|
|
594
|
+
self.stats.last_exception = exc
|
|
595
|
+
|
|
596
|
+
# -- sync decisions -------------------------------------------------------------------
|
|
597
|
+
def failure(self, attempt: int, exc: BaseException, started: float) -> float | None:
|
|
598
|
+
"""Decide after a failed attempt: the wait in seconds, or None to re-raise."""
|
|
599
|
+
if isinstance(exc, _NEVER_RETRY):
|
|
600
|
+
return None
|
|
601
|
+
self.check_sync("retry_if", "on_retry")
|
|
602
|
+
self._record(exc)
|
|
603
|
+
wanted = self.retry_if is None or bool(self.retry_if(exc))
|
|
604
|
+
wait = self._plan(attempt, exc, wanted, started)
|
|
605
|
+
if wait is not None:
|
|
606
|
+
if self.on_retry is not None:
|
|
607
|
+
self.on_retry(self._state(attempt, exc, None, wait, started))
|
|
608
|
+
self._announce(attempt, exc, None, wait)
|
|
609
|
+
return wait
|
|
610
|
+
|
|
611
|
+
def rejects(self, result: Any) -> bool:
|
|
612
|
+
"""True if `retry_if_result` asks for another attempt (sync callers)."""
|
|
613
|
+
if self.retry_if_result is None:
|
|
614
|
+
return False
|
|
615
|
+
self.check_sync("retry_if_result")
|
|
616
|
+
return bool(self.retry_if_result(result))
|
|
617
|
+
|
|
618
|
+
def result_failure(self, attempt: int, result: Any, started: float) -> float | None:
|
|
619
|
+
"""Decide after a rejected result: the wait, or None to return it as is."""
|
|
620
|
+
self.check_sync("on_retry")
|
|
621
|
+
wait = self._plan(attempt, None, True, started)
|
|
622
|
+
if wait is not None:
|
|
623
|
+
if self.on_retry is not None:
|
|
624
|
+
self.on_retry(self._state(attempt, None, result, wait, started))
|
|
625
|
+
self._announce(attempt, None, result, wait)
|
|
626
|
+
return wait
|
|
627
|
+
|
|
628
|
+
# -- async decisions ------------------------------------------------------------------
|
|
629
|
+
async def afailure(self, attempt: int, exc: BaseException, started: float) -> float | None:
|
|
630
|
+
"""`failure` for async callers: async hooks are awaited."""
|
|
631
|
+
if isinstance(exc, _NEVER_RETRY):
|
|
632
|
+
return None
|
|
633
|
+
self._record(exc)
|
|
634
|
+
wanted = self.retry_if is None or bool(await _maybe_await(self.retry_if(exc)))
|
|
635
|
+
wait = self._plan(attempt, exc, wanted, started)
|
|
636
|
+
if wait is not None:
|
|
637
|
+
if self.on_retry is not None:
|
|
638
|
+
await _maybe_await(self.on_retry(self._state(attempt, exc, None, wait, started)))
|
|
639
|
+
self._announce(attempt, exc, None, wait)
|
|
640
|
+
return wait
|
|
641
|
+
|
|
642
|
+
async def arejects(self, result: Any) -> bool:
|
|
643
|
+
"""`rejects` for async callers."""
|
|
644
|
+
if self.retry_if_result is None:
|
|
645
|
+
return False
|
|
646
|
+
return bool(await _maybe_await(self.retry_if_result(result)))
|
|
647
|
+
|
|
648
|
+
async def aresult_failure(self, attempt: int, result: Any, started: float) -> float | None:
|
|
649
|
+
"""`result_failure` for async callers."""
|
|
650
|
+
wait = self._plan(attempt, None, True, started)
|
|
651
|
+
if wait is not None:
|
|
652
|
+
if self.on_retry is not None:
|
|
653
|
+
state = self._state(attempt, None, result, wait, started)
|
|
654
|
+
await _maybe_await(self.on_retry(state))
|
|
655
|
+
self._announce(attempt, None, result, wait)
|
|
656
|
+
return wait
|
|
657
|
+
|
|
658
|
+
|
|
659
|
+
def _check_budget(budget: Any) -> None:
|
|
660
|
+
if budget is not None and not isinstance(budget, RetryBudget):
|
|
661
|
+
raise TypeError(f"budget must be a RetryBudget or None, got {budget!r}")
|
|
662
|
+
|
|
663
|
+
|
|
664
|
+
@overload
|
|
665
|
+
def retry(
|
|
666
|
+
func: F,
|
|
667
|
+
/,
|
|
668
|
+
*,
|
|
669
|
+
max_attempts: int | None = ...,
|
|
670
|
+
delay: float = ...,
|
|
671
|
+
backoff: float = ...,
|
|
672
|
+
max_delay: float | None = ...,
|
|
673
|
+
jitter: float = ...,
|
|
674
|
+
max_time: float | None = ...,
|
|
675
|
+
on: ExceptionTypes = ...,
|
|
676
|
+
retry_if: ExceptionPredicate | None = ...,
|
|
677
|
+
retry_if_result: ResultPredicate | None = ...,
|
|
678
|
+
on_retry: RetryCallback | None = ...,
|
|
679
|
+
delay_from: Callable[[BaseException], float | None] | None = ...,
|
|
680
|
+
budget: RetryBudget | None = ...,
|
|
681
|
+
logger: LoggerLike = ...,
|
|
682
|
+
log_level: int | None = ...,
|
|
683
|
+
log_exc_info: bool = ...,
|
|
684
|
+
) -> F: ...
|
|
685
|
+
@overload
|
|
686
|
+
def retry(
|
|
687
|
+
func: None = None,
|
|
688
|
+
/,
|
|
689
|
+
*,
|
|
690
|
+
max_attempts: int | None = ...,
|
|
691
|
+
delay: float = ...,
|
|
692
|
+
backoff: float = ...,
|
|
693
|
+
max_delay: float | None = ...,
|
|
694
|
+
jitter: float = ...,
|
|
695
|
+
max_time: float | None = ...,
|
|
696
|
+
on: ExceptionTypes = ...,
|
|
697
|
+
retry_if: ExceptionPredicate | None = ...,
|
|
698
|
+
retry_if_result: ResultPredicate | None = ...,
|
|
699
|
+
on_retry: RetryCallback | None = ...,
|
|
700
|
+
delay_from: Callable[[BaseException], float | None] | None = ...,
|
|
701
|
+
budget: RetryBudget | None = ...,
|
|
702
|
+
logger: LoggerLike = ...,
|
|
703
|
+
log_level: int | None = ...,
|
|
704
|
+
log_exc_info: bool = ...,
|
|
705
|
+
) -> Callable[[F], F]: ...
|
|
706
|
+
def retry(
|
|
707
|
+
func: Callable[..., Any] | None = None,
|
|
708
|
+
/,
|
|
709
|
+
*,
|
|
710
|
+
max_attempts: int | None = _UNSET,
|
|
711
|
+
delay: float = _UNSET,
|
|
712
|
+
backoff: float = _UNSET,
|
|
713
|
+
max_delay: float | None = _UNSET,
|
|
714
|
+
jitter: float = _UNSET,
|
|
715
|
+
max_time: float | None = _UNSET,
|
|
716
|
+
on: ExceptionTypes = Exception,
|
|
717
|
+
retry_if: ExceptionPredicate | None = None,
|
|
718
|
+
retry_if_result: ResultPredicate | None = None,
|
|
719
|
+
on_retry: RetryCallback | None = None,
|
|
720
|
+
delay_from: Callable[[BaseException], float | None] | None = None,
|
|
721
|
+
budget: RetryBudget | None = _UNSET,
|
|
722
|
+
logger: LoggerLike = None,
|
|
723
|
+
log_level: int | None = _UNSET,
|
|
724
|
+
log_exc_info: bool = False,
|
|
725
|
+
) -> Any:
|
|
726
|
+
"""Retry a function when it raises (or returns a bad value), with optional backoff.
|
|
727
|
+
|
|
728
|
+
Works bare (`@retry`), with options (`@retry(max_attempts=5)`) and as a plain
|
|
729
|
+
call (`retry(func, max_attempts=5)`). Async functions are awaited and slept
|
|
730
|
+
with `asyncio` so the event loop is never blocked.
|
|
731
|
+
|
|
732
|
+
The wait before attempt `n + 1` is
|
|
733
|
+
`min(delay * backoff ** (n - 1), max_delay) + random.uniform(0, jitter)`.
|
|
734
|
+
The last exception is re-raised unchanged, with its original traceback, once
|
|
735
|
+
attempts, the time budget or the retry budget are exhausted.
|
|
736
|
+
|
|
737
|
+
With `retry_if_result`, a returned value for which the predicate is true is treated
|
|
738
|
+
as a failure and retried. When no retry is left, that last value is **returned**
|
|
739
|
+
(no exception is raised), so check it if it matters.
|
|
740
|
+
|
|
741
|
+
For `async def` functions, `retry_if`, `retry_if_result` and `on_retry` may be
|
|
742
|
+
`async def` callables; they are awaited. A sync function given an async callable
|
|
743
|
+
raises `TypeError` when decorated.
|
|
744
|
+
|
|
745
|
+
`asyncio.CancelledError`, `KeyboardInterrupt`, `SystemExit` and `GeneratorExit` are
|
|
746
|
+
never retried, even with `on=BaseException` or when listed in `on`. Cancelling an
|
|
747
|
+
async call during the backoff sleep cancels it immediately.
|
|
748
|
+
|
|
749
|
+
Arguments you leave out fall back to `python_wrap.config.configure(retry={...})`
|
|
750
|
+
(read when the decorator is applied), then to the values shown here. Inside a
|
|
751
|
+
`python_wrap.deadline` block, it never sleeps past the time remaining: if the next
|
|
752
|
+
wait would outlast the deadline the last exception is re-raised instead.
|
|
753
|
+
|
|
754
|
+
Statistics are available through `retry_stats`. The events `retry.attempt_failed`,
|
|
755
|
+
`retry.succeeded`, `retry.gave_up` and `retry.budget_exhausted` are emitted for
|
|
756
|
+
`python_wrap.events`. Under `python_wrap.testing`, an `overrides.max_attempts` cap
|
|
757
|
+
lowers `max_attempts`.
|
|
758
|
+
|
|
759
|
+
Args:
|
|
760
|
+
func: The function to wrap (when used without parentheses).
|
|
761
|
+
max_attempts: Total number of calls allowed, or `None` for no limit.
|
|
762
|
+
delay: Base wait in seconds before the second attempt.
|
|
763
|
+
backoff: Multiplier applied to the wait after each failure (>= 1).
|
|
764
|
+
max_delay: Upper bound for the backoff wait (before jitter is added); also caps
|
|
765
|
+
waits returned by `delay_from`.
|
|
766
|
+
jitter: Up to this many random extra seconds are added to each wait.
|
|
767
|
+
max_time: Total time budget in seconds, measured from the first attempt. If the
|
|
768
|
+
next wait would run past it, the last exception is re-raised instead.
|
|
769
|
+
on: Exception type(s) that trigger a retry; anything else propagates at once.
|
|
770
|
+
retry_if: Extra predicate on the exception; it is retried only if this returns
|
|
771
|
+
True. May be async for async functions.
|
|
772
|
+
retry_if_result: Predicate on the return value; True means "retry". When no
|
|
773
|
+
retry is left the last value is returned. May be async for async functions.
|
|
774
|
+
on_retry: Called with a `RetryState` before each wait. May be async for async
|
|
775
|
+
functions.
|
|
776
|
+
delay_from: Called with the exception; a number is the exact wait for this
|
|
777
|
+
attempt (no backoff, no jitter; still capped by `max_delay` and `max_time`),
|
|
778
|
+
`None` means use the normal backoff. See `retry_after_header`.
|
|
779
|
+
budget: A `RetryBudget` shared with other retried code. When it refuses a retry
|
|
780
|
+
the last exception is re-raised (or the last result returned) at once.
|
|
781
|
+
logger: Logger (or logger name) for the per-retry message.
|
|
782
|
+
log_level: Level of the per-retry message, or `None` to not log.
|
|
783
|
+
log_exc_info: Attach the traceback to the per-retry log record.
|
|
784
|
+
|
|
785
|
+
Returns:
|
|
786
|
+
The wrapped function, or a decorator when `func` is omitted.
|
|
787
|
+
|
|
788
|
+
Raises:
|
|
789
|
+
ValueError: If an option is out of range (raised when decorating).
|
|
790
|
+
TypeError: If `budget` is not a `RetryBudget`, or a sync function is given an
|
|
791
|
+
async `retry_if`, `retry_if_result` or `on_retry` (raised when decorating).
|
|
792
|
+
|
|
793
|
+
Example:
|
|
794
|
+
>>> @retry(max_attempts=5, delay=0.5, backoff=2, on=ConnectionError)
|
|
795
|
+
... def fetch() -> str: ...
|
|
796
|
+
"""
|
|
797
|
+
ds = _resolve_defaults(
|
|
798
|
+
max_attempts=max_attempts,
|
|
799
|
+
delay=delay,
|
|
800
|
+
backoff=backoff,
|
|
801
|
+
max_delay=max_delay,
|
|
802
|
+
jitter=jitter,
|
|
803
|
+
max_time=max_time,
|
|
804
|
+
log_level=log_level,
|
|
805
|
+
budget=budget,
|
|
806
|
+
)
|
|
807
|
+
max_attempts, delay, backoff = ds["max_attempts"], ds["delay"], ds["backoff"]
|
|
808
|
+
max_delay, jitter, max_time, log_level = (
|
|
809
|
+
ds["max_delay"],
|
|
810
|
+
ds["jitter"],
|
|
811
|
+
ds["max_time"],
|
|
812
|
+
ds["log_level"],
|
|
813
|
+
)
|
|
814
|
+
budget = ds["budget"]
|
|
815
|
+
# Validate eagerly so a bad option fails at decoration time, even with no function yet.
|
|
816
|
+
_validate(max_attempts, delay, backoff, max_delay, jitter, max_time)
|
|
817
|
+
_check_budget(budget)
|
|
818
|
+
|
|
819
|
+
def decorate(f: Callable[P, R]) -> Callable[P, R]:
|
|
820
|
+
stats = RetryStats()
|
|
821
|
+
engine = _Engine(
|
|
822
|
+
_core.qualname(f),
|
|
823
|
+
max_attempts=max_attempts,
|
|
824
|
+
delay=delay,
|
|
825
|
+
backoff=backoff,
|
|
826
|
+
max_delay=max_delay,
|
|
827
|
+
jitter=jitter,
|
|
828
|
+
max_time=max_time,
|
|
829
|
+
on=on,
|
|
830
|
+
retry_if=retry_if,
|
|
831
|
+
retry_if_result=retry_if_result,
|
|
832
|
+
on_retry=on_retry,
|
|
833
|
+
delay_from=delay_from,
|
|
834
|
+
budget=budget,
|
|
835
|
+
logger=logger,
|
|
836
|
+
log_level=log_level,
|
|
837
|
+
log_exc_info=log_exc_info,
|
|
838
|
+
stats=stats,
|
|
839
|
+
)
|
|
840
|
+
check_result = retry_if_result is not None
|
|
841
|
+
|
|
842
|
+
if _core.is_async(f):
|
|
843
|
+
af = cast("Callable[..., Coroutine[Any, Any, Any]]", f)
|
|
844
|
+
|
|
845
|
+
@functools.wraps(af)
|
|
846
|
+
async def async_wrapper(*args: Any, **kwargs: Any) -> Any:
|
|
847
|
+
started = engine.start_call()
|
|
848
|
+
attempt = 1
|
|
849
|
+
while True:
|
|
850
|
+
engine.start_attempt()
|
|
851
|
+
try:
|
|
852
|
+
result = await af(*args, **kwargs)
|
|
853
|
+
except on as exc:
|
|
854
|
+
wait = await engine.afailure(attempt, exc, started)
|
|
855
|
+
if wait is None:
|
|
856
|
+
raise
|
|
857
|
+
else:
|
|
858
|
+
if not check_result or not await engine.arejects(result):
|
|
859
|
+
engine.success(attempt)
|
|
860
|
+
return result
|
|
861
|
+
wait = await engine.aresult_failure(attempt, result, started)
|
|
862
|
+
if wait is None:
|
|
863
|
+
return result
|
|
864
|
+
await _core.async_sleep(wait)
|
|
865
|
+
engine.slept(wait)
|
|
866
|
+
attempt += 1
|
|
867
|
+
|
|
868
|
+
setattr(async_wrapper, _STATS_ATTR, stats)
|
|
869
|
+
return cast("Callable[P, R]", async_wrapper)
|
|
870
|
+
|
|
871
|
+
engine.check_sync(*engine.async_hooks)
|
|
872
|
+
|
|
873
|
+
@functools.wraps(f)
|
|
874
|
+
def wrapper(*args: P.args, **kwargs: P.kwargs) -> R:
|
|
875
|
+
started = engine.start_call()
|
|
876
|
+
attempt = 1
|
|
877
|
+
while True:
|
|
878
|
+
engine.start_attempt()
|
|
879
|
+
try:
|
|
880
|
+
result = f(*args, **kwargs)
|
|
881
|
+
except on as exc:
|
|
882
|
+
wait = engine.failure(attempt, exc, started)
|
|
883
|
+
if wait is None:
|
|
884
|
+
raise
|
|
885
|
+
else:
|
|
886
|
+
if not check_result or not engine.rejects(result):
|
|
887
|
+
engine.success(attempt)
|
|
888
|
+
return result
|
|
889
|
+
wait = engine.result_failure(attempt, result, started)
|
|
890
|
+
if wait is None:
|
|
891
|
+
return result
|
|
892
|
+
_core.sleep(wait)
|
|
893
|
+
engine.slept(wait)
|
|
894
|
+
attempt += 1
|
|
895
|
+
|
|
896
|
+
setattr(wrapper, _STATS_ATTR, stats)
|
|
897
|
+
return wrapper
|
|
898
|
+
|
|
899
|
+
return decorate if func is None else decorate(func)
|
|
900
|
+
|
|
901
|
+
|
|
902
|
+
class _Run:
|
|
903
|
+
def __init__(self, engine: _Engine) -> None:
|
|
904
|
+
self.engine = engine
|
|
905
|
+
self.started = engine.start_call()
|
|
906
|
+
|
|
907
|
+
|
|
908
|
+
class Attempt:
|
|
909
|
+
"""One attempt of a `retrying` loop; use it as a context manager.
|
|
910
|
+
|
|
911
|
+
Exceptions that `retry` would retry are swallowed by `with attempt:` (the loop then
|
|
912
|
+
waits and continues); other exceptions, and the failure of the final attempt,
|
|
913
|
+
propagate. In an `async for` loop, use `async with attempt:` when `retry_if`,
|
|
914
|
+
`retry_if_result` or `on_retry` are async callables (plain `with` works otherwise).
|
|
915
|
+
|
|
916
|
+
With `retry_if_result`, store the value the block produced in `attempt.result`; the
|
|
917
|
+
predicate is checked when the block ends and a rejected value starts another
|
|
918
|
+
attempt. When no retry is left the loop simply ends, leaving the last value in
|
|
919
|
+
your variable (and in `attempt.result`).
|
|
920
|
+
|
|
921
|
+
Attributes:
|
|
922
|
+
number: 1-based number of this attempt.
|
|
923
|
+
"""
|
|
924
|
+
|
|
925
|
+
def __init__(self, number: int, run: _Run) -> None:
|
|
926
|
+
self.number = number
|
|
927
|
+
self._run = run
|
|
928
|
+
self._wait: float | None = None
|
|
929
|
+
self._gave_up = False
|
|
930
|
+
self._result: Any = _MISSING
|
|
931
|
+
|
|
932
|
+
@property
|
|
933
|
+
def result(self) -> Any:
|
|
934
|
+
"""The value checked by `retry_if_result` (`None` until you set it)."""
|
|
935
|
+
return None if self._result is _MISSING else self._result
|
|
936
|
+
|
|
937
|
+
@result.setter
|
|
938
|
+
def result(self, value: Any) -> None:
|
|
939
|
+
self._result = value
|
|
940
|
+
|
|
941
|
+
def __enter__(self) -> Attempt:
|
|
942
|
+
self._run.engine.start_attempt()
|
|
943
|
+
return self
|
|
944
|
+
|
|
945
|
+
def __exit__(
|
|
946
|
+
self,
|
|
947
|
+
exc_type: type[BaseException] | None,
|
|
948
|
+
exc: BaseException | None,
|
|
949
|
+
tb: TracebackType | None,
|
|
950
|
+
) -> bool:
|
|
951
|
+
engine = self._run.engine
|
|
952
|
+
if exc is None:
|
|
953
|
+
if self._result is not _MISSING and engine.rejects(self._result):
|
|
954
|
+
self._wait = engine.result_failure(self.number, self._result, self._run.started)
|
|
955
|
+
self._gave_up = self._wait is None
|
|
956
|
+
return False
|
|
957
|
+
if not isinstance(exc, engine.on):
|
|
958
|
+
return False
|
|
959
|
+
self._wait = engine.failure(self.number, exc, self._run.started)
|
|
960
|
+
return self._wait is not None
|
|
961
|
+
|
|
962
|
+
async def __aenter__(self) -> Attempt:
|
|
963
|
+
self._run.engine.start_attempt()
|
|
964
|
+
return self
|
|
965
|
+
|
|
966
|
+
async def __aexit__(
|
|
967
|
+
self,
|
|
968
|
+
exc_type: type[BaseException] | None,
|
|
969
|
+
exc: BaseException | None,
|
|
970
|
+
tb: TracebackType | None,
|
|
971
|
+
) -> bool:
|
|
972
|
+
engine = self._run.engine
|
|
973
|
+
if exc is None:
|
|
974
|
+
if self._result is not _MISSING and await engine.arejects(self._result):
|
|
975
|
+
started = self._run.started
|
|
976
|
+
self._wait = await engine.aresult_failure(self.number, self._result, started)
|
|
977
|
+
self._gave_up = self._wait is None
|
|
978
|
+
return False
|
|
979
|
+
if not isinstance(exc, engine.on):
|
|
980
|
+
return False
|
|
981
|
+
self._wait = await engine.afailure(self.number, exc, self._run.started)
|
|
982
|
+
return self._wait is not None
|
|
983
|
+
|
|
984
|
+
|
|
985
|
+
class Retrying:
|
|
986
|
+
"""Iterable of `Attempt` objects returned by `retrying`.
|
|
987
|
+
|
|
988
|
+
Iterate it with `for` (sync, sleeps with `time.sleep`) or `async for` (sleeps with
|
|
989
|
+
`asyncio`). Each iteration over a `Retrying` is an independent run.
|
|
990
|
+
"""
|
|
991
|
+
|
|
992
|
+
def __init__(self, engine: _Engine) -> None:
|
|
993
|
+
self._engine = engine
|
|
994
|
+
|
|
995
|
+
@property
|
|
996
|
+
def stats(self) -> RetryStats:
|
|
997
|
+
"""Counters accumulated over all runs of this object."""
|
|
998
|
+
return self._engine.stats
|
|
999
|
+
|
|
1000
|
+
def __iter__(self) -> Iterator[Attempt]:
|
|
1001
|
+
run = _Run(self._engine)
|
|
1002
|
+
number = 1
|
|
1003
|
+
while True:
|
|
1004
|
+
attempt = Attempt(number, run)
|
|
1005
|
+
yield attempt
|
|
1006
|
+
wait = attempt._wait
|
|
1007
|
+
if wait is None:
|
|
1008
|
+
if not attempt._gave_up:
|
|
1009
|
+
run.engine.success(number)
|
|
1010
|
+
return
|
|
1011
|
+
_core.sleep(wait)
|
|
1012
|
+
run.engine.slept(wait)
|
|
1013
|
+
number += 1
|
|
1014
|
+
|
|
1015
|
+
async def __aiter__(self) -> AsyncIterator[Attempt]:
|
|
1016
|
+
run = _Run(self._engine)
|
|
1017
|
+
number = 1
|
|
1018
|
+
while True:
|
|
1019
|
+
attempt = Attempt(number, run)
|
|
1020
|
+
yield attempt
|
|
1021
|
+
wait = attempt._wait
|
|
1022
|
+
if wait is None:
|
|
1023
|
+
if not attempt._gave_up:
|
|
1024
|
+
run.engine.success(number)
|
|
1025
|
+
return
|
|
1026
|
+
await _core.async_sleep(wait)
|
|
1027
|
+
run.engine.slept(wait)
|
|
1028
|
+
number += 1
|
|
1029
|
+
|
|
1030
|
+
|
|
1031
|
+
def retrying(
|
|
1032
|
+
*,
|
|
1033
|
+
max_attempts: int | None = _UNSET,
|
|
1034
|
+
delay: float = _UNSET,
|
|
1035
|
+
backoff: float = _UNSET,
|
|
1036
|
+
max_delay: float | None = _UNSET,
|
|
1037
|
+
jitter: float = _UNSET,
|
|
1038
|
+
max_time: float | None = _UNSET,
|
|
1039
|
+
on: ExceptionTypes = Exception,
|
|
1040
|
+
retry_if: ExceptionPredicate | None = None,
|
|
1041
|
+
retry_if_result: ResultPredicate | None = None,
|
|
1042
|
+
on_retry: RetryCallback | None = None,
|
|
1043
|
+
delay_from: Callable[[BaseException], float | None] | None = None,
|
|
1044
|
+
budget: RetryBudget | None = _UNSET,
|
|
1045
|
+
logger: LoggerLike = None,
|
|
1046
|
+
log_level: int | None = _UNSET,
|
|
1047
|
+
log_exc_info: bool = False,
|
|
1048
|
+
name: str = "retrying",
|
|
1049
|
+
) -> Retrying:
|
|
1050
|
+
"""Retry an inline block of code instead of a whole function.
|
|
1051
|
+
|
|
1052
|
+
Takes the same options as `retry` and behaves identically (backoff, jitter,
|
|
1053
|
+
`max_time`, `delay_from`, `budget`, cancellation rules, events, test overrides). Put
|
|
1054
|
+
the risky code in `with attempt:`: a retryable exception is swallowed and the loop
|
|
1055
|
+
waits and continues; success ends the loop; the final failure propagates.
|
|
1056
|
+
|
|
1057
|
+
Use `for` in sync code and `async for` in async code. Do not use `for` inside a
|
|
1058
|
+
coroutine, it would block the event loop while waiting. Async `retry_if`,
|
|
1059
|
+
`retry_if_result` or `on_retry` callables need `async for` and `async with attempt:`;
|
|
1060
|
+
with plain `with attempt:` they raise `TypeError`.
|
|
1061
|
+
|
|
1062
|
+
For `retry_if_result`, assign the block's value to `attempt.result`; a rejected
|
|
1063
|
+
value is retried, and when no retry is left the loop ends normally with the last
|
|
1064
|
+
value (like `retry`, nothing is raised).
|
|
1065
|
+
|
|
1066
|
+
Args:
|
|
1067
|
+
max_attempts: Total attempts allowed, or `None` for no limit.
|
|
1068
|
+
delay: Base wait in seconds before the second attempt.
|
|
1069
|
+
backoff: Multiplier applied to the wait after each failure (>= 1).
|
|
1070
|
+
max_delay: Upper bound for the backoff wait (before jitter).
|
|
1071
|
+
jitter: Up to this many random extra seconds are added to each wait.
|
|
1072
|
+
max_time: Total time budget in seconds from the first attempt.
|
|
1073
|
+
on: Exception type(s) that trigger a retry.
|
|
1074
|
+
retry_if: Extra predicate on the exception; retry only if it returns True.
|
|
1075
|
+
retry_if_result: Predicate on `attempt.result`; True means "retry".
|
|
1076
|
+
on_retry: Called with a `RetryState` before each wait.
|
|
1077
|
+
delay_from: Exact wait derived from the exception, or `None` for backoff.
|
|
1078
|
+
budget: A shared `RetryBudget`; when it refuses, the loop stops retrying.
|
|
1079
|
+
logger: Logger (or logger name) for the per-retry message.
|
|
1080
|
+
log_level: Level of the per-retry message, or `None` to not log.
|
|
1081
|
+
log_exc_info: Attach the traceback to the per-retry log record.
|
|
1082
|
+
name: Name used in log messages and events.
|
|
1083
|
+
|
|
1084
|
+
Returns:
|
|
1085
|
+
A `Retrying` object to iterate over.
|
|
1086
|
+
|
|
1087
|
+
Raises:
|
|
1088
|
+
ValueError: If an option is out of range.
|
|
1089
|
+
TypeError: If `budget` is not a `RetryBudget`.
|
|
1090
|
+
|
|
1091
|
+
Example:
|
|
1092
|
+
>>> for attempt in retrying(max_attempts=3, delay=0.1, on=ConnectionError):
|
|
1093
|
+
... with attempt:
|
|
1094
|
+
... do_work() # doctest: +SKIP
|
|
1095
|
+
"""
|
|
1096
|
+
ds = _resolve_defaults(
|
|
1097
|
+
max_attempts=max_attempts,
|
|
1098
|
+
delay=delay,
|
|
1099
|
+
backoff=backoff,
|
|
1100
|
+
max_delay=max_delay,
|
|
1101
|
+
jitter=jitter,
|
|
1102
|
+
max_time=max_time,
|
|
1103
|
+
log_level=log_level,
|
|
1104
|
+
budget=budget,
|
|
1105
|
+
)
|
|
1106
|
+
max_attempts, delay, backoff = ds["max_attempts"], ds["delay"], ds["backoff"]
|
|
1107
|
+
max_delay, jitter, max_time, log_level = (
|
|
1108
|
+
ds["max_delay"],
|
|
1109
|
+
ds["jitter"],
|
|
1110
|
+
ds["max_time"],
|
|
1111
|
+
ds["log_level"],
|
|
1112
|
+
)
|
|
1113
|
+
return Retrying(
|
|
1114
|
+
_Engine(
|
|
1115
|
+
name,
|
|
1116
|
+
max_attempts=max_attempts,
|
|
1117
|
+
delay=delay,
|
|
1118
|
+
backoff=backoff,
|
|
1119
|
+
max_delay=max_delay,
|
|
1120
|
+
jitter=jitter,
|
|
1121
|
+
max_time=max_time,
|
|
1122
|
+
on=on,
|
|
1123
|
+
retry_if=retry_if,
|
|
1124
|
+
retry_if_result=retry_if_result,
|
|
1125
|
+
on_retry=on_retry,
|
|
1126
|
+
delay_from=delay_from,
|
|
1127
|
+
budget=ds["budget"],
|
|
1128
|
+
logger=logger,
|
|
1129
|
+
log_level=log_level,
|
|
1130
|
+
log_exc_info=log_exc_info,
|
|
1131
|
+
stats=RetryStats(),
|
|
1132
|
+
)
|
|
1133
|
+
)
|
|
1134
|
+
|
|
1135
|
+
|
|
1136
|
+
def retry_on_exception(
|
|
1137
|
+
*exceptions: type[BaseException],
|
|
1138
|
+
max_attempts: int | None = 3,
|
|
1139
|
+
delay: float = 0.0,
|
|
1140
|
+
backoff: float = 1.0,
|
|
1141
|
+
max_delay: float | None = None,
|
|
1142
|
+
jitter: float = 0.0,
|
|
1143
|
+
retry_if: Callable[[BaseException], bool] | None = None,
|
|
1144
|
+
on_retry: Callable[[RetryState], None] | None = None,
|
|
1145
|
+
logger: LoggerLike = None,
|
|
1146
|
+
) -> Callable[[F], F]:
|
|
1147
|
+
"""Deprecated alias for ``retry(on=exceptions or Exception, ...)``.
|
|
1148
|
+
|
|
1149
|
+
Args:
|
|
1150
|
+
*exceptions: Exception types to retry on (default ``Exception``).
|
|
1151
|
+
max_attempts: Total calls allowed, or ``None`` for no limit.
|
|
1152
|
+
delay: Base wait in seconds between attempts.
|
|
1153
|
+
backoff: Multiplier applied to the wait after each failure.
|
|
1154
|
+
max_delay: Upper bound for the wait.
|
|
1155
|
+
jitter: Random extra seconds added to each wait.
|
|
1156
|
+
retry_if: Extra predicate deciding whether to retry.
|
|
1157
|
+
on_retry: Callback invoked before each wait.
|
|
1158
|
+
logger: Logger (or name) for retry messages.
|
|
1159
|
+
|
|
1160
|
+
Returns:
|
|
1161
|
+
A decorator. Applying it emits a ``DeprecationWarning``.
|
|
1162
|
+
|
|
1163
|
+
Raises:
|
|
1164
|
+
ValueError: If an option is out of range.
|
|
1165
|
+
|
|
1166
|
+
Example:
|
|
1167
|
+
>>> @retry_on_exception(ValueError) # use retry(on=ValueError) instead
|
|
1168
|
+
... def parse() -> None: ...
|
|
1169
|
+
"""
|
|
1170
|
+
on: ExceptionTypes = exceptions or Exception
|
|
1171
|
+
_validate(max_attempts, delay, backoff, max_delay, jitter)
|
|
1172
|
+
|
|
1173
|
+
def decorate(f: Callable[P, R]) -> Callable[P, R]:
|
|
1174
|
+
warnings.warn(
|
|
1175
|
+
"retry_on_exception is deprecated; use retry(on=...) instead",
|
|
1176
|
+
DeprecationWarning,
|
|
1177
|
+
stacklevel=2,
|
|
1178
|
+
)
|
|
1179
|
+
return retry(
|
|
1180
|
+
f,
|
|
1181
|
+
max_attempts=max_attempts,
|
|
1182
|
+
delay=delay,
|
|
1183
|
+
backoff=backoff,
|
|
1184
|
+
max_delay=max_delay,
|
|
1185
|
+
jitter=jitter,
|
|
1186
|
+
on=on,
|
|
1187
|
+
retry_if=retry_if,
|
|
1188
|
+
on_retry=on_retry,
|
|
1189
|
+
logger=logger,
|
|
1190
|
+
)
|
|
1191
|
+
|
|
1192
|
+
return cast("Callable[[F], F]", decorate)
|
|
1193
|
+
|
|
1194
|
+
|
|
1195
|
+
class _Decorator(Protocol[T_co]):
|
|
1196
|
+
"""A decorator that maps ``f`` to ``f`` with ``T_co`` added to the result type."""
|
|
1197
|
+
|
|
1198
|
+
@overload
|
|
1199
|
+
def __call__(
|
|
1200
|
+
self, func: Callable[P, Coroutine[Any, Any, R]], /
|
|
1201
|
+
) -> Callable[P, Coroutine[Any, Any, R | T_co]]: ...
|
|
1202
|
+
@overload
|
|
1203
|
+
def __call__(self, func: Callable[P, R], /) -> Callable[P, R | T_co]: ...
|
|
1204
|
+
|
|
1205
|
+
|
|
1206
|
+
def _make_catcher(
|
|
1207
|
+
on: ExceptionTypes,
|
|
1208
|
+
on_error: Callable[[Callable[..., Any], BaseException], Any],
|
|
1209
|
+
) -> Callable[[Callable[..., Any]], Callable[..., Any]]:
|
|
1210
|
+
def decorate(f: Callable[..., Any]) -> Callable[..., Any]:
|
|
1211
|
+
if _core.is_async(f):
|
|
1212
|
+
|
|
1213
|
+
@functools.wraps(f)
|
|
1214
|
+
async def async_wrapper(*args: Any, **kwargs: Any) -> Any:
|
|
1215
|
+
try:
|
|
1216
|
+
return await f(*args, **kwargs)
|
|
1217
|
+
except on as exc:
|
|
1218
|
+
return on_error(f, exc)
|
|
1219
|
+
|
|
1220
|
+
return async_wrapper
|
|
1221
|
+
|
|
1222
|
+
@functools.wraps(f)
|
|
1223
|
+
def wrapper(*args: Any, **kwargs: Any) -> Any:
|
|
1224
|
+
try:
|
|
1225
|
+
return f(*args, **kwargs)
|
|
1226
|
+
except on as exc:
|
|
1227
|
+
return on_error(f, exc)
|
|
1228
|
+
|
|
1229
|
+
return wrapper
|
|
1230
|
+
|
|
1231
|
+
return decorate
|
|
1232
|
+
|
|
1233
|
+
|
|
1234
|
+
@overload
|
|
1235
|
+
def fallback(
|
|
1236
|
+
value: T, /, *, on: ExceptionTypes = ..., logger: LoggerLike = ...
|
|
1237
|
+
) -> _Decorator[T]: ...
|
|
1238
|
+
@overload
|
|
1239
|
+
def fallback(
|
|
1240
|
+
*, handler: Callable[[BaseException], T], on: ExceptionTypes = ..., logger: LoggerLike = ...
|
|
1241
|
+
) -> _Decorator[T]: ...
|
|
1242
|
+
def fallback(
|
|
1243
|
+
value: Any = _MISSING,
|
|
1244
|
+
/,
|
|
1245
|
+
*,
|
|
1246
|
+
handler: Callable[[BaseException], Any] | None = None,
|
|
1247
|
+
on: ExceptionTypes = Exception,
|
|
1248
|
+
logger: LoggerLike = None,
|
|
1249
|
+
) -> Any:
|
|
1250
|
+
"""Return a default value (or a handler's result) when the function raises.
|
|
1251
|
+
|
|
1252
|
+
Args:
|
|
1253
|
+
value: The value to return on a matching exception.
|
|
1254
|
+
handler: Alternatively, a callable ``handler(exc)`` whose result is returned.
|
|
1255
|
+
on: Exception type(s) that trigger the fallback.
|
|
1256
|
+
logger: Logger (or name) for the WARNING message (logged with traceback).
|
|
1257
|
+
|
|
1258
|
+
Returns:
|
|
1259
|
+
A decorator for sync or async functions. For async functions the handler is
|
|
1260
|
+
still a regular (sync) function.
|
|
1261
|
+
|
|
1262
|
+
Raises:
|
|
1263
|
+
ValueError: If neither or both of ``value`` and ``handler`` are given.
|
|
1264
|
+
|
|
1265
|
+
Example:
|
|
1266
|
+
>>> @fallback(0, on=KeyError)
|
|
1267
|
+
... def lookup(key: str) -> int: ...
|
|
1268
|
+
"""
|
|
1269
|
+
if (value is _MISSING) == (handler is None):
|
|
1270
|
+
raise ValueError("fallback requires exactly one of `value` or `handler`")
|
|
1271
|
+
log = _core.get_logger(logger)
|
|
1272
|
+
|
|
1273
|
+
def on_error(f: Callable[..., Any], exc: BaseException) -> Any:
|
|
1274
|
+
log.warning(
|
|
1275
|
+
"%s raised %r; using fallback",
|
|
1276
|
+
_core.qualname(f),
|
|
1277
|
+
exc,
|
|
1278
|
+
exc_info=(type(exc), exc, exc.__traceback__),
|
|
1279
|
+
)
|
|
1280
|
+
return handler(exc) if handler is not None else value
|
|
1281
|
+
|
|
1282
|
+
return _make_catcher(on, on_error)
|
|
1283
|
+
|
|
1284
|
+
|
|
1285
|
+
@overload
|
|
1286
|
+
def suppress( # type: ignore[overload-overlap]
|
|
1287
|
+
exc: type[BaseException], /, *more: type[BaseException], logger: LoggerLike = ...
|
|
1288
|
+
) -> _Decorator[None]: ...
|
|
1289
|
+
@overload
|
|
1290
|
+
def suppress(
|
|
1291
|
+
func: Callable[P, Coroutine[Any, Any, R]], /
|
|
1292
|
+
) -> Callable[P, Coroutine[Any, Any, R | None]]: ...
|
|
1293
|
+
@overload
|
|
1294
|
+
def suppress(func: Callable[P, R], /) -> Callable[P, R | None]: ...
|
|
1295
|
+
@overload
|
|
1296
|
+
def suppress(*, logger: LoggerLike = ...) -> _Decorator[None]: ...
|
|
1297
|
+
def suppress(*exceptions: Any, logger: LoggerLike = None) -> Any:
|
|
1298
|
+
"""Swallow matching exceptions; the call then returns ``None``.
|
|
1299
|
+
|
|
1300
|
+
Usable bare (``@suppress``, which suppresses ``Exception``) or with exception
|
|
1301
|
+
types (``@suppress(KeyError)``). Suppressed errors are logged at DEBUG.
|
|
1302
|
+
|
|
1303
|
+
Args:
|
|
1304
|
+
*exceptions: Exception types to swallow (default ``Exception``).
|
|
1305
|
+
logger: Logger (or name) for the DEBUG message.
|
|
1306
|
+
|
|
1307
|
+
Returns:
|
|
1308
|
+
The wrapped function, or a decorator when exception types are given.
|
|
1309
|
+
|
|
1310
|
+
Example:
|
|
1311
|
+
>>> @suppress(KeyError)
|
|
1312
|
+
... def first(d: dict[str, int]) -> int: ...
|
|
1313
|
+
"""
|
|
1314
|
+
log = _core.get_logger(logger)
|
|
1315
|
+
|
|
1316
|
+
def make(on: ExceptionTypes) -> Callable[[Callable[..., Any]], Callable[..., Any]]:
|
|
1317
|
+
def on_error(f: Callable[..., Any], exc: BaseException) -> None:
|
|
1318
|
+
log.debug("%s raised %r; suppressed", _core.qualname(f), exc)
|
|
1319
|
+
|
|
1320
|
+
return _make_catcher(on, on_error)
|
|
1321
|
+
|
|
1322
|
+
if len(exceptions) == 1 and not isinstance(exceptions[0], type) and callable(exceptions[0]):
|
|
1323
|
+
return make(Exception)(exceptions[0])
|
|
1324
|
+
return make(tuple(exceptions) or Exception)
|