kstlib 0.0.1a0__py3-none-any.whl → 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.
Files changed (166) hide show
  1. kstlib/__init__.py +266 -1
  2. kstlib/__main__.py +16 -0
  3. kstlib/alerts/__init__.py +110 -0
  4. kstlib/alerts/channels/__init__.py +36 -0
  5. kstlib/alerts/channels/base.py +197 -0
  6. kstlib/alerts/channels/email.py +227 -0
  7. kstlib/alerts/channels/slack.py +389 -0
  8. kstlib/alerts/exceptions.py +72 -0
  9. kstlib/alerts/manager.py +651 -0
  10. kstlib/alerts/models.py +142 -0
  11. kstlib/alerts/throttle.py +263 -0
  12. kstlib/auth/__init__.py +139 -0
  13. kstlib/auth/callback.py +399 -0
  14. kstlib/auth/config.py +502 -0
  15. kstlib/auth/errors.py +127 -0
  16. kstlib/auth/models.py +316 -0
  17. kstlib/auth/providers/__init__.py +14 -0
  18. kstlib/auth/providers/base.py +393 -0
  19. kstlib/auth/providers/oauth2.py +645 -0
  20. kstlib/auth/providers/oidc.py +821 -0
  21. kstlib/auth/session.py +338 -0
  22. kstlib/auth/token.py +482 -0
  23. kstlib/cache/__init__.py +50 -0
  24. kstlib/cache/decorator.py +261 -0
  25. kstlib/cache/strategies.py +516 -0
  26. kstlib/cli/__init__.py +8 -0
  27. kstlib/cli/app.py +195 -0
  28. kstlib/cli/commands/__init__.py +5 -0
  29. kstlib/cli/commands/auth/__init__.py +39 -0
  30. kstlib/cli/commands/auth/common.py +122 -0
  31. kstlib/cli/commands/auth/login.py +325 -0
  32. kstlib/cli/commands/auth/logout.py +74 -0
  33. kstlib/cli/commands/auth/providers.py +57 -0
  34. kstlib/cli/commands/auth/status.py +291 -0
  35. kstlib/cli/commands/auth/token.py +199 -0
  36. kstlib/cli/commands/auth/whoami.py +106 -0
  37. kstlib/cli/commands/config.py +89 -0
  38. kstlib/cli/commands/ops/__init__.py +39 -0
  39. kstlib/cli/commands/ops/attach.py +49 -0
  40. kstlib/cli/commands/ops/common.py +269 -0
  41. kstlib/cli/commands/ops/list_sessions.py +252 -0
  42. kstlib/cli/commands/ops/logs.py +49 -0
  43. kstlib/cli/commands/ops/start.py +98 -0
  44. kstlib/cli/commands/ops/status.py +138 -0
  45. kstlib/cli/commands/ops/stop.py +60 -0
  46. kstlib/cli/commands/rapi/__init__.py +60 -0
  47. kstlib/cli/commands/rapi/call.py +341 -0
  48. kstlib/cli/commands/rapi/list.py +99 -0
  49. kstlib/cli/commands/rapi/show.py +206 -0
  50. kstlib/cli/commands/secrets/__init__.py +35 -0
  51. kstlib/cli/commands/secrets/common.py +425 -0
  52. kstlib/cli/commands/secrets/decrypt.py +88 -0
  53. kstlib/cli/commands/secrets/doctor.py +743 -0
  54. kstlib/cli/commands/secrets/encrypt.py +242 -0
  55. kstlib/cli/commands/secrets/shred.py +96 -0
  56. kstlib/cli/common.py +86 -0
  57. kstlib/config/__init__.py +76 -0
  58. kstlib/config/exceptions.py +110 -0
  59. kstlib/config/export.py +225 -0
  60. kstlib/config/loader.py +963 -0
  61. kstlib/config/sops.py +287 -0
  62. kstlib/db/__init__.py +54 -0
  63. kstlib/db/aiosqlcipher.py +137 -0
  64. kstlib/db/cipher.py +112 -0
  65. kstlib/db/database.py +367 -0
  66. kstlib/db/exceptions.py +25 -0
  67. kstlib/db/pool.py +302 -0
  68. kstlib/helpers/__init__.py +35 -0
  69. kstlib/helpers/exceptions.py +11 -0
  70. kstlib/helpers/time_trigger.py +396 -0
  71. kstlib/kstlib.conf.yml +890 -0
  72. kstlib/limits.py +963 -0
  73. kstlib/logging/__init__.py +108 -0
  74. kstlib/logging/manager.py +633 -0
  75. kstlib/mail/__init__.py +42 -0
  76. kstlib/mail/builder.py +626 -0
  77. kstlib/mail/exceptions.py +27 -0
  78. kstlib/mail/filesystem.py +248 -0
  79. kstlib/mail/transport.py +224 -0
  80. kstlib/mail/transports/__init__.py +19 -0
  81. kstlib/mail/transports/gmail.py +268 -0
  82. kstlib/mail/transports/resend.py +324 -0
  83. kstlib/mail/transports/smtp.py +326 -0
  84. kstlib/meta.py +72 -0
  85. kstlib/metrics/__init__.py +88 -0
  86. kstlib/metrics/decorators.py +1090 -0
  87. kstlib/metrics/exceptions.py +14 -0
  88. kstlib/monitoring/__init__.py +116 -0
  89. kstlib/monitoring/_styles.py +163 -0
  90. kstlib/monitoring/cell.py +57 -0
  91. kstlib/monitoring/config.py +424 -0
  92. kstlib/monitoring/delivery.py +579 -0
  93. kstlib/monitoring/exceptions.py +63 -0
  94. kstlib/monitoring/image.py +220 -0
  95. kstlib/monitoring/kv.py +79 -0
  96. kstlib/monitoring/list.py +69 -0
  97. kstlib/monitoring/metric.py +88 -0
  98. kstlib/monitoring/monitoring.py +341 -0
  99. kstlib/monitoring/renderer.py +139 -0
  100. kstlib/monitoring/service.py +392 -0
  101. kstlib/monitoring/table.py +129 -0
  102. kstlib/monitoring/types.py +56 -0
  103. kstlib/ops/__init__.py +86 -0
  104. kstlib/ops/base.py +148 -0
  105. kstlib/ops/container.py +577 -0
  106. kstlib/ops/exceptions.py +209 -0
  107. kstlib/ops/manager.py +407 -0
  108. kstlib/ops/models.py +176 -0
  109. kstlib/ops/tmux.py +372 -0
  110. kstlib/ops/validators.py +287 -0
  111. kstlib/py.typed +0 -0
  112. kstlib/rapi/__init__.py +118 -0
  113. kstlib/rapi/client.py +875 -0
  114. kstlib/rapi/config.py +861 -0
  115. kstlib/rapi/credentials.py +887 -0
  116. kstlib/rapi/exceptions.py +213 -0
  117. kstlib/resilience/__init__.py +101 -0
  118. kstlib/resilience/circuit_breaker.py +440 -0
  119. kstlib/resilience/exceptions.py +95 -0
  120. kstlib/resilience/heartbeat.py +491 -0
  121. kstlib/resilience/rate_limiter.py +506 -0
  122. kstlib/resilience/shutdown.py +417 -0
  123. kstlib/resilience/watchdog.py +637 -0
  124. kstlib/secrets/__init__.py +29 -0
  125. kstlib/secrets/exceptions.py +19 -0
  126. kstlib/secrets/models.py +62 -0
  127. kstlib/secrets/providers/__init__.py +79 -0
  128. kstlib/secrets/providers/base.py +58 -0
  129. kstlib/secrets/providers/environment.py +66 -0
  130. kstlib/secrets/providers/keyring.py +107 -0
  131. kstlib/secrets/providers/kms.py +223 -0
  132. kstlib/secrets/providers/kwargs.py +101 -0
  133. kstlib/secrets/providers/sops.py +209 -0
  134. kstlib/secrets/resolver.py +221 -0
  135. kstlib/secrets/sensitive.py +130 -0
  136. kstlib/secure/__init__.py +23 -0
  137. kstlib/secure/fs.py +194 -0
  138. kstlib/secure/permissions.py +70 -0
  139. kstlib/ssl.py +347 -0
  140. kstlib/ui/__init__.py +23 -0
  141. kstlib/ui/exceptions.py +26 -0
  142. kstlib/ui/panels.py +484 -0
  143. kstlib/ui/spinner.py +864 -0
  144. kstlib/ui/tables.py +382 -0
  145. kstlib/utils/__init__.py +48 -0
  146. kstlib/utils/dict.py +36 -0
  147. kstlib/utils/formatting.py +338 -0
  148. kstlib/utils/http_trace.py +237 -0
  149. kstlib/utils/lazy.py +49 -0
  150. kstlib/utils/secure_delete.py +205 -0
  151. kstlib/utils/serialization.py +247 -0
  152. kstlib/utils/text.py +56 -0
  153. kstlib/utils/validators.py +124 -0
  154. kstlib/websocket/__init__.py +97 -0
  155. kstlib/websocket/exceptions.py +214 -0
  156. kstlib/websocket/manager.py +1102 -0
  157. kstlib/websocket/models.py +361 -0
  158. kstlib-1.0.0.dist-info/METADATA +201 -0
  159. kstlib-1.0.0.dist-info/RECORD +163 -0
  160. {kstlib-0.0.1a0.dist-info → kstlib-1.0.0.dist-info}/WHEEL +1 -1
  161. kstlib-1.0.0.dist-info/entry_points.txt +2 -0
  162. kstlib-1.0.0.dist-info/licenses/LICENSE.md +9 -0
  163. kstlib-0.0.1a0.dist-info/METADATA +0 -29
  164. kstlib-0.0.1a0.dist-info/RECORD +0 -6
  165. kstlib-0.0.1a0.dist-info/licenses/LICENSE.md +0 -5
  166. {kstlib-0.0.1a0.dist-info → kstlib-1.0.0.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
+ ]