kstlib 0.0.1a0__py3-none-any.whl → 1.0.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.
- kstlib/__init__.py +266 -1
- kstlib/__main__.py +16 -0
- kstlib/alerts/__init__.py +110 -0
- kstlib/alerts/channels/__init__.py +36 -0
- kstlib/alerts/channels/base.py +197 -0
- kstlib/alerts/channels/email.py +227 -0
- kstlib/alerts/channels/slack.py +389 -0
- kstlib/alerts/exceptions.py +72 -0
- kstlib/alerts/manager.py +651 -0
- kstlib/alerts/models.py +142 -0
- kstlib/alerts/throttle.py +263 -0
- kstlib/auth/__init__.py +139 -0
- kstlib/auth/callback.py +399 -0
- kstlib/auth/config.py +502 -0
- kstlib/auth/errors.py +127 -0
- kstlib/auth/models.py +316 -0
- kstlib/auth/providers/__init__.py +14 -0
- kstlib/auth/providers/base.py +393 -0
- kstlib/auth/providers/oauth2.py +645 -0
- kstlib/auth/providers/oidc.py +821 -0
- kstlib/auth/session.py +338 -0
- kstlib/auth/token.py +482 -0
- kstlib/cache/__init__.py +50 -0
- kstlib/cache/decorator.py +261 -0
- kstlib/cache/strategies.py +516 -0
- kstlib/cli/__init__.py +8 -0
- kstlib/cli/app.py +195 -0
- kstlib/cli/commands/__init__.py +5 -0
- kstlib/cli/commands/auth/__init__.py +39 -0
- kstlib/cli/commands/auth/common.py +122 -0
- kstlib/cli/commands/auth/login.py +325 -0
- kstlib/cli/commands/auth/logout.py +74 -0
- kstlib/cli/commands/auth/providers.py +57 -0
- kstlib/cli/commands/auth/status.py +291 -0
- kstlib/cli/commands/auth/token.py +199 -0
- kstlib/cli/commands/auth/whoami.py +106 -0
- kstlib/cli/commands/config.py +89 -0
- kstlib/cli/commands/ops/__init__.py +39 -0
- kstlib/cli/commands/ops/attach.py +49 -0
- kstlib/cli/commands/ops/common.py +269 -0
- kstlib/cli/commands/ops/list_sessions.py +252 -0
- kstlib/cli/commands/ops/logs.py +49 -0
- kstlib/cli/commands/ops/start.py +98 -0
- kstlib/cli/commands/ops/status.py +138 -0
- kstlib/cli/commands/ops/stop.py +60 -0
- kstlib/cli/commands/rapi/__init__.py +60 -0
- kstlib/cli/commands/rapi/call.py +341 -0
- kstlib/cli/commands/rapi/list.py +99 -0
- kstlib/cli/commands/rapi/show.py +206 -0
- kstlib/cli/commands/secrets/__init__.py +35 -0
- kstlib/cli/commands/secrets/common.py +425 -0
- kstlib/cli/commands/secrets/decrypt.py +88 -0
- kstlib/cli/commands/secrets/doctor.py +743 -0
- kstlib/cli/commands/secrets/encrypt.py +242 -0
- kstlib/cli/commands/secrets/shred.py +96 -0
- kstlib/cli/common.py +86 -0
- kstlib/config/__init__.py +76 -0
- kstlib/config/exceptions.py +110 -0
- kstlib/config/export.py +225 -0
- kstlib/config/loader.py +963 -0
- kstlib/config/sops.py +287 -0
- kstlib/db/__init__.py +54 -0
- kstlib/db/aiosqlcipher.py +137 -0
- kstlib/db/cipher.py +112 -0
- kstlib/db/database.py +367 -0
- kstlib/db/exceptions.py +25 -0
- kstlib/db/pool.py +302 -0
- kstlib/helpers/__init__.py +35 -0
- kstlib/helpers/exceptions.py +11 -0
- kstlib/helpers/time_trigger.py +396 -0
- kstlib/kstlib.conf.yml +890 -0
- kstlib/limits.py +963 -0
- kstlib/logging/__init__.py +108 -0
- kstlib/logging/manager.py +633 -0
- kstlib/mail/__init__.py +42 -0
- kstlib/mail/builder.py +626 -0
- kstlib/mail/exceptions.py +27 -0
- kstlib/mail/filesystem.py +248 -0
- kstlib/mail/transport.py +224 -0
- kstlib/mail/transports/__init__.py +19 -0
- kstlib/mail/transports/gmail.py +268 -0
- kstlib/mail/transports/resend.py +324 -0
- kstlib/mail/transports/smtp.py +326 -0
- kstlib/meta.py +72 -0
- kstlib/metrics/__init__.py +88 -0
- kstlib/metrics/decorators.py +1090 -0
- kstlib/metrics/exceptions.py +14 -0
- kstlib/monitoring/__init__.py +116 -0
- kstlib/monitoring/_styles.py +163 -0
- kstlib/monitoring/cell.py +57 -0
- kstlib/monitoring/config.py +424 -0
- kstlib/monitoring/delivery.py +579 -0
- kstlib/monitoring/exceptions.py +63 -0
- kstlib/monitoring/image.py +220 -0
- kstlib/monitoring/kv.py +79 -0
- kstlib/monitoring/list.py +69 -0
- kstlib/monitoring/metric.py +88 -0
- kstlib/monitoring/monitoring.py +341 -0
- kstlib/monitoring/renderer.py +139 -0
- kstlib/monitoring/service.py +392 -0
- kstlib/monitoring/table.py +129 -0
- kstlib/monitoring/types.py +56 -0
- kstlib/ops/__init__.py +86 -0
- kstlib/ops/base.py +148 -0
- kstlib/ops/container.py +577 -0
- kstlib/ops/exceptions.py +209 -0
- kstlib/ops/manager.py +407 -0
- kstlib/ops/models.py +176 -0
- kstlib/ops/tmux.py +372 -0
- kstlib/ops/validators.py +287 -0
- kstlib/py.typed +0 -0
- kstlib/rapi/__init__.py +118 -0
- kstlib/rapi/client.py +875 -0
- kstlib/rapi/config.py +861 -0
- kstlib/rapi/credentials.py +887 -0
- kstlib/rapi/exceptions.py +213 -0
- kstlib/resilience/__init__.py +101 -0
- kstlib/resilience/circuit_breaker.py +440 -0
- kstlib/resilience/exceptions.py +95 -0
- kstlib/resilience/heartbeat.py +491 -0
- kstlib/resilience/rate_limiter.py +506 -0
- kstlib/resilience/shutdown.py +417 -0
- kstlib/resilience/watchdog.py +637 -0
- kstlib/secrets/__init__.py +29 -0
- kstlib/secrets/exceptions.py +19 -0
- kstlib/secrets/models.py +62 -0
- kstlib/secrets/providers/__init__.py +79 -0
- kstlib/secrets/providers/base.py +58 -0
- kstlib/secrets/providers/environment.py +66 -0
- kstlib/secrets/providers/keyring.py +107 -0
- kstlib/secrets/providers/kms.py +223 -0
- kstlib/secrets/providers/kwargs.py +101 -0
- kstlib/secrets/providers/sops.py +209 -0
- kstlib/secrets/resolver.py +221 -0
- kstlib/secrets/sensitive.py +130 -0
- kstlib/secure/__init__.py +23 -0
- kstlib/secure/fs.py +194 -0
- kstlib/secure/permissions.py +70 -0
- kstlib/ssl.py +347 -0
- kstlib/ui/__init__.py +23 -0
- kstlib/ui/exceptions.py +26 -0
- kstlib/ui/panels.py +484 -0
- kstlib/ui/spinner.py +864 -0
- kstlib/ui/tables.py +382 -0
- kstlib/utils/__init__.py +48 -0
- kstlib/utils/dict.py +36 -0
- kstlib/utils/formatting.py +338 -0
- kstlib/utils/http_trace.py +237 -0
- kstlib/utils/lazy.py +49 -0
- kstlib/utils/secure_delete.py +205 -0
- kstlib/utils/serialization.py +247 -0
- kstlib/utils/text.py +56 -0
- kstlib/utils/validators.py +124 -0
- kstlib/websocket/__init__.py +97 -0
- kstlib/websocket/exceptions.py +214 -0
- kstlib/websocket/manager.py +1102 -0
- kstlib/websocket/models.py +361 -0
- kstlib-1.0.1.dist-info/METADATA +201 -0
- kstlib-1.0.1.dist-info/RECORD +163 -0
- {kstlib-0.0.1a0.dist-info → kstlib-1.0.1.dist-info}/WHEEL +1 -1
- kstlib-1.0.1.dist-info/entry_points.txt +2 -0
- kstlib-1.0.1.dist-info/licenses/LICENSE.md +9 -0
- kstlib-0.0.1a0.dist-info/METADATA +0 -29
- kstlib-0.0.1a0.dist-info/RECORD +0 -6
- kstlib-0.0.1a0.dist-info/licenses/LICENSE.md +0 -5
- {kstlib-0.0.1a0.dist-info → kstlib-1.0.1.dist-info}/top_level.txt +0 -0
|
@@ -0,0 +1,440 @@
|
|
|
1
|
+
"""Circuit breaker pattern for fault tolerance."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import functools
|
|
6
|
+
import inspect
|
|
7
|
+
import threading
|
|
8
|
+
import time
|
|
9
|
+
from dataclasses import dataclass
|
|
10
|
+
from enum import Enum, auto
|
|
11
|
+
from typing import TYPE_CHECKING, TypeVar, overload
|
|
12
|
+
|
|
13
|
+
from typing_extensions import ParamSpec
|
|
14
|
+
|
|
15
|
+
from kstlib.limits import (
|
|
16
|
+
HARD_MAX_CIRCUIT_FAILURES,
|
|
17
|
+
HARD_MAX_CIRCUIT_RESET_TIMEOUT,
|
|
18
|
+
HARD_MAX_HALF_OPEN_CALLS,
|
|
19
|
+
HARD_MIN_CIRCUIT_FAILURES,
|
|
20
|
+
HARD_MIN_CIRCUIT_RESET_TIMEOUT,
|
|
21
|
+
HARD_MIN_HALF_OPEN_CALLS,
|
|
22
|
+
clamp_with_limits,
|
|
23
|
+
get_resilience_limits,
|
|
24
|
+
)
|
|
25
|
+
from kstlib.resilience.exceptions import CircuitOpenError
|
|
26
|
+
|
|
27
|
+
if TYPE_CHECKING:
|
|
28
|
+
from collections.abc import Awaitable, Callable
|
|
29
|
+
|
|
30
|
+
P = ParamSpec("P")
|
|
31
|
+
R = TypeVar("R")
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class CircuitState(Enum):
|
|
35
|
+
"""State of the circuit breaker.
|
|
36
|
+
|
|
37
|
+
States:
|
|
38
|
+
CLOSED: Normal operation, requests pass through.
|
|
39
|
+
OPEN: Circuit tripped, requests fail immediately.
|
|
40
|
+
HALF_OPEN: Testing if service recovered.
|
|
41
|
+
"""
|
|
42
|
+
|
|
43
|
+
CLOSED = auto()
|
|
44
|
+
OPEN = auto()
|
|
45
|
+
HALF_OPEN = auto()
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
@dataclass
|
|
49
|
+
class CircuitStats:
|
|
50
|
+
"""Statistics for circuit breaker monitoring.
|
|
51
|
+
|
|
52
|
+
Attributes:
|
|
53
|
+
total_calls: Total number of calls attempted.
|
|
54
|
+
successful_calls: Number of successful calls.
|
|
55
|
+
failed_calls: Number of failed calls.
|
|
56
|
+
rejected_calls: Number of calls rejected due to open circuit.
|
|
57
|
+
state_changes: Number of state transitions.
|
|
58
|
+
|
|
59
|
+
Examples:
|
|
60
|
+
>>> stats = CircuitStats()
|
|
61
|
+
>>> stats.record_success()
|
|
62
|
+
>>> stats.record_failure()
|
|
63
|
+
>>> stats.record_rejection()
|
|
64
|
+
>>> (stats.successful_calls, stats.failed_calls, stats.rejected_calls)
|
|
65
|
+
(1, 1, 1)
|
|
66
|
+
>>> stats.total_calls
|
|
67
|
+
3
|
|
68
|
+
"""
|
|
69
|
+
|
|
70
|
+
total_calls: int = 0
|
|
71
|
+
successful_calls: int = 0
|
|
72
|
+
failed_calls: int = 0
|
|
73
|
+
rejected_calls: int = 0
|
|
74
|
+
state_changes: int = 0
|
|
75
|
+
|
|
76
|
+
def record_success(self) -> None:
|
|
77
|
+
"""Record a successful call."""
|
|
78
|
+
self.total_calls += 1
|
|
79
|
+
self.successful_calls += 1
|
|
80
|
+
|
|
81
|
+
def record_failure(self) -> None:
|
|
82
|
+
"""Record a failed call."""
|
|
83
|
+
self.total_calls += 1
|
|
84
|
+
self.failed_calls += 1
|
|
85
|
+
|
|
86
|
+
def record_rejection(self) -> None:
|
|
87
|
+
"""Record a rejected call (circuit open)."""
|
|
88
|
+
self.total_calls += 1
|
|
89
|
+
self.rejected_calls += 1
|
|
90
|
+
|
|
91
|
+
def record_state_change(self) -> None:
|
|
92
|
+
"""Record a state transition."""
|
|
93
|
+
self.state_changes += 1
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
class CircuitBreaker:
|
|
97
|
+
"""Circuit breaker for protecting against cascading failures.
|
|
98
|
+
|
|
99
|
+
Implements the circuit breaker pattern to prevent repeated calls
|
|
100
|
+
to a failing service and allow recovery time.
|
|
101
|
+
|
|
102
|
+
Args:
|
|
103
|
+
max_failures: Failures before opening circuit (default from config).
|
|
104
|
+
reset_timeout: Seconds before attempting recovery (default from config).
|
|
105
|
+
half_open_max_calls: Calls allowed in half-open state (default from config).
|
|
106
|
+
excluded_exceptions: Exceptions that don't count as failures.
|
|
107
|
+
name: Optional name for the circuit breaker.
|
|
108
|
+
|
|
109
|
+
Examples:
|
|
110
|
+
As a decorator:
|
|
111
|
+
|
|
112
|
+
>>> @circuit_breaker
|
|
113
|
+
... def call_api(): # doctest: +SKIP
|
|
114
|
+
... return requests.get("http://api.example.com")
|
|
115
|
+
|
|
116
|
+
With custom settings:
|
|
117
|
+
|
|
118
|
+
>>> @circuit_breaker(max_failures=3, reset_timeout=30)
|
|
119
|
+
... def risky_call(): # doctest: +SKIP
|
|
120
|
+
... pass
|
|
121
|
+
|
|
122
|
+
Direct instantiation:
|
|
123
|
+
|
|
124
|
+
>>> cb = CircuitBreaker(max_failures=5)
|
|
125
|
+
>>> cb.state
|
|
126
|
+
<CircuitState.CLOSED: 1>
|
|
127
|
+
"""
|
|
128
|
+
|
|
129
|
+
def __init__(
|
|
130
|
+
self,
|
|
131
|
+
*,
|
|
132
|
+
max_failures: int | None = None,
|
|
133
|
+
reset_timeout: float | None = None,
|
|
134
|
+
half_open_max_calls: int | None = None,
|
|
135
|
+
excluded_exceptions: tuple[type[Exception], ...] = (),
|
|
136
|
+
name: str | None = None,
|
|
137
|
+
) -> None:
|
|
138
|
+
"""Initialize circuit breaker.
|
|
139
|
+
|
|
140
|
+
Args:
|
|
141
|
+
max_failures: Failures before opening circuit. Uses config if None.
|
|
142
|
+
reset_timeout: Seconds before attempting recovery. Uses config if None.
|
|
143
|
+
half_open_max_calls: Calls allowed in half-open state. Uses config if None.
|
|
144
|
+
excluded_exceptions: Exceptions that don't count as failures.
|
|
145
|
+
name: Optional name for the circuit breaker.
|
|
146
|
+
"""
|
|
147
|
+
limits = get_resilience_limits()
|
|
148
|
+
|
|
149
|
+
# Max failures (use config default or clamp provided value)
|
|
150
|
+
self._max_failures = (
|
|
151
|
+
limits.circuit_max_failures
|
|
152
|
+
if max_failures is None
|
|
153
|
+
else int(clamp_with_limits(max_failures, HARD_MIN_CIRCUIT_FAILURES, HARD_MAX_CIRCUIT_FAILURES))
|
|
154
|
+
)
|
|
155
|
+
|
|
156
|
+
# Reset timeout (use config default or clamp provided value)
|
|
157
|
+
self._reset_timeout = (
|
|
158
|
+
limits.circuit_reset_timeout
|
|
159
|
+
if reset_timeout is None
|
|
160
|
+
else clamp_with_limits(reset_timeout, HARD_MIN_CIRCUIT_RESET_TIMEOUT, HARD_MAX_CIRCUIT_RESET_TIMEOUT)
|
|
161
|
+
)
|
|
162
|
+
|
|
163
|
+
# Half-open max calls (use config default or clamp provided value)
|
|
164
|
+
self._half_open_max_calls = (
|
|
165
|
+
limits.circuit_half_open_calls
|
|
166
|
+
if half_open_max_calls is None
|
|
167
|
+
else int(clamp_with_limits(half_open_max_calls, HARD_MIN_HALF_OPEN_CALLS, HARD_MAX_HALF_OPEN_CALLS))
|
|
168
|
+
)
|
|
169
|
+
|
|
170
|
+
self._excluded_exceptions = excluded_exceptions
|
|
171
|
+
self._name = name
|
|
172
|
+
|
|
173
|
+
# State
|
|
174
|
+
self._state = CircuitState.CLOSED
|
|
175
|
+
self._failure_count = 0
|
|
176
|
+
self._last_failure_time: float | None = None
|
|
177
|
+
self._half_open_calls = 0
|
|
178
|
+
|
|
179
|
+
# Thread safety
|
|
180
|
+
self._lock = threading.Lock()
|
|
181
|
+
|
|
182
|
+
# Statistics
|
|
183
|
+
self._stats = CircuitStats()
|
|
184
|
+
|
|
185
|
+
@property
|
|
186
|
+
def state(self) -> CircuitState:
|
|
187
|
+
"""Return the current circuit state."""
|
|
188
|
+
with self._lock:
|
|
189
|
+
self._check_state_transition()
|
|
190
|
+
return self._state
|
|
191
|
+
|
|
192
|
+
@property
|
|
193
|
+
def name(self) -> str | None:
|
|
194
|
+
"""Return the circuit breaker name."""
|
|
195
|
+
return self._name
|
|
196
|
+
|
|
197
|
+
@property
|
|
198
|
+
def stats(self) -> CircuitStats:
|
|
199
|
+
"""Return circuit breaker statistics."""
|
|
200
|
+
return self._stats
|
|
201
|
+
|
|
202
|
+
@property
|
|
203
|
+
def failure_count(self) -> int:
|
|
204
|
+
"""Return current failure count."""
|
|
205
|
+
return self._failure_count
|
|
206
|
+
|
|
207
|
+
def _check_state_transition(self) -> None:
|
|
208
|
+
"""Check and perform state transition if needed."""
|
|
209
|
+
if self._state == CircuitState.OPEN and self._last_failure_time is not None:
|
|
210
|
+
elapsed = time.monotonic() - self._last_failure_time
|
|
211
|
+
if elapsed >= self._reset_timeout:
|
|
212
|
+
self._state = CircuitState.HALF_OPEN
|
|
213
|
+
self._half_open_calls = 0
|
|
214
|
+
self._stats.record_state_change()
|
|
215
|
+
|
|
216
|
+
def _record_success(self) -> None:
|
|
217
|
+
"""Record a successful call and update state."""
|
|
218
|
+
with self._lock:
|
|
219
|
+
self._stats.record_success()
|
|
220
|
+
if self._state == CircuitState.HALF_OPEN:
|
|
221
|
+
self._half_open_calls += 1
|
|
222
|
+
if self._half_open_calls >= self._half_open_max_calls:
|
|
223
|
+
# Recovery successful, close circuit
|
|
224
|
+
self._state = CircuitState.CLOSED
|
|
225
|
+
self._failure_count = 0
|
|
226
|
+
self._last_failure_time = None
|
|
227
|
+
self._stats.record_state_change()
|
|
228
|
+
elif self._state == CircuitState.CLOSED:
|
|
229
|
+
# Reset failure count on success
|
|
230
|
+
self._failure_count = 0
|
|
231
|
+
|
|
232
|
+
def _record_failure(self, exc: Exception) -> None:
|
|
233
|
+
"""Record a failed call and update state."""
|
|
234
|
+
# Check if exception is excluded
|
|
235
|
+
if isinstance(exc, self._excluded_exceptions):
|
|
236
|
+
return
|
|
237
|
+
|
|
238
|
+
with self._lock:
|
|
239
|
+
self._stats.record_failure()
|
|
240
|
+
self._failure_count += 1
|
|
241
|
+
self._last_failure_time = time.monotonic()
|
|
242
|
+
|
|
243
|
+
if self._state == CircuitState.HALF_OPEN:
|
|
244
|
+
# Failed during recovery, reopen circuit
|
|
245
|
+
self._state = CircuitState.OPEN
|
|
246
|
+
self._stats.record_state_change()
|
|
247
|
+
elif self._state == CircuitState.CLOSED:
|
|
248
|
+
if self._failure_count >= self._max_failures:
|
|
249
|
+
self._state = CircuitState.OPEN
|
|
250
|
+
self._stats.record_state_change()
|
|
251
|
+
|
|
252
|
+
def _check_open(self) -> None:
|
|
253
|
+
"""Check if circuit is open and raise if so."""
|
|
254
|
+
with self._lock:
|
|
255
|
+
self._check_state_transition()
|
|
256
|
+
if self._state == CircuitState.OPEN:
|
|
257
|
+
remaining = 0.0
|
|
258
|
+
if self._last_failure_time is not None:
|
|
259
|
+
elapsed = time.monotonic() - self._last_failure_time
|
|
260
|
+
remaining = max(0.0, self._reset_timeout - elapsed)
|
|
261
|
+
self._stats.record_rejection()
|
|
262
|
+
raise CircuitOpenError(
|
|
263
|
+
f"Circuit breaker '{self._name or 'unnamed'}' is open",
|
|
264
|
+
remaining_seconds=remaining,
|
|
265
|
+
)
|
|
266
|
+
|
|
267
|
+
def call(self, func: Callable[P, R], *args: P.args, **kwargs: P.kwargs) -> R:
|
|
268
|
+
"""Execute a function through the circuit breaker.
|
|
269
|
+
|
|
270
|
+
Args:
|
|
271
|
+
func: Function to execute.
|
|
272
|
+
*args: Positional arguments for the function.
|
|
273
|
+
**kwargs: Keyword arguments for the function.
|
|
274
|
+
|
|
275
|
+
Returns:
|
|
276
|
+
Function result.
|
|
277
|
+
|
|
278
|
+
Raises:
|
|
279
|
+
CircuitOpenError: If circuit is open.
|
|
280
|
+
|
|
281
|
+
Examples:
|
|
282
|
+
>>> cb = CircuitBreaker()
|
|
283
|
+
>>> result = cb.call(lambda x: x * 2, 5)
|
|
284
|
+
>>> result
|
|
285
|
+
10
|
|
286
|
+
"""
|
|
287
|
+
self._check_open()
|
|
288
|
+
try:
|
|
289
|
+
result = func(*args, **kwargs)
|
|
290
|
+
self._record_success()
|
|
291
|
+
return result
|
|
292
|
+
except Exception as exc:
|
|
293
|
+
self._record_failure(exc)
|
|
294
|
+
raise
|
|
295
|
+
|
|
296
|
+
async def acall(
|
|
297
|
+
self,
|
|
298
|
+
func: Callable[P, Awaitable[R]],
|
|
299
|
+
*args: P.args,
|
|
300
|
+
**kwargs: P.kwargs,
|
|
301
|
+
) -> R:
|
|
302
|
+
"""Execute an async function through the circuit breaker.
|
|
303
|
+
|
|
304
|
+
Args:
|
|
305
|
+
func: Async function to execute.
|
|
306
|
+
*args: Positional arguments for the function.
|
|
307
|
+
**kwargs: Keyword arguments for the function.
|
|
308
|
+
|
|
309
|
+
Returns:
|
|
310
|
+
Function result.
|
|
311
|
+
|
|
312
|
+
Raises:
|
|
313
|
+
CircuitOpenError: If circuit is open.
|
|
314
|
+
|
|
315
|
+
Examples:
|
|
316
|
+
>>> import asyncio
|
|
317
|
+
>>> cb = CircuitBreaker()
|
|
318
|
+
>>> async def double(x): return x * 2
|
|
319
|
+
>>> asyncio.run(cb.acall(double, 5))
|
|
320
|
+
10
|
|
321
|
+
"""
|
|
322
|
+
self._check_open()
|
|
323
|
+
try:
|
|
324
|
+
result = await func(*args, **kwargs)
|
|
325
|
+
self._record_success()
|
|
326
|
+
return result
|
|
327
|
+
except Exception as exc:
|
|
328
|
+
self._record_failure(exc)
|
|
329
|
+
raise
|
|
330
|
+
|
|
331
|
+
def reset(self) -> None:
|
|
332
|
+
"""Manually reset the circuit breaker to closed state.
|
|
333
|
+
|
|
334
|
+
Examples:
|
|
335
|
+
>>> cb = CircuitBreaker(max_failures=1)
|
|
336
|
+
>>> try:
|
|
337
|
+
... cb.call(lambda: 1/0)
|
|
338
|
+
... except ZeroDivisionError:
|
|
339
|
+
... pass
|
|
340
|
+
>>> cb.state.name
|
|
341
|
+
'OPEN'
|
|
342
|
+
>>> cb.reset()
|
|
343
|
+
>>> cb.state.name
|
|
344
|
+
'CLOSED'
|
|
345
|
+
"""
|
|
346
|
+
with self._lock:
|
|
347
|
+
self._state = CircuitState.CLOSED
|
|
348
|
+
self._failure_count = 0
|
|
349
|
+
self._last_failure_time = None
|
|
350
|
+
self._half_open_calls = 0
|
|
351
|
+
|
|
352
|
+
def __call__(self, func: Callable[P, R]) -> Callable[P, R] | Callable[P, Awaitable[R]]:
|
|
353
|
+
"""Use circuit breaker as a decorator.
|
|
354
|
+
|
|
355
|
+
Args:
|
|
356
|
+
func: Function to wrap.
|
|
357
|
+
|
|
358
|
+
Returns:
|
|
359
|
+
Wrapped function with circuit breaker protection.
|
|
360
|
+
"""
|
|
361
|
+
if inspect.iscoroutinefunction(func):
|
|
362
|
+
|
|
363
|
+
@functools.wraps(func)
|
|
364
|
+
async def async_wrapper(*args: P.args, **kwargs: P.kwargs) -> R:
|
|
365
|
+
return await self.acall(func, *args, **kwargs)
|
|
366
|
+
|
|
367
|
+
return async_wrapper
|
|
368
|
+
|
|
369
|
+
@functools.wraps(func)
|
|
370
|
+
def sync_wrapper(*args: P.args, **kwargs: P.kwargs) -> R:
|
|
371
|
+
return self.call(func, *args, **kwargs)
|
|
372
|
+
|
|
373
|
+
return sync_wrapper
|
|
374
|
+
|
|
375
|
+
|
|
376
|
+
# Decorator factory
|
|
377
|
+
@overload
|
|
378
|
+
def circuit_breaker(func: Callable[P, R]) -> Callable[P, R]: ...
|
|
379
|
+
|
|
380
|
+
|
|
381
|
+
@overload
|
|
382
|
+
def circuit_breaker(
|
|
383
|
+
*,
|
|
384
|
+
max_failures: int | None = None,
|
|
385
|
+
reset_timeout: float | None = None,
|
|
386
|
+
half_open_max_calls: int | None = None,
|
|
387
|
+
excluded_exceptions: tuple[type[Exception], ...] = (),
|
|
388
|
+
name: str | None = None,
|
|
389
|
+
) -> Callable[[Callable[P, R]], Callable[P, R]]: ...
|
|
390
|
+
|
|
391
|
+
|
|
392
|
+
def circuit_breaker(
|
|
393
|
+
func: Callable[P, R] | None = None,
|
|
394
|
+
*,
|
|
395
|
+
max_failures: int | None = None,
|
|
396
|
+
reset_timeout: float | None = None,
|
|
397
|
+
half_open_max_calls: int | None = None,
|
|
398
|
+
excluded_exceptions: tuple[type[Exception], ...] = (),
|
|
399
|
+
name: str | None = None,
|
|
400
|
+
) -> Callable[P, R] | Callable[[Callable[P, R]], Callable[P, R]]:
|
|
401
|
+
"""Circuit breaker decorator for functions.
|
|
402
|
+
|
|
403
|
+
Can be used with or without arguments:
|
|
404
|
+
|
|
405
|
+
Examples:
|
|
406
|
+
Without arguments (uses config defaults):
|
|
407
|
+
|
|
408
|
+
>>> @circuit_breaker
|
|
409
|
+
... def api_call(): # doctest: +SKIP
|
|
410
|
+
... pass
|
|
411
|
+
|
|
412
|
+
With arguments:
|
|
413
|
+
|
|
414
|
+
>>> @circuit_breaker(max_failures=3, reset_timeout=30)
|
|
415
|
+
... def api_call(): # doctest: +SKIP
|
|
416
|
+
... pass
|
|
417
|
+
|
|
418
|
+
Exclude specific exceptions:
|
|
419
|
+
|
|
420
|
+
>>> @circuit_breaker(excluded_exceptions=(ValueError,))
|
|
421
|
+
... def validate(): # doctest: +SKIP
|
|
422
|
+
... pass
|
|
423
|
+
"""
|
|
424
|
+
cb = CircuitBreaker(
|
|
425
|
+
max_failures=max_failures,
|
|
426
|
+
reset_timeout=reset_timeout,
|
|
427
|
+
half_open_max_calls=half_open_max_calls,
|
|
428
|
+
excluded_exceptions=excluded_exceptions,
|
|
429
|
+
name=name,
|
|
430
|
+
)
|
|
431
|
+
|
|
432
|
+
if func is not None:
|
|
433
|
+
# @circuit_breaker without parentheses
|
|
434
|
+
return cb(func) # type: ignore[return-value]
|
|
435
|
+
|
|
436
|
+
# @circuit_breaker(...) with arguments
|
|
437
|
+
return cb # type: ignore[return-value]
|
|
438
|
+
|
|
439
|
+
|
|
440
|
+
__all__ = ["CircuitBreaker", "CircuitState", "CircuitStats", "circuit_breaker"]
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
"""Specialized exceptions raised by the kstlib.resilience module."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class HeartbeatError(RuntimeError):
|
|
7
|
+
"""Raised when the heartbeat encounters an error.
|
|
8
|
+
|
|
9
|
+
Examples include state file write failure or invalid state file path.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class ShutdownError(RuntimeError):
|
|
14
|
+
"""Raised when graceful shutdown encounters an error.
|
|
15
|
+
|
|
16
|
+
Examples include cleanup callback failure or timeout exceeded.
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class CircuitBreakerError(RuntimeError):
|
|
21
|
+
"""Base exception for circuit breaker errors."""
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class CircuitOpenError(CircuitBreakerError):
|
|
25
|
+
"""Raised when a call is attempted while the circuit is open.
|
|
26
|
+
|
|
27
|
+
Attributes:
|
|
28
|
+
remaining_seconds: Time until the circuit may transition to half-open.
|
|
29
|
+
"""
|
|
30
|
+
|
|
31
|
+
def __init__(self, message: str, remaining_seconds: float) -> None:
|
|
32
|
+
"""Initialize CircuitOpenError.
|
|
33
|
+
|
|
34
|
+
Args:
|
|
35
|
+
message: Human-readable error message.
|
|
36
|
+
remaining_seconds: Seconds until circuit may transition to half-open.
|
|
37
|
+
"""
|
|
38
|
+
super().__init__(message)
|
|
39
|
+
self.remaining_seconds = remaining_seconds
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
class RateLimitError(RuntimeError):
|
|
43
|
+
"""Base exception for rate limiter errors."""
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
class RateLimitExceededError(RateLimitError):
|
|
47
|
+
"""Raised when rate limit is exceeded and blocking is disabled.
|
|
48
|
+
|
|
49
|
+
Attributes:
|
|
50
|
+
retry_after: Seconds until a token will be available.
|
|
51
|
+
"""
|
|
52
|
+
|
|
53
|
+
def __init__(self, message: str, retry_after: float) -> None:
|
|
54
|
+
"""Initialize RateLimitExceededError.
|
|
55
|
+
|
|
56
|
+
Args:
|
|
57
|
+
message: Human-readable error message.
|
|
58
|
+
retry_after: Seconds until a token will be available.
|
|
59
|
+
"""
|
|
60
|
+
super().__init__(message)
|
|
61
|
+
self.retry_after = retry_after
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
class WatchdogError(RuntimeError):
|
|
65
|
+
"""Base exception for watchdog errors."""
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
class WatchdogTimeoutError(WatchdogError):
|
|
69
|
+
"""Raised when watchdog detects inactivity timeout.
|
|
70
|
+
|
|
71
|
+
Attributes:
|
|
72
|
+
seconds_inactive: Time since last ping/activity.
|
|
73
|
+
"""
|
|
74
|
+
|
|
75
|
+
def __init__(self, message: str, seconds_inactive: float) -> None:
|
|
76
|
+
"""Initialize WatchdogTimeoutError.
|
|
77
|
+
|
|
78
|
+
Args:
|
|
79
|
+
message: Human-readable error message.
|
|
80
|
+
seconds_inactive: Seconds since last activity.
|
|
81
|
+
"""
|
|
82
|
+
super().__init__(message)
|
|
83
|
+
self.seconds_inactive = seconds_inactive
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
__all__ = [
|
|
87
|
+
"CircuitBreakerError",
|
|
88
|
+
"CircuitOpenError",
|
|
89
|
+
"HeartbeatError",
|
|
90
|
+
"RateLimitError",
|
|
91
|
+
"RateLimitExceededError",
|
|
92
|
+
"ShutdownError",
|
|
93
|
+
"WatchdogError",
|
|
94
|
+
"WatchdogTimeoutError",
|
|
95
|
+
]
|