agent-killswitch 0.1.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.
@@ -0,0 +1,323 @@
1
+ """Circuit breaker pattern for protecting against cascading failures.
2
+
3
+ Implements the circuit breaker pattern with three states:
4
+ - CLOSED: Normal operation, requests pass through
5
+ - OPEN: Too many failures, requests are rejected immediately
6
+ - HALF_OPEN: Recovery testing, limited requests pass through
7
+
8
+ Thread-safe: All state is protected by a reentrant lock.
9
+ No external dependencies: Uses only Python stdlib.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import contextlib
15
+ import logging
16
+ import threading
17
+ import time
18
+ from collections.abc import Callable
19
+ from typing import Any, TypeVar
20
+
21
+ from .enums import CircuitState
22
+ from .models import CircuitBreakerState
23
+
24
+ logger = logging.getLogger(__name__)
25
+
26
+ T = TypeVar("T")
27
+
28
+
29
+ class CircuitBreakerOpen(Exception):
30
+ """Raised when a call is rejected because the circuit breaker is open.
31
+
32
+ Attributes:
33
+ breaker_name: Name of the circuit breaker that rejected the call.
34
+ time_until_half_open: Seconds until the breaker will try HALF_OPEN.
35
+ """
36
+
37
+ def __init__(self, breaker_name: str, time_until_half_open: float) -> None:
38
+ self.breaker_name = breaker_name
39
+ self.time_until_half_open = time_until_half_open
40
+ super().__init__(
41
+ f"Circuit breaker '{breaker_name}' is OPEN. Try again in {time_until_half_open:.1f}s."
42
+ )
43
+
44
+
45
+ class CircuitBreaker:
46
+ """Circuit breaker for protecting against cascading failures.
47
+
48
+ Wraps function calls and tracks failures. When failures exceed
49
+ the threshold, the breaker opens and rejects calls immediately
50
+ (fail-fast) instead of waiting for timeouts.
51
+
52
+ Example::
53
+
54
+ from agent_killswitch import CircuitBreaker
55
+
56
+ breaker = CircuitBreaker(
57
+ name="openai-api",
58
+ failure_threshold=5,
59
+ recovery_timeout=30.0,
60
+ )
61
+
62
+ try:
63
+ result = breaker.call(my_api_function, arg1, arg2)
64
+ except CircuitBreakerOpen:
65
+ print("API is down, using fallback")
66
+
67
+ Args:
68
+ name: Identifier for this circuit breaker.
69
+ failure_threshold: Consecutive failures to trip the breaker.
70
+ recovery_timeout: Seconds to wait before trying HALF_OPEN.
71
+ half_open_max_calls: Max calls allowed in HALF_OPEN state.
72
+ on_state_change: Callback on state transitions.
73
+ Signature: (name, old_state, new_state) -> None
74
+ on_open: Callback when breaker opens.
75
+ Signature: (name, failure_count) -> None
76
+ excluded_exceptions: Exception types that should NOT count as failures.
77
+ """
78
+
79
+ def __init__(
80
+ self,
81
+ name: str = "default",
82
+ failure_threshold: int = 5,
83
+ recovery_timeout: float = 30.0,
84
+ half_open_max_calls: int = 1,
85
+ on_state_change: Callable[[str, CircuitState, CircuitState], None] | None = None,
86
+ on_open: Callable[[str, int], None] | None = None,
87
+ excluded_exceptions: tuple[type[BaseException], ...] | None = None,
88
+ ) -> None:
89
+ self._state = CircuitBreakerState(
90
+ name=name,
91
+ failure_threshold=failure_threshold,
92
+ recovery_timeout=recovery_timeout,
93
+ half_open_max_calls=half_open_max_calls,
94
+ last_state_change=time.monotonic(),
95
+ )
96
+ self._on_state_change = on_state_change
97
+ self._on_open = on_open
98
+ self._excluded = excluded_exceptions or ()
99
+ self._lock = threading.RLock()
100
+ self._half_open_calls = 0
101
+
102
+ @property
103
+ def name(self) -> str:
104
+ """Name of this circuit breaker."""
105
+ return self._state.name
106
+
107
+ def get_state(self) -> CircuitState:
108
+ """Get the current circuit breaker state.
109
+
110
+ Returns:
111
+ Current CircuitState.
112
+ """
113
+ with self._lock:
114
+ self._maybe_transition_to_half_open()
115
+ return self._state.state
116
+
117
+ def _maybe_transition_to_half_open(self) -> None:
118
+ """Check if it's time to transition from OPEN to HALF_OPEN."""
119
+ if self._state.state != CircuitState.OPEN:
120
+ return
121
+
122
+ elapsed = time.monotonic() - self._state.last_state_change
123
+ if elapsed >= self._state.recovery_timeout:
124
+ self._transition(CircuitState.HALF_OPEN)
125
+
126
+ def _transition(self, new_state: CircuitState) -> None:
127
+ """Transition to a new state and fire callbacks."""
128
+ old_state = self._state.state
129
+ if old_state == new_state:
130
+ return
131
+
132
+ self._state.state = new_state
133
+ self._state.last_state_change = time.monotonic()
134
+
135
+ if new_state == CircuitState.CLOSED:
136
+ self._state.failure_count = 0
137
+ self._state.success_count = 0
138
+ self._half_open_calls = 0
139
+ elif new_state == CircuitState.HALF_OPEN:
140
+ self._state.success_count = 0
141
+ self._half_open_calls = 0
142
+
143
+ logger.info(
144
+ "circuit_breaker_state_change",
145
+ extra={
146
+ "name": self._state.name,
147
+ "old_state": old_state.value,
148
+ "new_state": new_state.value,
149
+ },
150
+ )
151
+
152
+ if self._on_state_change is not None:
153
+ try:
154
+ self._on_state_change(self._state.name, old_state, new_state)
155
+ except Exception as exc:
156
+ logger.error(
157
+ "on_state_change callback error",
158
+ extra={"error": str(exc)},
159
+ )
160
+
161
+ def _record_failure(self) -> None:
162
+ """Record a failure and possibly open the circuit."""
163
+ self._state.failure_count += 1
164
+ self._state.last_failure_time = time.monotonic()
165
+ self._state.success_count = 0
166
+
167
+ if self._state.state == CircuitState.HALF_OPEN:
168
+ # Any failure in HALF_OPEN immediately reopens
169
+ self._transition(CircuitState.OPEN)
170
+ if self._on_open is not None:
171
+ with contextlib.suppress(Exception):
172
+ self._on_open(self._state.name, self._state.failure_count)
173
+ elif self._state.failure_count >= self._state.failure_threshold:
174
+ self._transition(CircuitState.OPEN)
175
+ if self._on_open is not None:
176
+ with contextlib.suppress(Exception):
177
+ self._on_open(self._state.name, self._state.failure_count)
178
+
179
+ def _record_success(self) -> None:
180
+ """Record a success and possibly close the circuit."""
181
+ self._state.success_count += 1
182
+
183
+ if self._state.state == CircuitState.HALF_OPEN:
184
+ if self._state.success_count >= self._state.half_open_max_calls:
185
+ self._transition(CircuitState.CLOSED)
186
+ elif self._state.state == CircuitState.CLOSED:
187
+ # Reset failure count on success
188
+ self._state.failure_count = 0
189
+
190
+ def call(self, func: Callable[..., T], *args: Any, **kwargs: Any) -> T:
191
+ """Execute a function with circuit breaker protection.
192
+
193
+ If the circuit is OPEN, raises CircuitBreakerOpen immediately.
194
+ If the circuit is CLOSED or HALF_OPEN, executes the function
195
+ and records the result.
196
+
197
+ Args:
198
+ func: The function to execute.
199
+ *args: Positional arguments for the function.
200
+ **kwargs: Keyword arguments for the function.
201
+
202
+ Returns:
203
+ The return value of the function.
204
+
205
+ Raises:
206
+ CircuitBreakerOpen: If the circuit is OPEN.
207
+ Exception: Any exception raised by the function.
208
+ """
209
+ with self._lock:
210
+ self._maybe_transition_to_half_open()
211
+ state = self._state.state
212
+
213
+ if state == CircuitState.OPEN:
214
+ time_remaining = max(
215
+ 0.0,
216
+ self._state.recovery_timeout
217
+ - (time.monotonic() - self._state.last_state_change),
218
+ )
219
+ raise CircuitBreakerOpen(self._state.name, time_remaining)
220
+
221
+ if state == CircuitState.HALF_OPEN:
222
+ if self._half_open_calls >= self._state.half_open_max_calls:
223
+ time_remaining = max(
224
+ 0.0,
225
+ self._state.recovery_timeout
226
+ - (time.monotonic() - self._state.last_state_change),
227
+ )
228
+ raise CircuitBreakerOpen(self._state.name, time_remaining)
229
+ self._half_open_calls += 1
230
+
231
+ try:
232
+ result = func(*args, **kwargs)
233
+ except BaseException as exc:
234
+ if isinstance(exc, self._excluded):
235
+ raise
236
+ with self._lock:
237
+ self._record_failure()
238
+ raise
239
+ else:
240
+ with self._lock:
241
+ self._record_success()
242
+ return result
243
+
244
+ async def async_call(self, func: Callable[..., Any], *args: Any, **kwargs: Any) -> Any:
245
+ """Execute an async function with circuit breaker protection.
246
+
247
+ Same semantics as call() but for async functions.
248
+
249
+ Args:
250
+ func: The async function to execute.
251
+ *args: Positional arguments.
252
+ **kwargs: Keyword arguments.
253
+
254
+ Returns:
255
+ The return value of the async function.
256
+
257
+ Raises:
258
+ CircuitBreakerOpen: If the circuit is OPEN.
259
+ """
260
+ with self._lock:
261
+ self._maybe_transition_to_half_open()
262
+ state = self._state.state
263
+
264
+ if state == CircuitState.OPEN:
265
+ time_remaining = max(
266
+ 0.0,
267
+ self._state.recovery_timeout
268
+ - (time.monotonic() - self._state.last_state_change),
269
+ )
270
+ raise CircuitBreakerOpen(self._state.name, time_remaining)
271
+
272
+ if state == CircuitState.HALF_OPEN:
273
+ if self._half_open_calls >= self._state.half_open_max_calls:
274
+ time_remaining = max(
275
+ 0.0,
276
+ self._state.recovery_timeout
277
+ - (time.monotonic() - self._state.last_state_change),
278
+ )
279
+ raise CircuitBreakerOpen(self._state.name, time_remaining)
280
+ self._half_open_calls += 1
281
+
282
+ try:
283
+ result = await func(*args, **kwargs)
284
+ except BaseException as exc:
285
+ if isinstance(exc, self._excluded):
286
+ raise
287
+ with self._lock:
288
+ self._record_failure()
289
+ raise
290
+ else:
291
+ with self._lock:
292
+ self._record_success()
293
+ return result
294
+
295
+ def reset(self) -> None:
296
+ """Manually reset the circuit breaker to CLOSED state."""
297
+ with self._lock:
298
+ self._transition(CircuitState.CLOSED)
299
+
300
+ def trip(self) -> None:
301
+ """Manually trip (open) the circuit breaker."""
302
+ with self._lock:
303
+ self._transition(CircuitState.OPEN)
304
+
305
+ @property
306
+ def failure_count(self) -> int:
307
+ """Current consecutive failure count."""
308
+ with self._lock:
309
+ return self._state.failure_count
310
+
311
+ @property
312
+ def state_info(self) -> dict[str, Any]:
313
+ """Full state information for debugging."""
314
+ with self._lock:
315
+ self._maybe_transition_to_half_open()
316
+ return {
317
+ "name": self._state.name,
318
+ "state": self._state.state.value,
319
+ "failure_count": self._state.failure_count,
320
+ "success_count": self._state.success_count,
321
+ "failure_threshold": self._state.failure_threshold,
322
+ "recovery_timeout": self._state.recovery_timeout,
323
+ }
@@ -0,0 +1,112 @@
1
+ """Enumerations for agent-killswitch.
2
+
3
+ Defines the core enums used throughout the library:
4
+ - KillLevel: Tiered severity (PAUSE/STOP/KILL)
5
+ - KillScope: Breadth of impact (GLOBAL/CREW/AGENT/TASK)
6
+ - HeartbeatStatus: Agent health states
7
+ - CircuitState: Circuit breaker states
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ from enum import Enum
13
+
14
+
15
+ class KillLevel(str, Enum):
16
+ """Kill switch activation tiers with latency guarantees.
17
+
18
+ Each tier escalates the severity of the kill switch:
19
+ - PAUSE: Graceful -- stop new tasks, let in-flight complete (<200ms)
20
+ - STOP: Forceful -- checkpoint in-flight, pause all (<300ms)
21
+ - KILL: Immediate -- terminate everything (<500ms)
22
+
23
+ Numeric ordering: PAUSE < STOP < KILL (higher = more severe).
24
+ """
25
+
26
+ PAUSE = "pause"
27
+ STOP = "stop"
28
+ KILL = "kill"
29
+
30
+ @property
31
+ def severity(self) -> int:
32
+ """Return numeric severity for comparison.
33
+
34
+ Returns:
35
+ Integer severity: PAUSE=1, STOP=2, KILL=3.
36
+ """
37
+ return _SEVERITY_MAP[self]
38
+
39
+ def __gt__(self, other: object) -> bool:
40
+ if not isinstance(other, KillLevel):
41
+ return NotImplemented
42
+ return self.severity > other.severity
43
+
44
+ def __ge__(self, other: object) -> bool:
45
+ if not isinstance(other, KillLevel):
46
+ return NotImplemented
47
+ return self.severity >= other.severity
48
+
49
+ def __lt__(self, other: object) -> bool:
50
+ if not isinstance(other, KillLevel):
51
+ return NotImplemented
52
+ return self.severity < other.severity
53
+
54
+ def __le__(self, other: object) -> bool:
55
+ if not isinstance(other, KillLevel):
56
+ return NotImplemented
57
+ return self.severity <= other.severity
58
+
59
+
60
+ _SEVERITY_MAP: dict[KillLevel, int] = {
61
+ KillLevel.PAUSE: 1,
62
+ KillLevel.STOP: 2,
63
+ KillLevel.KILL: 3,
64
+ }
65
+
66
+ # Latency guarantees per level (milliseconds)
67
+ LEVEL_LATENCY_GUARANTEE_MS: dict[KillLevel, float] = {
68
+ KillLevel.PAUSE: 200.0,
69
+ KillLevel.STOP: 300.0,
70
+ KillLevel.KILL: 500.0,
71
+ }
72
+
73
+
74
+ class KillScope(str, Enum):
75
+ """Kill switch scope -- how broadly the kill switch applies.
76
+
77
+ Priority order for checking: GLOBAL > CREW > AGENT > TASK.
78
+ A global KILL overrides a crew-level PAUSE.
79
+ """
80
+
81
+ GLOBAL = "global"
82
+ CREW = "crew"
83
+ AGENT = "agent"
84
+ TASK = "task"
85
+
86
+
87
+ class HeartbeatStatus(str, Enum):
88
+ """Health status of an agent based on heartbeat monitoring.
89
+
90
+ - ALIVE: Heartbeat received within expected interval
91
+ - SUSPECT: One or more missed heartbeats, not yet dead
92
+ - DEAD: Exceeded miss threshold, considered unresponsive
93
+ - UNKNOWN: No heartbeat data available
94
+ """
95
+
96
+ ALIVE = "alive"
97
+ SUSPECT = "suspect"
98
+ DEAD = "dead"
99
+ UNKNOWN = "unknown"
100
+
101
+
102
+ class CircuitState(str, Enum):
103
+ """Circuit breaker states.
104
+
105
+ - CLOSED: Normal operation, requests pass through
106
+ - OPEN: Failing, requests are rejected immediately
107
+ - HALF_OPEN: Testing recovery, limited requests pass through
108
+ """
109
+
110
+ CLOSED = "closed"
111
+ OPEN = "open"
112
+ HALF_OPEN = "half_open"