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/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)