interlock-cb 1.0.0__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.
- interlock/__init__.py +53 -0
- interlock/_classify.py +24 -0
- interlock/_clock.py +18 -0
- interlock/_detect.py +34 -0
- interlock/_engine.py +232 -0
- interlock/_state_machine.py +197 -0
- interlock/_typing.py +35 -0
- interlock/_windows.py +118 -0
- interlock/breaker.py +169 -0
- interlock/config.py +69 -0
- interlock/errors.py +72 -0
- interlock/httpx2.py +158 -0
- interlock/listeners.py +38 -0
- interlock/otel.py +64 -0
- interlock/outcome.py +30 -0
- interlock/protocols.py +89 -0
- interlock/py.typed +0 -0
- interlock/registry.py +69 -0
- interlock/state.py +28 -0
- interlock/timeout.py +33 -0
- interlock/version.py +15 -0
- interlock/window.py +47 -0
- interlock_cb-1.0.0.dist-info/METADATA +153 -0
- interlock_cb-1.0.0.dist-info/RECORD +26 -0
- interlock_cb-1.0.0.dist-info/WHEEL +4 -0
- interlock_cb-1.0.0.dist-info/licenses/LICENSE +21 -0
interlock/__init__.py
ADDED
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
"""interlock — a modern circuit breaker for Python."""
|
|
2
|
+
|
|
3
|
+
from interlock._typing import AsyncCallable, Call, SyncCallable
|
|
4
|
+
from interlock.breaker import CircuitBreaker
|
|
5
|
+
from interlock.config import Config
|
|
6
|
+
from interlock.errors import (
|
|
7
|
+
CallTimeoutError,
|
|
8
|
+
CircuitOpenError,
|
|
9
|
+
InterlockDeprecationWarning,
|
|
10
|
+
InterlockError,
|
|
11
|
+
)
|
|
12
|
+
from interlock.listeners import LoggingEventListener
|
|
13
|
+
from interlock.outcome import Outcome
|
|
14
|
+
from interlock.protocols import (
|
|
15
|
+
Clock,
|
|
16
|
+
EventListener,
|
|
17
|
+
FailureClassifier,
|
|
18
|
+
SlidingWindow,
|
|
19
|
+
Storage,
|
|
20
|
+
)
|
|
21
|
+
from interlock.registry import Registry
|
|
22
|
+
from interlock.state import State
|
|
23
|
+
from interlock.timeout import timeout
|
|
24
|
+
from interlock.version import VERSION
|
|
25
|
+
from interlock.window import WindowSnapshot, WindowType
|
|
26
|
+
|
|
27
|
+
__version__ = VERSION
|
|
28
|
+
|
|
29
|
+
__all__ = (
|
|
30
|
+
'VERSION',
|
|
31
|
+
'AsyncCallable',
|
|
32
|
+
'Call',
|
|
33
|
+
'CallTimeoutError',
|
|
34
|
+
'CircuitBreaker',
|
|
35
|
+
'CircuitOpenError',
|
|
36
|
+
'Clock',
|
|
37
|
+
'Config',
|
|
38
|
+
'EventListener',
|
|
39
|
+
'FailureClassifier',
|
|
40
|
+
'InterlockDeprecationWarning',
|
|
41
|
+
'InterlockError',
|
|
42
|
+
'LoggingEventListener',
|
|
43
|
+
'Outcome',
|
|
44
|
+
'Registry',
|
|
45
|
+
'SlidingWindow',
|
|
46
|
+
'State',
|
|
47
|
+
'Storage',
|
|
48
|
+
'SyncCallable',
|
|
49
|
+
'WindowSnapshot',
|
|
50
|
+
'WindowType',
|
|
51
|
+
'__version__',
|
|
52
|
+
'timeout',
|
|
53
|
+
)
|
interlock/_classify.py
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
"""Default failure classification policy.
|
|
2
|
+
|
|
3
|
+
Implements ``FailureClassifier``: the baseline treats any raised exception as a
|
|
4
|
+
failure and any returned value as a success. Result-based predicates and
|
|
5
|
+
exception allow/ignore lists layer on top in the public API (M5).
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
__all__ = ('DefaultFailureClassifier',)
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class DefaultFailureClassifier:
|
|
12
|
+
"""Counts a call as a failure exactly when it raised."""
|
|
13
|
+
|
|
14
|
+
def is_failure(self, *, result: object, exception: BaseException | None) -> bool: # noqa: ARG002
|
|
15
|
+
"""Return whether a completed call counts as a failure.
|
|
16
|
+
|
|
17
|
+
Args:
|
|
18
|
+
result: The call's return value; ignored by this policy.
|
|
19
|
+
exception: The exception the call raised, or ``None`` if it returned.
|
|
20
|
+
|
|
21
|
+
Returns:
|
|
22
|
+
``True`` if the call raised, ``False`` otherwise.
|
|
23
|
+
"""
|
|
24
|
+
return exception is not None
|
interlock/_clock.py
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
"""The default production clock.
|
|
2
|
+
|
|
3
|
+
Breakers read time only through a ``Clock``, so tests can inject a fake one.
|
|
4
|
+
In production the default is this thin wrapper over ``time.monotonic`` — a
|
|
5
|
+
monotonic source unaffected by wall-clock adjustments.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import time
|
|
9
|
+
|
|
10
|
+
__all__ = ('SystemClock',)
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class SystemClock:
|
|
14
|
+
"""A ``Clock`` backed by ``time.monotonic``."""
|
|
15
|
+
|
|
16
|
+
def monotonic(self) -> float:
|
|
17
|
+
"""Return ``time.monotonic()`` in fractional seconds."""
|
|
18
|
+
return time.monotonic()
|
interlock/_detect.py
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
"""Detect whether a callable runs synchronously or returns an awaitable.
|
|
2
|
+
|
|
3
|
+
The public surface accepts any callable and must dispatch to the right path.
|
|
4
|
+
A naive ``inspect.iscoroutinefunction(fn)`` misses two common shapes: callables
|
|
5
|
+
wrapped in ``functools.partial`` and objects whose ``__call__`` is a coroutine
|
|
6
|
+
function. This unwraps partials and inspects ``__call__`` so both are handled.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
import functools
|
|
10
|
+
import inspect
|
|
11
|
+
|
|
12
|
+
__all__ = ('is_async_callable',)
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def is_async_callable(fn: object) -> bool:
|
|
16
|
+
"""Return whether calling ``fn`` produces an awaitable.
|
|
17
|
+
|
|
18
|
+
Args:
|
|
19
|
+
fn: Any callable — a function, bound method, ``functools.partial``, or an
|
|
20
|
+
instance with a ``__call__`` method.
|
|
21
|
+
|
|
22
|
+
Returns:
|
|
23
|
+
``True`` if ``fn`` is (or wraps, or is an object whose ``__call__`` is) a
|
|
24
|
+
coroutine function; ``False`` otherwise.
|
|
25
|
+
"""
|
|
26
|
+
while isinstance(fn, functools.partial):
|
|
27
|
+
fn = fn.func
|
|
28
|
+
|
|
29
|
+
if inspect.iscoroutinefunction(fn):
|
|
30
|
+
return True
|
|
31
|
+
|
|
32
|
+
# The __call__ attribute itself is needed to test its async-ness, which
|
|
33
|
+
# callable() cannot report — so B004's suggestion does not apply here.
|
|
34
|
+
return inspect.iscoroutinefunction(getattr(type(fn), '__call__', None)) # noqa: B004
|
interlock/_engine.py
ADDED
|
@@ -0,0 +1,232 @@
|
|
|
1
|
+
"""The ``call()`` primitive: detect, dispatch, time, classify, record.
|
|
2
|
+
|
|
3
|
+
This is the I/O-aware layer wrapping the I/O-free ``StateMachine``. It owns a
|
|
4
|
+
single ``threading.Lock`` and holds it only around the two await-free critical
|
|
5
|
+
sections — admitting a call (``acquire``) and recording its outcome
|
|
6
|
+
(``record``). The protected callable runs *outside* the lock, so a slow
|
|
7
|
+
downstream never serialises throughput and a re-entrant call cannot deadlock.
|
|
8
|
+
|
|
9
|
+
A single instance serves both sync and async callers: ``call`` detects the
|
|
10
|
+
callable's nature via ``is_async_callable`` and dispatches to ``call_sync`` or
|
|
11
|
+
``call_async``. The lock is a ``threading.Lock`` because the critical sections
|
|
12
|
+
never ``await``; it is correct for threads and for a single event loop alike.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
import threading
|
|
16
|
+
from collections.abc import Awaitable, Callable
|
|
17
|
+
from typing import cast
|
|
18
|
+
|
|
19
|
+
from interlock._classify import DefaultFailureClassifier
|
|
20
|
+
from interlock._detect import is_async_callable
|
|
21
|
+
from interlock._state_machine import StateMachine
|
|
22
|
+
from interlock._typing import AsyncCallable, P, R, SyncCallable
|
|
23
|
+
from interlock.config import Config
|
|
24
|
+
from interlock.errors import CircuitOpenError
|
|
25
|
+
from interlock.outcome import Outcome
|
|
26
|
+
from interlock.protocols import Clock, EventListener, FailureClassifier
|
|
27
|
+
from interlock.state import State
|
|
28
|
+
from interlock.window import WindowSnapshot
|
|
29
|
+
|
|
30
|
+
__all__ = ('Engine',)
|
|
31
|
+
|
|
32
|
+
_OUTCOME_BY_FLAGS = {
|
|
33
|
+
(False, False): Outcome.SUCCESS,
|
|
34
|
+
(True, False): Outcome.FAILURE,
|
|
35
|
+
(False, True): Outcome.SLOW_SUCCESS,
|
|
36
|
+
(True, True): Outcome.SLOW_FAILURE,
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
class _NoopListener:
|
|
41
|
+
"""Null EventListener used when none is configured.
|
|
42
|
+
|
|
43
|
+
Lets the engine always call ``self._listener.<hook>(...)`` without a None
|
|
44
|
+
check; every hook is a no-op.
|
|
45
|
+
"""
|
|
46
|
+
|
|
47
|
+
def on_state_change(self, *, name: str, old: State, new: State) -> None: ...
|
|
48
|
+
def on_call(self, *, name: str, outcome: Outcome, duration: float) -> None: ...
|
|
49
|
+
def on_rejected(self, *, name: str) -> None: ...
|
|
50
|
+
def on_reset(self, *, name: str) -> None: ...
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
_NOOP_LISTENER: EventListener = _NoopListener()
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
class Engine:
|
|
57
|
+
"""Runs callables under one breaker, mediating the state machine.
|
|
58
|
+
|
|
59
|
+
Args:
|
|
60
|
+
name: Breaker name, surfaced on ``CircuitOpenError``.
|
|
61
|
+
config: Thresholds, window and timing.
|
|
62
|
+
clock: Time source; injected for deterministic tests.
|
|
63
|
+
classifier: Decides which outcomes count as failures. Defaults to
|
|
64
|
+
``DefaultFailureClassifier`` (any raised exception is a failure).
|
|
65
|
+
listener: Observability hooks. Defaults to a no-op listener.
|
|
66
|
+
"""
|
|
67
|
+
|
|
68
|
+
def __init__(
|
|
69
|
+
self,
|
|
70
|
+
*,
|
|
71
|
+
name: str,
|
|
72
|
+
config: Config,
|
|
73
|
+
clock: Clock,
|
|
74
|
+
classifier: FailureClassifier | None = None,
|
|
75
|
+
listener: EventListener | None = None,
|
|
76
|
+
) -> None:
|
|
77
|
+
self._name = name
|
|
78
|
+
self._config = config
|
|
79
|
+
self._clock = clock
|
|
80
|
+
self._classifier = classifier if classifier is not None else DefaultFailureClassifier()
|
|
81
|
+
self._listener = listener if listener is not None else _NOOP_LISTENER
|
|
82
|
+
self._machine = StateMachine(config=config, clock=clock)
|
|
83
|
+
self._lock = threading.Lock()
|
|
84
|
+
self._last_failure: BaseException | None = None
|
|
85
|
+
|
|
86
|
+
@property
|
|
87
|
+
def state(self) -> State:
|
|
88
|
+
"""The breaker's current lifecycle state."""
|
|
89
|
+
return self._machine.state
|
|
90
|
+
|
|
91
|
+
def snapshot(self) -> WindowSnapshot:
|
|
92
|
+
"""An immutable view of the current window aggregates."""
|
|
93
|
+
return self._machine.snapshot()
|
|
94
|
+
|
|
95
|
+
def call(
|
|
96
|
+
self,
|
|
97
|
+
fn: AsyncCallable[P, R] | SyncCallable[P, R],
|
|
98
|
+
/,
|
|
99
|
+
*args: P.args,
|
|
100
|
+
**kwargs: P.kwargs,
|
|
101
|
+
) -> Awaitable[R] | R:
|
|
102
|
+
"""Run ``fn`` under protection, dispatching on its sync/async nature.
|
|
103
|
+
|
|
104
|
+
Returns an awaitable for a coroutine function and the plain result for a
|
|
105
|
+
synchronous one; the precise sync/async-preserving typing is supplied by
|
|
106
|
+
the public ``CircuitBreaker`` surface (M5) over this primitive.
|
|
107
|
+
"""
|
|
108
|
+
if is_async_callable(fn):
|
|
109
|
+
return self.call_async(cast('AsyncCallable[P, R]', fn), *args, **kwargs)
|
|
110
|
+
|
|
111
|
+
return self.call_sync(cast('SyncCallable[P, R]', fn), *args, **kwargs)
|
|
112
|
+
|
|
113
|
+
def call_sync(self, fn: SyncCallable[P, R], /, *args: P.args, **kwargs: P.kwargs) -> R:
|
|
114
|
+
"""Run a synchronous ``fn`` under protection.
|
|
115
|
+
|
|
116
|
+
Raises:
|
|
117
|
+
CircuitOpenError: If the breaker rejects the call.
|
|
118
|
+
"""
|
|
119
|
+
self._admit()
|
|
120
|
+
start = self._clock.monotonic()
|
|
121
|
+
|
|
122
|
+
try:
|
|
123
|
+
result = fn(*args, **kwargs)
|
|
124
|
+
except Exception as exc:
|
|
125
|
+
self._settle(result=None, exception=exc, start=start)
|
|
126
|
+
raise
|
|
127
|
+
else:
|
|
128
|
+
self._settle(result=result, exception=None, start=start)
|
|
129
|
+
return result
|
|
130
|
+
|
|
131
|
+
async def call_async(self, fn: AsyncCallable[P, R], /, *args: P.args, **kwargs: P.kwargs) -> R:
|
|
132
|
+
"""Run an asynchronous ``fn`` under protection.
|
|
133
|
+
|
|
134
|
+
Raises:
|
|
135
|
+
CircuitOpenError: If the breaker rejects the call.
|
|
136
|
+
"""
|
|
137
|
+
self._admit()
|
|
138
|
+
start = self._clock.monotonic()
|
|
139
|
+
|
|
140
|
+
try:
|
|
141
|
+
result = await fn(*args, **kwargs)
|
|
142
|
+
except Exception as exc:
|
|
143
|
+
self._settle(result=None, exception=exc, start=start)
|
|
144
|
+
raise
|
|
145
|
+
else:
|
|
146
|
+
self._settle(result=result, exception=None, start=start)
|
|
147
|
+
return result
|
|
148
|
+
|
|
149
|
+
def enter_block(self) -> float:
|
|
150
|
+
"""Admit a guarded block and return its start time.
|
|
151
|
+
|
|
152
|
+
Backs the context-manager surface, where there is no callable to run —
|
|
153
|
+
only a block whose exception and duration are observed.
|
|
154
|
+
|
|
155
|
+
Raises:
|
|
156
|
+
CircuitOpenError: If the breaker rejects the block.
|
|
157
|
+
"""
|
|
158
|
+
self._admit()
|
|
159
|
+
return self._clock.monotonic()
|
|
160
|
+
|
|
161
|
+
def exit_block(self, *, start: float, exception: BaseException | None) -> None:
|
|
162
|
+
"""Record a guarded block's outcome from its exception and duration."""
|
|
163
|
+
if exception is not None and not isinstance(exception, Exception):
|
|
164
|
+
return # mirror call(): cancellation/shutdown are not downstream failures
|
|
165
|
+
self._settle(result=None, exception=exception, start=start)
|
|
166
|
+
|
|
167
|
+
def reset(self) -> None:
|
|
168
|
+
"""Return to ``CLOSED`` with a fresh window, discarding past metrics."""
|
|
169
|
+
with self._lock:
|
|
170
|
+
before = self._machine.state
|
|
171
|
+
self._machine.reset()
|
|
172
|
+
after = self._machine.state
|
|
173
|
+
|
|
174
|
+
self._emit_state_change(before, after)
|
|
175
|
+
self._listener.on_reset(name=self._name)
|
|
176
|
+
|
|
177
|
+
def force_open(self) -> None:
|
|
178
|
+
"""Override to ``FORCED_OPEN``: reject all traffic until reset."""
|
|
179
|
+
self._override(self._machine.force_open)
|
|
180
|
+
|
|
181
|
+
def disable(self) -> None:
|
|
182
|
+
"""Override to ``DISABLED``: admit all traffic, record nothing."""
|
|
183
|
+
self._override(self._machine.disable)
|
|
184
|
+
|
|
185
|
+
def metrics_only(self) -> None:
|
|
186
|
+
"""Override to ``METRICS_ONLY``: admit all traffic, record but never trip."""
|
|
187
|
+
self._override(self._machine.metrics_only)
|
|
188
|
+
|
|
189
|
+
def _override(self, mutate: Callable[[], None]) -> None:
|
|
190
|
+
with self._lock:
|
|
191
|
+
before = self._machine.state
|
|
192
|
+
mutate()
|
|
193
|
+
after = self._machine.state
|
|
194
|
+
|
|
195
|
+
self._emit_state_change(before, after)
|
|
196
|
+
|
|
197
|
+
def _admit(self) -> None:
|
|
198
|
+
with self._lock:
|
|
199
|
+
before = self._machine.state
|
|
200
|
+
admitted = self._machine.acquire()
|
|
201
|
+
after = self._machine.state
|
|
202
|
+
retry_after = None if admitted else self._machine.retry_after()
|
|
203
|
+
last_failure = self._last_failure
|
|
204
|
+
|
|
205
|
+
self._emit_state_change(before, after)
|
|
206
|
+
if not admitted:
|
|
207
|
+
self._listener.on_rejected(name=self._name)
|
|
208
|
+
raise CircuitOpenError(
|
|
209
|
+
self._name,
|
|
210
|
+
retry_after=retry_after,
|
|
211
|
+
last_failure=last_failure,
|
|
212
|
+
)
|
|
213
|
+
|
|
214
|
+
def _settle(self, *, result: object, exception: Exception | None, start: float) -> None:
|
|
215
|
+
duration = self._clock.monotonic() - start
|
|
216
|
+
failure = self._classifier.is_failure(result=result, exception=exception)
|
|
217
|
+
slow = duration >= self._config.slow_call_duration_threshold
|
|
218
|
+
outcome = _OUTCOME_BY_FLAGS[failure, slow]
|
|
219
|
+
|
|
220
|
+
with self._lock:
|
|
221
|
+
before = self._machine.state
|
|
222
|
+
self._machine.record(outcome)
|
|
223
|
+
after = self._machine.state
|
|
224
|
+
if failure and exception is not None:
|
|
225
|
+
self._last_failure = exception
|
|
226
|
+
|
|
227
|
+
self._listener.on_call(name=self._name, outcome=outcome, duration=duration)
|
|
228
|
+
self._emit_state_change(before, after)
|
|
229
|
+
|
|
230
|
+
def _emit_state_change(self, before: State, after: State) -> None:
|
|
231
|
+
if after != before:
|
|
232
|
+
self._listener.on_state_change(name=self._name, old=before, new=after)
|
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
"""The I/O-free circuit breaker state machine.
|
|
2
|
+
|
|
3
|
+
This is the core: it owns the current ``State``, the sliding window of recent
|
|
4
|
+
outcomes, and every transition between them. It performs no I/O and knows
|
|
5
|
+
nothing about sync vs async — the call layer wraps it in a lock and feeds it
|
|
6
|
+
admission requests (``acquire``) and results (``record``). All time comes from
|
|
7
|
+
the injected ``Clock``, so transitions are fully deterministic under test.
|
|
8
|
+
|
|
9
|
+
Three core states cycle on downstream health:
|
|
10
|
+
|
|
11
|
+
- ``CLOSED`` admits everything and trips to ``OPEN`` once the window holds at
|
|
12
|
+
least ``minimum_number_of_calls`` and the failure *or* slow-call rate reaches
|
|
13
|
+
its threshold.
|
|
14
|
+
- ``OPEN`` rejects everything until ``wait_duration_in_open`` has elapsed, then
|
|
15
|
+
lazily (on the next ``acquire``) moves to ``HALF_OPEN``.
|
|
16
|
+
- ``HALF_OPEN`` admits a bounded number of probes — at most
|
|
17
|
+
``permitted_calls_in_half_open`` total and ``max_concurrent_probes`` at once —
|
|
18
|
+
then closes or reopens based on the probes' rates.
|
|
19
|
+
|
|
20
|
+
Three special states are operator overrides: ``FORCED_OPEN`` (reject all),
|
|
21
|
+
``DISABLED`` (admit all, no metrics), ``METRICS_ONLY`` (admit all, record
|
|
22
|
+
metrics, never trip).
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
from interlock._windows import build_window
|
|
26
|
+
from interlock.config import Config
|
|
27
|
+
from interlock.outcome import Outcome
|
|
28
|
+
from interlock.protocols import Clock
|
|
29
|
+
from interlock.state import State
|
|
30
|
+
from interlock.window import WindowSnapshot
|
|
31
|
+
|
|
32
|
+
__all__ = ('StateMachine',)
|
|
33
|
+
|
|
34
|
+
_PERMIT_ALL = frozenset({State.CLOSED, State.DISABLED, State.METRICS_ONLY})
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class StateMachine:
|
|
38
|
+
"""Owns breaker state and drives transitions from recorded outcomes.
|
|
39
|
+
|
|
40
|
+
Not thread-safe on its own: the call layer serialises ``acquire`` and
|
|
41
|
+
``record`` under a single lock. Time is read only through the injected
|
|
42
|
+
``Clock``.
|
|
43
|
+
"""
|
|
44
|
+
|
|
45
|
+
def __init__(self, *, config: Config, clock: Clock) -> None:
|
|
46
|
+
self._config = config
|
|
47
|
+
self._clock = clock
|
|
48
|
+
self._window = build_window(config=config, clock=clock)
|
|
49
|
+
self._state = State.CLOSED
|
|
50
|
+
self._opened_at = 0.0
|
|
51
|
+
self._reset_probes()
|
|
52
|
+
|
|
53
|
+
@property
|
|
54
|
+
def state(self) -> State:
|
|
55
|
+
"""The current lifecycle state."""
|
|
56
|
+
return self._state
|
|
57
|
+
|
|
58
|
+
def snapshot(self) -> WindowSnapshot:
|
|
59
|
+
"""An immutable view of the current window aggregates."""
|
|
60
|
+
return self._window.snapshot()
|
|
61
|
+
|
|
62
|
+
def retry_after(self) -> float | None:
|
|
63
|
+
"""Seconds until the next probe is admitted, when the breaker can estimate it.
|
|
64
|
+
|
|
65
|
+
Estimable only in ``OPEN`` — the remainder of ``wait_duration_in_open``.
|
|
66
|
+
``HALF_OPEN`` rejections come from probe caps rather than time, and
|
|
67
|
+
``FORCED_OPEN`` waits for an operator; neither has a time estimate, so
|
|
68
|
+
both (like the admitting states) return ``None``.
|
|
69
|
+
"""
|
|
70
|
+
if self._state is not State.OPEN:
|
|
71
|
+
return None
|
|
72
|
+
|
|
73
|
+
elapsed = self._clock.monotonic() - self._opened_at
|
|
74
|
+
return max(0.0, self._config.wait_duration_in_open - elapsed)
|
|
75
|
+
|
|
76
|
+
def acquire(self) -> bool:
|
|
77
|
+
"""Decide whether one call may proceed, mutating state lazily.
|
|
78
|
+
|
|
79
|
+
``OPEN`` becomes ``HALF_OPEN`` here once its wait has elapsed, and the
|
|
80
|
+
triggering call is admitted as the first probe. The caller raises
|
|
81
|
+
``CircuitOpenError`` on a ``False`` result; this method never raises.
|
|
82
|
+
"""
|
|
83
|
+
if self._state in _PERMIT_ALL:
|
|
84
|
+
return True
|
|
85
|
+
|
|
86
|
+
if self._state is State.OPEN:
|
|
87
|
+
return self._begin_probing_if_elapsed()
|
|
88
|
+
|
|
89
|
+
if self._state is State.HALF_OPEN:
|
|
90
|
+
return self._admit_probe()
|
|
91
|
+
|
|
92
|
+
return False # FORCED_OPEN
|
|
93
|
+
|
|
94
|
+
def record(self, outcome: Outcome) -> None:
|
|
95
|
+
"""Record one completed call's outcome and evaluate any transition."""
|
|
96
|
+
if self._state is State.CLOSED:
|
|
97
|
+
self._window.record(outcome)
|
|
98
|
+
self._evaluate_closed()
|
|
99
|
+
elif self._state is State.HALF_OPEN:
|
|
100
|
+
self._record_probe(outcome)
|
|
101
|
+
elif self._state is State.METRICS_ONLY:
|
|
102
|
+
self._window.record(outcome)
|
|
103
|
+
|
|
104
|
+
def force_open(self) -> None:
|
|
105
|
+
"""Override to ``FORCED_OPEN``: reject all traffic until reset."""
|
|
106
|
+
self._state = State.FORCED_OPEN
|
|
107
|
+
self._reset_probes()
|
|
108
|
+
|
|
109
|
+
def disable(self) -> None:
|
|
110
|
+
"""Override to ``DISABLED``: admit all traffic, record nothing."""
|
|
111
|
+
self._state = State.DISABLED
|
|
112
|
+
self._reset_probes()
|
|
113
|
+
|
|
114
|
+
def metrics_only(self) -> None:
|
|
115
|
+
"""Override to ``METRICS_ONLY``: admit all traffic, record but never trip."""
|
|
116
|
+
self._state = State.METRICS_ONLY
|
|
117
|
+
self._reset_probes()
|
|
118
|
+
|
|
119
|
+
def reset(self) -> None:
|
|
120
|
+
"""Return to ``CLOSED`` with a fresh window, discarding past metrics."""
|
|
121
|
+
self._close()
|
|
122
|
+
|
|
123
|
+
def _begin_probing_if_elapsed(self) -> bool:
|
|
124
|
+
if self._clock.monotonic() - self._opened_at < self._config.wait_duration_in_open:
|
|
125
|
+
return False
|
|
126
|
+
|
|
127
|
+
self._to_half_open()
|
|
128
|
+
return self._admit_probe()
|
|
129
|
+
|
|
130
|
+
def _admit_probe(self) -> bool:
|
|
131
|
+
# Cap total probes (don't hammer a barely-recovered dependency) and how
|
|
132
|
+
# many run at once (else the whole parallel load floods in as probes).
|
|
133
|
+
if (
|
|
134
|
+
self._probes_in_flight >= self._config.max_concurrent_probes
|
|
135
|
+
or self._probes_admitted >= self._config.permitted_calls_in_half_open
|
|
136
|
+
):
|
|
137
|
+
return False
|
|
138
|
+
|
|
139
|
+
self._probes_admitted += 1
|
|
140
|
+
self._probes_in_flight += 1
|
|
141
|
+
return True
|
|
142
|
+
|
|
143
|
+
def _evaluate_closed(self) -> None:
|
|
144
|
+
snapshot = self._window.snapshot()
|
|
145
|
+
if snapshot.total_calls < self._config.minimum_number_of_calls:
|
|
146
|
+
return
|
|
147
|
+
|
|
148
|
+
if self._exceeds_threshold(
|
|
149
|
+
failure_rate=snapshot.failure_rate,
|
|
150
|
+
slow_call_rate=snapshot.slow_call_rate,
|
|
151
|
+
):
|
|
152
|
+
self._open()
|
|
153
|
+
|
|
154
|
+
def _record_probe(self, outcome: Outcome) -> None:
|
|
155
|
+
self._probes_in_flight -= 1
|
|
156
|
+
self._probes_completed += 1
|
|
157
|
+
self._probe_failures += outcome.is_failure
|
|
158
|
+
self._probe_slows += outcome.is_slow
|
|
159
|
+
|
|
160
|
+
if self._probes_completed >= self._config.permitted_calls_in_half_open:
|
|
161
|
+
self._evaluate_probes()
|
|
162
|
+
|
|
163
|
+
def _evaluate_probes(self) -> None:
|
|
164
|
+
completed = self._probes_completed
|
|
165
|
+
if self._exceeds_threshold(
|
|
166
|
+
failure_rate=self._probe_failures / completed,
|
|
167
|
+
slow_call_rate=self._probe_slows / completed,
|
|
168
|
+
):
|
|
169
|
+
self._open()
|
|
170
|
+
else:
|
|
171
|
+
self._close()
|
|
172
|
+
|
|
173
|
+
def _exceeds_threshold(self, *, failure_rate: float, slow_call_rate: float) -> bool:
|
|
174
|
+
return (
|
|
175
|
+
failure_rate >= self._config.failure_rate_threshold
|
|
176
|
+
or slow_call_rate >= self._config.slow_call_rate_threshold
|
|
177
|
+
)
|
|
178
|
+
|
|
179
|
+
def _open(self) -> None:
|
|
180
|
+
self._state = State.OPEN
|
|
181
|
+
self._opened_at = self._clock.monotonic()
|
|
182
|
+
|
|
183
|
+
def _to_half_open(self) -> None:
|
|
184
|
+
self._state = State.HALF_OPEN
|
|
185
|
+
self._reset_probes()
|
|
186
|
+
|
|
187
|
+
def _close(self) -> None:
|
|
188
|
+
self._state = State.CLOSED
|
|
189
|
+
self._window = build_window(config=self._config, clock=self._clock)
|
|
190
|
+
self._reset_probes()
|
|
191
|
+
|
|
192
|
+
def _reset_probes(self) -> None:
|
|
193
|
+
self._probes_admitted = 0
|
|
194
|
+
self._probes_in_flight = 0
|
|
195
|
+
self._probes_completed = 0
|
|
196
|
+
self._probe_failures = 0
|
|
197
|
+
self._probe_slows = 0
|
interlock/_typing.py
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
"""Typing primitives for the call() contract and type-safe decorators.
|
|
2
|
+
|
|
3
|
+
These aliases let the public API preserve a wrapped callable's signature and
|
|
4
|
+
its sync/async nature instead of collapsing to ``Callable[..., Any]``.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from collections.abc import Awaitable, Callable
|
|
8
|
+
from typing import ParamSpec, Protocol, TypeVar, overload, runtime_checkable
|
|
9
|
+
|
|
10
|
+
P = ParamSpec('P')
|
|
11
|
+
R = TypeVar('R')
|
|
12
|
+
|
|
13
|
+
SyncCallable = Callable[P, R]
|
|
14
|
+
AsyncCallable = Callable[P, Awaitable[R]]
|
|
15
|
+
|
|
16
|
+
__all__ = ('AsyncCallable', 'Call', 'SyncCallable')
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
@runtime_checkable
|
|
20
|
+
class Call(Protocol):
|
|
21
|
+
"""The call() primitive: run a callable under the breaker's protection.
|
|
22
|
+
|
|
23
|
+
The return type tracks the callable's nature — a coroutine function yields
|
|
24
|
+
an awaitable, a plain function yields its result — so static typing never
|
|
25
|
+
loses the sync/async distinction. The detect-and-dispatch implementation
|
|
26
|
+
lives in the core (M4); this protocol fixes the contract.
|
|
27
|
+
"""
|
|
28
|
+
|
|
29
|
+
@overload
|
|
30
|
+
def __call__(
|
|
31
|
+
self, fn: AsyncCallable[P, R], /, *args: P.args, **kwargs: P.kwargs
|
|
32
|
+
) -> Awaitable[R]: ...
|
|
33
|
+
|
|
34
|
+
@overload
|
|
35
|
+
def __call__(self, fn: SyncCallable[P, R], /, *args: P.args, **kwargs: P.kwargs) -> R: ...
|