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/circuit.py
ADDED
|
@@ -0,0 +1,553 @@
|
|
|
1
|
+
"""Circuit breaker decorator and class."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import enum
|
|
6
|
+
import functools
|
|
7
|
+
import threading
|
|
8
|
+
from collections.abc import Callable, Coroutine
|
|
9
|
+
from dataclasses import dataclass
|
|
10
|
+
from typing import Any, Concatenate, NoReturn, ParamSpec, Protocol, TypeVar, cast, overload
|
|
11
|
+
|
|
12
|
+
from python_wrap import _core
|
|
13
|
+
from python_wrap._core import F, LoggerLike, P, R
|
|
14
|
+
from python_wrap.errors import CircuitOpenError
|
|
15
|
+
|
|
16
|
+
__all__ = [
|
|
17
|
+
"CircuitBreaker",
|
|
18
|
+
"CircuitProtected",
|
|
19
|
+
"CircuitState",
|
|
20
|
+
"CircuitStats",
|
|
21
|
+
"StateChangeListener",
|
|
22
|
+
"circuit_breaker",
|
|
23
|
+
]
|
|
24
|
+
|
|
25
|
+
R_co = TypeVar("R_co", covariant=True)
|
|
26
|
+
P2 = ParamSpec("P2")
|
|
27
|
+
T = TypeVar("T")
|
|
28
|
+
|
|
29
|
+
ExceptionTypes = type[BaseException] | tuple[type[BaseException], ...]
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class CircuitState(str, enum.Enum):
|
|
33
|
+
"""The three states of a circuit breaker."""
|
|
34
|
+
|
|
35
|
+
CLOSED = "closed"
|
|
36
|
+
OPEN = "open"
|
|
37
|
+
HALF_OPEN = "half_open"
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
StateChangeListener = Callable[["CircuitBreaker", CircuitState, CircuitState], None]
|
|
41
|
+
"""Signature of ``on_state_change``: ``listener(breaker, old_state, new_state)``."""
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
@dataclass(frozen=True)
|
|
45
|
+
class CircuitStats:
|
|
46
|
+
"""A point-in-time snapshot of a `CircuitBreaker`, from its ``.stats`` property.
|
|
47
|
+
|
|
48
|
+
Attributes:
|
|
49
|
+
name: The breaker's name.
|
|
50
|
+
state: Current state.
|
|
51
|
+
failures: Calls that failed with a counted exception (since creation or the last
|
|
52
|
+
``reset()``).
|
|
53
|
+
successes: Calls that returned normally (same period).
|
|
54
|
+
rejected: Calls refused with `CircuitOpenError` (same period).
|
|
55
|
+
consecutive_failures: Counted failures since the last success.
|
|
56
|
+
opened_at: ``_core.monotonic()`` time at which the circuit last opened, or
|
|
57
|
+
``None`` while it is closed.
|
|
58
|
+
"""
|
|
59
|
+
|
|
60
|
+
name: str
|
|
61
|
+
state: CircuitState
|
|
62
|
+
failures: int
|
|
63
|
+
successes: int
|
|
64
|
+
rejected: int
|
|
65
|
+
consecutive_failures: int
|
|
66
|
+
opened_at: float | None
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
class CircuitBreaker:
|
|
70
|
+
"""Stop calling a failing dependency until it has had time to recover.
|
|
71
|
+
|
|
72
|
+
The breaker is closed at first. After ``failure_threshold`` consecutive matching
|
|
73
|
+
failures it opens and rejects calls with `CircuitOpenError`.
|
|
74
|
+
After ``recovery_timeout`` seconds it goes half-open and lets up to
|
|
75
|
+
``half_open_max_calls`` trial calls run at once: ``success_threshold`` consecutive
|
|
76
|
+
successful trials close it, any counted failure re-opens it. One instance can
|
|
77
|
+
decorate several functions to share state.
|
|
78
|
+
|
|
79
|
+
Exceptions matching ``on`` are counted as failures, unless they also match
|
|
80
|
+
``exclude``. Excluded and non-matching exceptions propagate to the caller without
|
|
81
|
+
affecting the breaker (they count neither as a failure nor as a success).
|
|
82
|
+
|
|
83
|
+
Each transition calls ``on_state_change(breaker, old, new)`` (outside the breaker's
|
|
84
|
+
lock; a failing listener is logged, never raised) and emits the
|
|
85
|
+
``circuit.state_changed`` event. Every rejected call emits ``circuit.rejected``.
|
|
86
|
+
|
|
87
|
+
Args:
|
|
88
|
+
failure_threshold: Consecutive failures that open the circuit (>= 1).
|
|
89
|
+
recovery_timeout: Seconds to stay open before allowing trial calls.
|
|
90
|
+
half_open_max_calls: Concurrent trial calls allowed while half-open (>= 1).
|
|
91
|
+
success_threshold: Consecutive successful trial calls needed to close a
|
|
92
|
+
half-open circuit (>= 1).
|
|
93
|
+
on: Exception type(s) that count as failures; others pass through uncounted.
|
|
94
|
+
exclude: Exception type(s) never counted, even when they match ``on``, e.g.
|
|
95
|
+
``exclude=NotFound`` with ``on=Exception``.
|
|
96
|
+
on_state_change: Listener called with ``(breaker, old_state, new_state)``.
|
|
97
|
+
name: Name used in log messages, errors and events.
|
|
98
|
+
logger: Logger (or name) for state transitions.
|
|
99
|
+
|
|
100
|
+
Raises:
|
|
101
|
+
ValueError: If a numeric option is out of range.
|
|
102
|
+
|
|
103
|
+
Example:
|
|
104
|
+
```python
|
|
105
|
+
from python_wrap.circuit import CircuitBreaker, CircuitState
|
|
106
|
+
|
|
107
|
+
changes = []
|
|
108
|
+
breaker = CircuitBreaker(
|
|
109
|
+
failure_threshold=2,
|
|
110
|
+
exclude=KeyError,
|
|
111
|
+
on_state_change=lambda b, old, new: changes.append((old.value, new.value)),
|
|
112
|
+
name="payments",
|
|
113
|
+
)
|
|
114
|
+
|
|
115
|
+
@breaker
|
|
116
|
+
def charge(fail: bool) -> str:
|
|
117
|
+
if fail:
|
|
118
|
+
raise ConnectionError("down")
|
|
119
|
+
return "ok"
|
|
120
|
+
|
|
121
|
+
for _ in range(2):
|
|
122
|
+
try:
|
|
123
|
+
charge(True)
|
|
124
|
+
except ConnectionError:
|
|
125
|
+
pass
|
|
126
|
+
assert breaker.state is CircuitState.OPEN
|
|
127
|
+
assert changes == [("closed", "open")]
|
|
128
|
+
assert breaker.stats.failures == 2
|
|
129
|
+
```
|
|
130
|
+
"""
|
|
131
|
+
|
|
132
|
+
def __reduce__(self) -> NoReturn:
|
|
133
|
+
_core.refuse_pickle(self)
|
|
134
|
+
|
|
135
|
+
def __init__(
|
|
136
|
+
self,
|
|
137
|
+
*,
|
|
138
|
+
failure_threshold: int = 5,
|
|
139
|
+
recovery_timeout: float = 30.0,
|
|
140
|
+
half_open_max_calls: int = 1,
|
|
141
|
+
success_threshold: int = 1,
|
|
142
|
+
on: ExceptionTypes = Exception,
|
|
143
|
+
exclude: ExceptionTypes = (),
|
|
144
|
+
on_state_change: StateChangeListener | None = None,
|
|
145
|
+
name: str | None = None,
|
|
146
|
+
logger: LoggerLike = None,
|
|
147
|
+
) -> None:
|
|
148
|
+
if failure_threshold < 1:
|
|
149
|
+
raise ValueError(f"failure_threshold must be >= 1, got {failure_threshold!r}")
|
|
150
|
+
if recovery_timeout < 0:
|
|
151
|
+
raise ValueError(f"recovery_timeout must be >= 0, got {recovery_timeout!r}")
|
|
152
|
+
if half_open_max_calls < 1:
|
|
153
|
+
raise ValueError(f"half_open_max_calls must be >= 1, got {half_open_max_calls!r}")
|
|
154
|
+
if success_threshold < 1:
|
|
155
|
+
raise ValueError(f"success_threshold must be >= 1, got {success_threshold!r}")
|
|
156
|
+
self.failure_threshold = failure_threshold
|
|
157
|
+
self.recovery_timeout = recovery_timeout
|
|
158
|
+
self.half_open_max_calls = half_open_max_calls
|
|
159
|
+
self.success_threshold = success_threshold
|
|
160
|
+
self.on = on
|
|
161
|
+
self.exclude = exclude
|
|
162
|
+
self.on_state_change = on_state_change
|
|
163
|
+
self.name = name or "circuit"
|
|
164
|
+
self._log = _core.get_logger(logger)
|
|
165
|
+
self._lock = threading.Lock()
|
|
166
|
+
self._state = CircuitState.CLOSED
|
|
167
|
+
self._failures = 0
|
|
168
|
+
self._opened_at = 0.0
|
|
169
|
+
self._trials = 0
|
|
170
|
+
self._trial_successes = 0
|
|
171
|
+
self._generation = 0
|
|
172
|
+
self._total_failures = 0
|
|
173
|
+
self._total_successes = 0
|
|
174
|
+
self._total_rejected = 0
|
|
175
|
+
# Transitions made under the lock, delivered to listeners after releasing it.
|
|
176
|
+
self._pending: list[tuple[CircuitState, CircuitState]] = []
|
|
177
|
+
|
|
178
|
+
# -- introspection -------------------------------------------------------------
|
|
179
|
+
@property
|
|
180
|
+
def state(self) -> CircuitState:
|
|
181
|
+
"""Current state (an open circuit past its timeout reports ``half_open``)."""
|
|
182
|
+
with self._lock:
|
|
183
|
+
self._maybe_half_open()
|
|
184
|
+
state = self._state
|
|
185
|
+
self._notify()
|
|
186
|
+
return state
|
|
187
|
+
|
|
188
|
+
@property
|
|
189
|
+
def failure_count(self) -> int:
|
|
190
|
+
"""Consecutive failures counted so far."""
|
|
191
|
+
with self._lock:
|
|
192
|
+
return self._failures
|
|
193
|
+
|
|
194
|
+
@property
|
|
195
|
+
def stats(self) -> CircuitStats:
|
|
196
|
+
"""A consistent `CircuitStats` snapshot of the counters and state."""
|
|
197
|
+
with self._lock:
|
|
198
|
+
self._maybe_half_open()
|
|
199
|
+
snapshot = CircuitStats(
|
|
200
|
+
name=self.name,
|
|
201
|
+
state=self._state,
|
|
202
|
+
failures=self._total_failures,
|
|
203
|
+
successes=self._total_successes,
|
|
204
|
+
rejected=self._total_rejected,
|
|
205
|
+
consecutive_failures=self._failures,
|
|
206
|
+
opened_at=None if self._state is CircuitState.CLOSED else self._opened_at,
|
|
207
|
+
)
|
|
208
|
+
self._notify()
|
|
209
|
+
return snapshot
|
|
210
|
+
|
|
211
|
+
def reset(self) -> None:
|
|
212
|
+
"""Force the breaker back to closed and zero every counter."""
|
|
213
|
+
with self._lock:
|
|
214
|
+
if self._state is not CircuitState.CLOSED:
|
|
215
|
+
self._log.info("circuit %s reset to closed", self.name)
|
|
216
|
+
self._set_state(CircuitState.CLOSED)
|
|
217
|
+
self._failures = 0
|
|
218
|
+
self._trials = 0
|
|
219
|
+
self._trial_successes = 0
|
|
220
|
+
self._total_failures = 0
|
|
221
|
+
self._total_successes = 0
|
|
222
|
+
self._total_rejected = 0
|
|
223
|
+
self._generation += 1
|
|
224
|
+
self._notify()
|
|
225
|
+
|
|
226
|
+
# -- internals (call with the lock held) ---------------------------------------
|
|
227
|
+
def _set_state(self, new: CircuitState) -> None:
|
|
228
|
+
old = self._state
|
|
229
|
+
self._state = new
|
|
230
|
+
if old is not new:
|
|
231
|
+
self._pending.append((old, new))
|
|
232
|
+
|
|
233
|
+
def _maybe_half_open(self) -> None:
|
|
234
|
+
if (
|
|
235
|
+
self._state is CircuitState.OPEN
|
|
236
|
+
and _core.monotonic() - self._opened_at >= self.recovery_timeout
|
|
237
|
+
):
|
|
238
|
+
self._set_state(CircuitState.HALF_OPEN)
|
|
239
|
+
self._trials = 0
|
|
240
|
+
self._trial_successes = 0
|
|
241
|
+
self._generation += 1
|
|
242
|
+
self._log.info("circuit %s is half-open; allowing trial calls", self.name)
|
|
243
|
+
|
|
244
|
+
def _open(self) -> None:
|
|
245
|
+
self._set_state(CircuitState.OPEN)
|
|
246
|
+
self._opened_at = _core.monotonic()
|
|
247
|
+
self._trials = 0
|
|
248
|
+
self._trial_successes = 0
|
|
249
|
+
self._generation += 1
|
|
250
|
+
self._log.warning(
|
|
251
|
+
"circuit %s opened after %d failure(s); rejecting calls for %.1fs",
|
|
252
|
+
self.name,
|
|
253
|
+
self._failures,
|
|
254
|
+
self.recovery_timeout,
|
|
255
|
+
)
|
|
256
|
+
|
|
257
|
+
# -- internals (call WITHOUT the lock) -----------------------------------------
|
|
258
|
+
def _notify(self) -> None:
|
|
259
|
+
"""Deliver pending transitions to the listener and the event hook."""
|
|
260
|
+
if not self._pending:
|
|
261
|
+
return
|
|
262
|
+
with self._lock:
|
|
263
|
+
pending, self._pending = self._pending, []
|
|
264
|
+
for old, new in pending:
|
|
265
|
+
_core.emit("circuit.state_changed", self.name, old=old, new=new)
|
|
266
|
+
listener = self.on_state_change
|
|
267
|
+
if listener is None:
|
|
268
|
+
continue
|
|
269
|
+
try:
|
|
270
|
+
listener(self, old, new)
|
|
271
|
+
except Exception:
|
|
272
|
+
self._log.exception("circuit %s on_state_change listener failed", self.name)
|
|
273
|
+
|
|
274
|
+
def _acquire(self) -> tuple[int, bool]:
|
|
275
|
+
"""Admit a call. Returns ``(generation, is_trial)`` or raises CircuitOpenError."""
|
|
276
|
+
error: CircuitOpenError | None = None
|
|
277
|
+
with self._lock:
|
|
278
|
+
self._maybe_half_open()
|
|
279
|
+
admitted = (self._generation, self._state is CircuitState.HALF_OPEN)
|
|
280
|
+
if self._state is CircuitState.OPEN:
|
|
281
|
+
remaining = max(self.recovery_timeout - (_core.monotonic() - self._opened_at), 0)
|
|
282
|
+
error = CircuitOpenError(
|
|
283
|
+
f"circuit {self.name} is open; retry in {remaining:.1f}s",
|
|
284
|
+
retry_after=remaining,
|
|
285
|
+
)
|
|
286
|
+
elif self._state is CircuitState.HALF_OPEN:
|
|
287
|
+
if self._trials >= self.half_open_max_calls:
|
|
288
|
+
error = CircuitOpenError(
|
|
289
|
+
f"circuit {self.name} is half-open and its trial calls are in use",
|
|
290
|
+
retry_after=0.0,
|
|
291
|
+
)
|
|
292
|
+
else:
|
|
293
|
+
self._trials += 1
|
|
294
|
+
if error is not None:
|
|
295
|
+
self._total_rejected += 1
|
|
296
|
+
self._notify()
|
|
297
|
+
if error is not None:
|
|
298
|
+
_core.emit("circuit.rejected", self.name, retry_after=error.retry_after)
|
|
299
|
+
raise error
|
|
300
|
+
return admitted
|
|
301
|
+
|
|
302
|
+
def _success(self, generation: int, trial: bool) -> None:
|
|
303
|
+
with self._lock:
|
|
304
|
+
self._total_successes += 1
|
|
305
|
+
if generation != self._generation:
|
|
306
|
+
return
|
|
307
|
+
if trial:
|
|
308
|
+
self._trials = max(self._trials - 1, 0)
|
|
309
|
+
self._trial_successes += 1
|
|
310
|
+
if self._trial_successes >= self.success_threshold:
|
|
311
|
+
self._set_state(CircuitState.CLOSED)
|
|
312
|
+
self._trials = 0
|
|
313
|
+
self._trial_successes = 0
|
|
314
|
+
self._generation += 1
|
|
315
|
+
self._log.info(
|
|
316
|
+
"circuit %s closed after %d successful trial call(s)",
|
|
317
|
+
self.name,
|
|
318
|
+
self.success_threshold,
|
|
319
|
+
)
|
|
320
|
+
self._failures = 0
|
|
321
|
+
self._notify()
|
|
322
|
+
|
|
323
|
+
def _failure(self, generation: int, trial: bool) -> None:
|
|
324
|
+
with self._lock:
|
|
325
|
+
self._total_failures += 1
|
|
326
|
+
if generation != self._generation:
|
|
327
|
+
return
|
|
328
|
+
self._failures += 1
|
|
329
|
+
if trial or self._failures >= self.failure_threshold:
|
|
330
|
+
self._open()
|
|
331
|
+
self._notify()
|
|
332
|
+
|
|
333
|
+
def _release(self, generation: int, trial: bool) -> None:
|
|
334
|
+
"""A non-counted exception ended the call: free its trial slot, if any."""
|
|
335
|
+
if not trial:
|
|
336
|
+
return
|
|
337
|
+
with self._lock:
|
|
338
|
+
if generation == self._generation:
|
|
339
|
+
self._trials = max(self._trials - 1, 0)
|
|
340
|
+
|
|
341
|
+
def _settle(self, generation: int, trial: bool, exc: BaseException) -> None:
|
|
342
|
+
"""Book a call that raised ``exc``: a failure, or uncounted."""
|
|
343
|
+
if isinstance(exc, self.on) and not isinstance(exc, self.exclude):
|
|
344
|
+
self._failure(generation, trial)
|
|
345
|
+
else:
|
|
346
|
+
self._release(generation, trial)
|
|
347
|
+
|
|
348
|
+
# -- public call API -----------------------------------------------------------
|
|
349
|
+
@overload
|
|
350
|
+
def call(
|
|
351
|
+
self, func: Callable[P, Coroutine[Any, Any, R]], /, *args: P.args, **kwargs: P.kwargs
|
|
352
|
+
) -> Coroutine[Any, Any, R]: ...
|
|
353
|
+
@overload
|
|
354
|
+
def call(self, func: Callable[P, R], /, *args: P.args, **kwargs: P.kwargs) -> R: ...
|
|
355
|
+
def call(self, func: Callable[..., Any], /, *args: Any, **kwargs: Any) -> Any:
|
|
356
|
+
"""Call ``func(*args, **kwargs)`` through the breaker.
|
|
357
|
+
|
|
358
|
+
For an ``async def`` function this returns a coroutine to await.
|
|
359
|
+
|
|
360
|
+
Args:
|
|
361
|
+
func: The function to call.
|
|
362
|
+
*args: Positional arguments for ``func``.
|
|
363
|
+
**kwargs: Keyword arguments for ``func``.
|
|
364
|
+
|
|
365
|
+
Returns:
|
|
366
|
+
Whatever ``func`` returns (a coroutine if it is async).
|
|
367
|
+
|
|
368
|
+
Raises:
|
|
369
|
+
CircuitOpenError: If the circuit is open (``func`` is not called).
|
|
370
|
+
"""
|
|
371
|
+
if _core.is_async(func):
|
|
372
|
+
return self._acall(func, args, kwargs)
|
|
373
|
+
return self._call_sync(func, args, kwargs)
|
|
374
|
+
|
|
375
|
+
def _call_sync(self, func: Callable[..., Any], args: Any, kwargs: Any) -> Any:
|
|
376
|
+
generation, trial = self._acquire()
|
|
377
|
+
try:
|
|
378
|
+
result = func(*args, **kwargs)
|
|
379
|
+
except BaseException as exc:
|
|
380
|
+
self._settle(generation, trial, exc)
|
|
381
|
+
raise
|
|
382
|
+
self._success(generation, trial)
|
|
383
|
+
return result
|
|
384
|
+
|
|
385
|
+
async def _acall(self, func: Callable[..., Any], args: Any, kwargs: Any) -> Any:
|
|
386
|
+
# The lock is only taken inside the sync helpers, never across the await.
|
|
387
|
+
generation, trial = self._acquire()
|
|
388
|
+
try:
|
|
389
|
+
result = await func(*args, **kwargs)
|
|
390
|
+
except BaseException as exc:
|
|
391
|
+
self._settle(generation, trial, exc)
|
|
392
|
+
raise
|
|
393
|
+
self._success(generation, trial)
|
|
394
|
+
return result
|
|
395
|
+
|
|
396
|
+
def __call__(self, func: F) -> F:
|
|
397
|
+
"""Decorate ``func`` so every call goes through this breaker.
|
|
398
|
+
|
|
399
|
+
The wrapper keeps the exact type of ``func``, so attributes added by decorators
|
|
400
|
+
applied underneath (such as ``cache_info()`` from ``memoize``) stay visible.
|
|
401
|
+
"""
|
|
402
|
+
if _core.is_async(func):
|
|
403
|
+
|
|
404
|
+
@functools.wraps(func)
|
|
405
|
+
async def async_wrapper(*args: Any, **kwargs: Any) -> Any:
|
|
406
|
+
return await self._acall(func, args, kwargs)
|
|
407
|
+
|
|
408
|
+
return cast(F, async_wrapper)
|
|
409
|
+
|
|
410
|
+
@functools.wraps(func)
|
|
411
|
+
def wrapper(*args: Any, **kwargs: Any) -> Any:
|
|
412
|
+
# Sync vs async was decided above, once: no per-call detection.
|
|
413
|
+
return self._call_sync(func, args, kwargs)
|
|
414
|
+
|
|
415
|
+
return cast(F, wrapper)
|
|
416
|
+
|
|
417
|
+
def __repr__(self) -> str:
|
|
418
|
+
return f"CircuitBreaker(name={self.name!r}, state={self.state.value!r})"
|
|
419
|
+
|
|
420
|
+
|
|
421
|
+
class CircuitProtected(Protocol[P, R_co]):
|
|
422
|
+
"""A function wrapped by `circuit_breaker`: callable, with its own breaker attached.
|
|
423
|
+
|
|
424
|
+
Use it to annotate a decorated function, e.g. ``CircuitProtected[[int], str]``.
|
|
425
|
+
Binds ``self`` and ``cls`` like `python_wrap.caching.Memoized`, with the same typing
|
|
426
|
+
limitations.
|
|
427
|
+
"""
|
|
428
|
+
|
|
429
|
+
def __call__(self, *args: P.args, **kwargs: P.kwargs) -> R_co: ...
|
|
430
|
+
|
|
431
|
+
@overload
|
|
432
|
+
def __get__(
|
|
433
|
+
self: CircuitProtected[Concatenate[type[T], P2], R_co], instance: None, owner: type[T]
|
|
434
|
+
) -> _CircuitProtectedOnClass[P, P2, R_co]: ...
|
|
435
|
+
@overload
|
|
436
|
+
def __get__(self, instance: None, owner: type | None = None) -> CircuitProtected[P, R_co]: ...
|
|
437
|
+
@overload
|
|
438
|
+
def __get__(
|
|
439
|
+
self: CircuitProtected[Concatenate[Any, P2], R_co],
|
|
440
|
+
instance: object,
|
|
441
|
+
owner: type | None = None,
|
|
442
|
+
) -> CircuitProtected[P2, R_co]: ...
|
|
443
|
+
|
|
444
|
+
@property
|
|
445
|
+
def breaker(self) -> CircuitBreaker:
|
|
446
|
+
"""The dedicated `CircuitBreaker` (inspect ``.state`` or call ``.reset()``)."""
|
|
447
|
+
...
|
|
448
|
+
|
|
449
|
+
|
|
450
|
+
class _CircuitProtectedOnClass(CircuitProtected[P, R_co], Protocol[P, P2, R_co]):
|
|
451
|
+
"""`CircuitProtected` read from a class: accepts the call with and without the first argument.
|
|
452
|
+
|
|
453
|
+
Returned for class access (``Cls.attr``) when the first parameter could be ``cls``.
|
|
454
|
+
pyright only picks it under ``@classmethod``; mypy cannot tell a classmethod from an
|
|
455
|
+
instance or static method there, so both call forms are accepted.
|
|
456
|
+
"""
|
|
457
|
+
|
|
458
|
+
@overload
|
|
459
|
+
def __call__(self, *args: P2.args, **kwargs: P2.kwargs) -> R_co: ...
|
|
460
|
+
@overload
|
|
461
|
+
def __call__(self, *args: P.args, **kwargs: P.kwargs) -> R_co: ...
|
|
462
|
+
|
|
463
|
+
|
|
464
|
+
@overload
|
|
465
|
+
def circuit_breaker(
|
|
466
|
+
func: Callable[P, R],
|
|
467
|
+
/,
|
|
468
|
+
*,
|
|
469
|
+
failure_threshold: int = ...,
|
|
470
|
+
recovery_timeout: float = ...,
|
|
471
|
+
half_open_max_calls: int = ...,
|
|
472
|
+
success_threshold: int = ...,
|
|
473
|
+
on: ExceptionTypes = ...,
|
|
474
|
+
exclude: ExceptionTypes = ...,
|
|
475
|
+
on_state_change: StateChangeListener | None = ...,
|
|
476
|
+
name: str | None = ...,
|
|
477
|
+
logger: LoggerLike = ...,
|
|
478
|
+
) -> CircuitProtected[P, R]: ...
|
|
479
|
+
@overload
|
|
480
|
+
def circuit_breaker(
|
|
481
|
+
func: None = None,
|
|
482
|
+
/,
|
|
483
|
+
*,
|
|
484
|
+
failure_threshold: int = ...,
|
|
485
|
+
recovery_timeout: float = ...,
|
|
486
|
+
half_open_max_calls: int = ...,
|
|
487
|
+
success_threshold: int = ...,
|
|
488
|
+
on: ExceptionTypes = ...,
|
|
489
|
+
exclude: ExceptionTypes = ...,
|
|
490
|
+
on_state_change: StateChangeListener | None = ...,
|
|
491
|
+
name: str | None = ...,
|
|
492
|
+
logger: LoggerLike = ...,
|
|
493
|
+
) -> Callable[[Callable[P, R]], CircuitProtected[P, R]]: ...
|
|
494
|
+
def circuit_breaker(
|
|
495
|
+
func: Callable[..., Any] | None = None,
|
|
496
|
+
/,
|
|
497
|
+
*,
|
|
498
|
+
failure_threshold: int = 5,
|
|
499
|
+
recovery_timeout: float = 30.0,
|
|
500
|
+
half_open_max_calls: int = 1,
|
|
501
|
+
success_threshold: int = 1,
|
|
502
|
+
on: ExceptionTypes = Exception,
|
|
503
|
+
exclude: ExceptionTypes = (),
|
|
504
|
+
on_state_change: StateChangeListener | None = None,
|
|
505
|
+
name: str | None = None,
|
|
506
|
+
logger: LoggerLike = None,
|
|
507
|
+
) -> Any:
|
|
508
|
+
"""Decorate a function with its own dedicated `CircuitBreaker`.
|
|
509
|
+
|
|
510
|
+
The wrapper exposes the breaker as ``wrapper.breaker``. To share one breaker
|
|
511
|
+
between several functions, create a `CircuitBreaker` and use it as a
|
|
512
|
+
decorator instead.
|
|
513
|
+
|
|
514
|
+
Args:
|
|
515
|
+
func: The function to wrap (when used without parentheses).
|
|
516
|
+
failure_threshold: Consecutive failures that open the circuit.
|
|
517
|
+
recovery_timeout: Seconds to stay open before allowing trial calls.
|
|
518
|
+
half_open_max_calls: Trial calls allowed while half-open.
|
|
519
|
+
success_threshold: Consecutive successful trials needed to close again.
|
|
520
|
+
on: Exception type(s) counted as failures.
|
|
521
|
+
exclude: Exception type(s) never counted, even when they match ``on``.
|
|
522
|
+
on_state_change: Listener called with ``(breaker, old_state, new_state)``.
|
|
523
|
+
name: Name for log messages, errors and events (defaults to the function's qualified name).
|
|
524
|
+
logger: Logger (or name) for state transitions.
|
|
525
|
+
|
|
526
|
+
Returns:
|
|
527
|
+
The wrapped function, or a decorator when ``func`` is omitted.
|
|
528
|
+
|
|
529
|
+
Raises:
|
|
530
|
+
ValueError: If a numeric option is out of range.
|
|
531
|
+
|
|
532
|
+
Example:
|
|
533
|
+
>>> @circuit_breaker(failure_threshold=3, recovery_timeout=10)
|
|
534
|
+
... def call_api() -> None: ...
|
|
535
|
+
"""
|
|
536
|
+
|
|
537
|
+
def decorate(f: Callable[..., Any]) -> Any:
|
|
538
|
+
breaker = CircuitBreaker(
|
|
539
|
+
failure_threshold=failure_threshold,
|
|
540
|
+
recovery_timeout=recovery_timeout,
|
|
541
|
+
half_open_max_calls=half_open_max_calls,
|
|
542
|
+
success_threshold=success_threshold,
|
|
543
|
+
on=on,
|
|
544
|
+
exclude=exclude,
|
|
545
|
+
on_state_change=on_state_change,
|
|
546
|
+
name=name or _core.qualname(f),
|
|
547
|
+
logger=logger,
|
|
548
|
+
)
|
|
549
|
+
wrapped: Any = breaker(f)
|
|
550
|
+
wrapped.breaker = breaker
|
|
551
|
+
return wrapped
|
|
552
|
+
|
|
553
|
+
return decorate if func is None else decorate(func)
|