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