code-puppy 0.0.134__py3-none-any.whl → 0.0.136__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.
- code_puppy/agent.py +15 -17
- code_puppy/agents/agent_manager.py +320 -9
- code_puppy/agents/base_agent.py +58 -2
- code_puppy/agents/runtime_manager.py +68 -42
- code_puppy/command_line/command_handler.py +82 -33
- code_puppy/command_line/mcp/__init__.py +10 -0
- code_puppy/command_line/mcp/add_command.py +183 -0
- code_puppy/command_line/mcp/base.py +35 -0
- code_puppy/command_line/mcp/handler.py +133 -0
- code_puppy/command_line/mcp/help_command.py +146 -0
- code_puppy/command_line/mcp/install_command.py +176 -0
- code_puppy/command_line/mcp/list_command.py +94 -0
- code_puppy/command_line/mcp/logs_command.py +126 -0
- code_puppy/command_line/mcp/remove_command.py +82 -0
- code_puppy/command_line/mcp/restart_command.py +92 -0
- code_puppy/command_line/mcp/search_command.py +117 -0
- code_puppy/command_line/mcp/start_all_command.py +126 -0
- code_puppy/command_line/mcp/start_command.py +98 -0
- code_puppy/command_line/mcp/status_command.py +185 -0
- code_puppy/command_line/mcp/stop_all_command.py +109 -0
- code_puppy/command_line/mcp/stop_command.py +79 -0
- code_puppy/command_line/mcp/test_command.py +107 -0
- code_puppy/command_line/mcp/utils.py +129 -0
- code_puppy/command_line/mcp/wizard_utils.py +259 -0
- code_puppy/command_line/model_picker_completion.py +21 -4
- code_puppy/command_line/prompt_toolkit_completion.py +9 -0
- code_puppy/main.py +23 -17
- code_puppy/mcp/__init__.py +42 -16
- code_puppy/mcp/async_lifecycle.py +51 -49
- code_puppy/mcp/blocking_startup.py +125 -113
- code_puppy/mcp/captured_stdio_server.py +63 -70
- code_puppy/mcp/circuit_breaker.py +63 -47
- code_puppy/mcp/config_wizard.py +169 -136
- code_puppy/mcp/dashboard.py +79 -71
- code_puppy/mcp/error_isolation.py +147 -100
- code_puppy/mcp/examples/retry_example.py +55 -42
- code_puppy/mcp/health_monitor.py +152 -141
- code_puppy/mcp/managed_server.py +100 -97
- code_puppy/mcp/manager.py +168 -156
- code_puppy/mcp/registry.py +148 -110
- code_puppy/mcp/retry_manager.py +63 -61
- code_puppy/mcp/server_registry_catalog.py +271 -225
- code_puppy/mcp/status_tracker.py +80 -80
- code_puppy/mcp/system_tools.py +47 -52
- code_puppy/messaging/message_queue.py +20 -13
- code_puppy/messaging/renderers.py +30 -15
- code_puppy/state_management.py +103 -0
- code_puppy/tui/app.py +64 -7
- code_puppy/tui/components/chat_view.py +3 -3
- code_puppy/tui/components/human_input_modal.py +12 -8
- code_puppy/tui/screens/__init__.py +2 -2
- code_puppy/tui/screens/mcp_install_wizard.py +208 -179
- code_puppy/tui/tests/test_agent_command.py +3 -3
- {code_puppy-0.0.134.dist-info → code_puppy-0.0.136.dist-info}/METADATA +1 -1
- {code_puppy-0.0.134.dist-info → code_puppy-0.0.136.dist-info}/RECORD +59 -41
- code_puppy/command_line/mcp_commands.py +0 -1789
- {code_puppy-0.0.134.data → code_puppy-0.0.136.data}/data/code_puppy/models.json +0 -0
- {code_puppy-0.0.134.dist-info → code_puppy-0.0.136.dist-info}/WHEEL +0 -0
- {code_puppy-0.0.134.dist-info → code_puppy-0.0.136.dist-info}/entry_points.txt +0 -0
- {code_puppy-0.0.134.dist-info → code_puppy-0.0.136.dist-info}/licenses/LICENSE +0 -0
|
@@ -6,20 +6,15 @@ stderr output and makes it available through proper logging channels.
|
|
|
6
6
|
"""
|
|
7
7
|
|
|
8
8
|
import asyncio
|
|
9
|
-
import io
|
|
10
9
|
import logging
|
|
11
10
|
import os
|
|
12
|
-
import sys
|
|
13
|
-
import tempfile
|
|
14
11
|
from contextlib import asynccontextmanager
|
|
15
|
-
from typing import AsyncIterator,
|
|
16
|
-
from threading import Thread
|
|
17
|
-
from queue import Queue, Empty
|
|
12
|
+
from typing import AsyncIterator, Optional, Sequence
|
|
18
13
|
|
|
19
|
-
from pydantic_ai.mcp import MCPServerStdio
|
|
20
|
-
from mcp.client.stdio import StdioServerParameters, stdio_client
|
|
21
14
|
from anyio.streams.memory import MemoryObjectReceiveStream, MemoryObjectSendStream
|
|
15
|
+
from mcp.client.stdio import StdioServerParameters, stdio_client
|
|
22
16
|
from mcp.shared.session import SessionMessage
|
|
17
|
+
from pydantic_ai.mcp import MCPServerStdio
|
|
23
18
|
|
|
24
19
|
logger = logging.getLogger(__name__)
|
|
25
20
|
|
|
@@ -28,11 +23,11 @@ class StderrCapture:
|
|
|
28
23
|
"""
|
|
29
24
|
Captures stderr output using a pipe and background reader.
|
|
30
25
|
"""
|
|
31
|
-
|
|
26
|
+
|
|
32
27
|
def __init__(self, name: str, handler: Optional[callable] = None):
|
|
33
28
|
"""
|
|
34
29
|
Initialize stderr capture.
|
|
35
|
-
|
|
30
|
+
|
|
36
31
|
Args:
|
|
37
32
|
name: Name for this capture stream
|
|
38
33
|
handler: Optional function to call with captured lines
|
|
@@ -43,75 +38,75 @@ class StderrCapture:
|
|
|
43
38
|
self._reader_task = None
|
|
44
39
|
self._pipe_r = None
|
|
45
40
|
self._pipe_w = None
|
|
46
|
-
|
|
41
|
+
|
|
47
42
|
def _default_handler(self, line: str):
|
|
48
43
|
"""Default handler that logs to Python logging."""
|
|
49
44
|
if line.strip():
|
|
50
45
|
logger.debug(f"[MCP {self.name}] {line.rstrip()}")
|
|
51
|
-
|
|
46
|
+
|
|
52
47
|
async def start_capture(self):
|
|
53
48
|
"""Start capturing stderr by creating a pipe and reader task."""
|
|
54
49
|
# Create a pipe for capturing stderr
|
|
55
50
|
self._pipe_r, self._pipe_w = os.pipe()
|
|
56
|
-
|
|
51
|
+
|
|
57
52
|
# Make the read end non-blocking
|
|
58
53
|
os.set_blocking(self._pipe_r, False)
|
|
59
|
-
|
|
54
|
+
|
|
60
55
|
# Start background task to read from pipe
|
|
61
56
|
self._reader_task = asyncio.create_task(self._read_pipe())
|
|
62
|
-
|
|
57
|
+
|
|
63
58
|
# Return the write end as the file descriptor for stderr
|
|
64
59
|
return self._pipe_w
|
|
65
|
-
|
|
60
|
+
|
|
66
61
|
async def _read_pipe(self):
|
|
67
62
|
"""Background task to read from the pipe."""
|
|
68
63
|
loop = asyncio.get_event_loop()
|
|
69
|
-
buffer = b
|
|
70
|
-
|
|
64
|
+
buffer = b""
|
|
65
|
+
|
|
71
66
|
try:
|
|
72
67
|
while True:
|
|
73
68
|
# Use asyncio's add_reader for efficient async reading
|
|
74
69
|
future = asyncio.Future()
|
|
75
|
-
|
|
70
|
+
|
|
76
71
|
def read_callback():
|
|
77
72
|
try:
|
|
78
73
|
data = os.read(self._pipe_r, 4096)
|
|
79
74
|
future.set_result(data)
|
|
80
75
|
except BlockingIOError:
|
|
81
|
-
future.set_result(b
|
|
76
|
+
future.set_result(b"")
|
|
82
77
|
except Exception as e:
|
|
83
78
|
future.set_exception(e)
|
|
84
|
-
|
|
79
|
+
|
|
85
80
|
loop.add_reader(self._pipe_r, read_callback)
|
|
86
81
|
try:
|
|
87
82
|
data = await future
|
|
88
83
|
finally:
|
|
89
84
|
loop.remove_reader(self._pipe_r)
|
|
90
|
-
|
|
85
|
+
|
|
91
86
|
if not data:
|
|
92
87
|
await asyncio.sleep(0.1)
|
|
93
88
|
continue
|
|
94
|
-
|
|
89
|
+
|
|
95
90
|
# Process the data
|
|
96
91
|
buffer += data
|
|
97
|
-
|
|
92
|
+
|
|
98
93
|
# Look for complete lines
|
|
99
|
-
while b
|
|
100
|
-
line, buffer = buffer.split(b
|
|
101
|
-
line_str = line.decode(
|
|
94
|
+
while b"\n" in buffer:
|
|
95
|
+
line, buffer = buffer.split(b"\n", 1)
|
|
96
|
+
line_str = line.decode("utf-8", errors="replace")
|
|
102
97
|
if line_str:
|
|
103
98
|
self._captured_lines.append(line_str)
|
|
104
99
|
self.handler(line_str)
|
|
105
|
-
|
|
100
|
+
|
|
106
101
|
except asyncio.CancelledError:
|
|
107
102
|
# Process any remaining buffer
|
|
108
103
|
if buffer:
|
|
109
|
-
line_str = buffer.decode(
|
|
104
|
+
line_str = buffer.decode("utf-8", errors="replace")
|
|
110
105
|
if line_str:
|
|
111
106
|
self._captured_lines.append(line_str)
|
|
112
107
|
self.handler(line_str)
|
|
113
108
|
raise
|
|
114
|
-
|
|
109
|
+
|
|
115
110
|
async def stop_capture(self):
|
|
116
111
|
"""Stop capturing and clean up."""
|
|
117
112
|
if self._reader_task:
|
|
@@ -120,12 +115,12 @@ class StderrCapture:
|
|
|
120
115
|
await self._reader_task
|
|
121
116
|
except asyncio.CancelledError:
|
|
122
117
|
pass
|
|
123
|
-
|
|
118
|
+
|
|
124
119
|
if self._pipe_r is not None:
|
|
125
120
|
os.close(self._pipe_r)
|
|
126
121
|
if self._pipe_w is not None:
|
|
127
122
|
os.close(self._pipe_w)
|
|
128
|
-
|
|
123
|
+
|
|
129
124
|
def get_captured_lines(self) -> list[str]:
|
|
130
125
|
"""Get all captured lines."""
|
|
131
126
|
return self._captured_lines.copy()
|
|
@@ -134,11 +129,11 @@ class StderrCapture:
|
|
|
134
129
|
class CapturedMCPServerStdio(MCPServerStdio):
|
|
135
130
|
"""
|
|
136
131
|
Extended MCPServerStdio that captures and handles stderr output.
|
|
137
|
-
|
|
132
|
+
|
|
138
133
|
This class captures stderr from the subprocess and makes it available
|
|
139
134
|
through proper logging channels instead of letting it pollute the console.
|
|
140
135
|
"""
|
|
141
|
-
|
|
136
|
+
|
|
142
137
|
def __init__(
|
|
143
138
|
self,
|
|
144
139
|
command: str,
|
|
@@ -146,11 +141,11 @@ class CapturedMCPServerStdio(MCPServerStdio):
|
|
|
146
141
|
env: dict[str, str] | None = None,
|
|
147
142
|
cwd: str | None = None,
|
|
148
143
|
stderr_handler: Optional[callable] = None,
|
|
149
|
-
**kwargs
|
|
144
|
+
**kwargs,
|
|
150
145
|
):
|
|
151
146
|
"""
|
|
152
147
|
Initialize captured stdio server.
|
|
153
|
-
|
|
148
|
+
|
|
154
149
|
Args:
|
|
155
150
|
command: The command to run
|
|
156
151
|
args: Arguments for the command
|
|
@@ -163,7 +158,7 @@ class CapturedMCPServerStdio(MCPServerStdio):
|
|
|
163
158
|
self.stderr_handler = stderr_handler
|
|
164
159
|
self._stderr_capture = None
|
|
165
160
|
self._captured_lines = []
|
|
166
|
-
|
|
161
|
+
|
|
167
162
|
@asynccontextmanager
|
|
168
163
|
async def client_streams(
|
|
169
164
|
self,
|
|
@@ -175,40 +170,40 @@ class CapturedMCPServerStdio(MCPServerStdio):
|
|
|
175
170
|
]:
|
|
176
171
|
"""Create the streams for the MCP server with stderr capture."""
|
|
177
172
|
server = StdioServerParameters(
|
|
178
|
-
command=self.command,
|
|
179
|
-
args=list(self.args),
|
|
180
|
-
env=self.env,
|
|
181
|
-
cwd=self.cwd
|
|
173
|
+
command=self.command, args=list(self.args), env=self.env, cwd=self.cwd
|
|
182
174
|
)
|
|
183
|
-
|
|
175
|
+
|
|
184
176
|
# Create stderr capture
|
|
185
177
|
def stderr_line_handler(line: str):
|
|
186
178
|
"""Handle captured stderr lines."""
|
|
187
179
|
self._captured_lines.append(line)
|
|
188
|
-
|
|
180
|
+
|
|
189
181
|
if self.stderr_handler:
|
|
190
182
|
self.stderr_handler(line)
|
|
191
183
|
else:
|
|
192
184
|
# Default: log at DEBUG level to avoid console spam
|
|
193
185
|
logger.debug(f"[MCP Server {self.command}] {line}")
|
|
194
|
-
|
|
186
|
+
|
|
195
187
|
self._stderr_capture = StderrCapture(self.command, stderr_line_handler)
|
|
196
|
-
|
|
188
|
+
|
|
197
189
|
# For now, use devnull for stderr to suppress output
|
|
198
190
|
# We'll capture it through other means if needed
|
|
199
|
-
with open(os.devnull,
|
|
200
|
-
async with stdio_client(server=server, errlog=devnull) as (
|
|
191
|
+
with open(os.devnull, "w") as devnull:
|
|
192
|
+
async with stdio_client(server=server, errlog=devnull) as (
|
|
193
|
+
read_stream,
|
|
194
|
+
write_stream,
|
|
195
|
+
):
|
|
201
196
|
yield read_stream, write_stream
|
|
202
|
-
|
|
197
|
+
|
|
203
198
|
def get_captured_stderr(self) -> list[str]:
|
|
204
199
|
"""
|
|
205
200
|
Get all captured stderr lines.
|
|
206
|
-
|
|
201
|
+
|
|
207
202
|
Returns:
|
|
208
203
|
List of captured stderr lines
|
|
209
204
|
"""
|
|
210
205
|
return self._captured_lines.copy()
|
|
211
|
-
|
|
206
|
+
|
|
212
207
|
def clear_captured_stderr(self):
|
|
213
208
|
"""Clear the captured stderr buffer."""
|
|
214
209
|
self._captured_lines.clear()
|
|
@@ -217,56 +212,55 @@ class CapturedMCPServerStdio(MCPServerStdio):
|
|
|
217
212
|
class StderrCollector:
|
|
218
213
|
"""
|
|
219
214
|
A centralized collector for stderr from multiple MCP servers.
|
|
220
|
-
|
|
215
|
+
|
|
221
216
|
This can be used to aggregate stderr from all MCP servers in one place.
|
|
222
217
|
"""
|
|
223
|
-
|
|
218
|
+
|
|
224
219
|
def __init__(self):
|
|
225
220
|
"""Initialize the collector."""
|
|
226
221
|
self.servers = {}
|
|
227
222
|
self.all_lines = []
|
|
228
|
-
|
|
223
|
+
|
|
229
224
|
def create_handler(self, server_name: str, emit_to_user: bool = False):
|
|
230
225
|
"""
|
|
231
226
|
Create a handler function for a specific server.
|
|
232
|
-
|
|
227
|
+
|
|
233
228
|
Args:
|
|
234
229
|
server_name: Name to identify this server
|
|
235
230
|
emit_to_user: If True, emit stderr lines to user via emit_info
|
|
236
|
-
|
|
231
|
+
|
|
237
232
|
Returns:
|
|
238
233
|
Handler function that can be passed to CapturedMCPServerStdio
|
|
239
234
|
"""
|
|
235
|
+
|
|
240
236
|
def handler(line: str):
|
|
241
237
|
# Store with server identification
|
|
242
238
|
import time
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
'timestamp': time.time()
|
|
247
|
-
}
|
|
248
|
-
|
|
239
|
+
|
|
240
|
+
entry = {"server": server_name, "line": line, "timestamp": time.time()}
|
|
241
|
+
|
|
249
242
|
if server_name not in self.servers:
|
|
250
243
|
self.servers[server_name] = []
|
|
251
|
-
|
|
244
|
+
|
|
252
245
|
self.servers[server_name].append(line)
|
|
253
246
|
self.all_lines.append(entry)
|
|
254
|
-
|
|
247
|
+
|
|
255
248
|
# Emit to user if requested
|
|
256
249
|
if emit_to_user:
|
|
257
250
|
from code_puppy.messaging import emit_info
|
|
251
|
+
|
|
258
252
|
emit_info(f"[MCP {server_name}] {line}", style="dim cyan")
|
|
259
|
-
|
|
253
|
+
|
|
260
254
|
return handler
|
|
261
|
-
|
|
255
|
+
|
|
262
256
|
def get_server_output(self, server_name: str) -> list[str]:
|
|
263
257
|
"""Get all output from a specific server."""
|
|
264
258
|
return self.servers.get(server_name, []).copy()
|
|
265
|
-
|
|
259
|
+
|
|
266
260
|
def get_all_output(self) -> list[dict]:
|
|
267
261
|
"""Get all output from all servers with metadata."""
|
|
268
262
|
return self.all_lines.copy()
|
|
269
|
-
|
|
263
|
+
|
|
270
264
|
def clear(self, server_name: Optional[str] = None):
|
|
271
265
|
"""Clear captured output."""
|
|
272
266
|
if server_name:
|
|
@@ -274,9 +268,8 @@ class StderrCollector:
|
|
|
274
268
|
self.servers[server_name].clear()
|
|
275
269
|
# Also clear from all_lines
|
|
276
270
|
self.all_lines = [
|
|
277
|
-
entry for entry in self.all_lines
|
|
278
|
-
if entry['server'] != server_name
|
|
271
|
+
entry for entry in self.all_lines if entry["server"] != server_name
|
|
279
272
|
]
|
|
280
273
|
else:
|
|
281
274
|
self.servers.clear()
|
|
282
|
-
self.all_lines.clear()
|
|
275
|
+
self.all_lines.clear()
|
|
@@ -9,129 +9,137 @@ failures when MCP servers become unhealthy. The circuit breaker has three states
|
|
|
9
9
|
"""
|
|
10
10
|
|
|
11
11
|
import asyncio
|
|
12
|
+
import logging
|
|
12
13
|
import time
|
|
13
14
|
from enum import Enum
|
|
14
15
|
from typing import Any, Callable
|
|
15
|
-
import logging
|
|
16
16
|
|
|
17
17
|
logger = logging.getLogger(__name__)
|
|
18
18
|
|
|
19
19
|
|
|
20
20
|
class CircuitState(Enum):
|
|
21
21
|
"""Circuit breaker states."""
|
|
22
|
-
|
|
23
|
-
|
|
22
|
+
|
|
23
|
+
CLOSED = "closed" # Normal operation
|
|
24
|
+
OPEN = "open" # Blocking calls
|
|
24
25
|
HALF_OPEN = "half_open" # Testing recovery
|
|
25
26
|
|
|
26
27
|
|
|
27
28
|
class CircuitOpenError(Exception):
|
|
28
29
|
"""Raised when circuit breaker is in OPEN state."""
|
|
30
|
+
|
|
29
31
|
pass
|
|
30
32
|
|
|
31
33
|
|
|
32
34
|
class CircuitBreaker:
|
|
33
35
|
"""
|
|
34
36
|
Circuit breaker to prevent cascading failures in MCP servers.
|
|
35
|
-
|
|
37
|
+
|
|
36
38
|
The circuit breaker monitors the success/failure rate of operations and
|
|
37
39
|
transitions between states to protect the system from unhealthy dependencies.
|
|
38
|
-
|
|
40
|
+
|
|
39
41
|
States:
|
|
40
42
|
- CLOSED: Normal operation, all calls allowed
|
|
41
43
|
- OPEN: Circuit is open, all calls fail fast with CircuitOpenError
|
|
42
44
|
- HALF_OPEN: Testing recovery, limited calls allowed
|
|
43
|
-
|
|
45
|
+
|
|
44
46
|
State Transitions:
|
|
45
47
|
- CLOSED → OPEN: After failure_threshold consecutive failures
|
|
46
48
|
- OPEN → HALF_OPEN: After timeout seconds
|
|
47
49
|
- HALF_OPEN → CLOSED: After success_threshold consecutive successes
|
|
48
50
|
- HALF_OPEN → OPEN: After any failure
|
|
49
51
|
"""
|
|
50
|
-
|
|
51
|
-
def __init__(
|
|
52
|
+
|
|
53
|
+
def __init__(
|
|
54
|
+
self, failure_threshold: int = 5, success_threshold: int = 2, timeout: int = 60
|
|
55
|
+
):
|
|
52
56
|
"""
|
|
53
57
|
Initialize circuit breaker.
|
|
54
|
-
|
|
58
|
+
|
|
55
59
|
Args:
|
|
56
60
|
failure_threshold: Number of consecutive failures before opening circuit
|
|
57
61
|
success_threshold: Number of consecutive successes needed to close circuit from half-open
|
|
58
62
|
timeout: Seconds to wait before transitioning from OPEN to HALF_OPEN
|
|
59
63
|
"""
|
|
60
64
|
self.failure_threshold = failure_threshold
|
|
61
|
-
self.success_threshold = success_threshold
|
|
65
|
+
self.success_threshold = success_threshold
|
|
62
66
|
self.timeout = timeout
|
|
63
|
-
|
|
67
|
+
|
|
64
68
|
self._state = CircuitState.CLOSED
|
|
65
69
|
self._failure_count = 0
|
|
66
70
|
self._success_count = 0
|
|
67
71
|
self._last_failure_time = None
|
|
68
72
|
self._lock = asyncio.Lock()
|
|
69
|
-
|
|
73
|
+
|
|
70
74
|
logger.info(
|
|
71
75
|
f"Circuit breaker initialized: failure_threshold={failure_threshold}, "
|
|
72
76
|
f"success_threshold={success_threshold}, timeout={timeout}s"
|
|
73
77
|
)
|
|
74
|
-
|
|
78
|
+
|
|
75
79
|
async def call(self, func: Callable, *args, **kwargs) -> Any:
|
|
76
80
|
"""
|
|
77
81
|
Execute a function through the circuit breaker.
|
|
78
|
-
|
|
82
|
+
|
|
79
83
|
Args:
|
|
80
84
|
func: Function to execute
|
|
81
85
|
*args: Positional arguments for the function
|
|
82
86
|
**kwargs: Keyword arguments for the function
|
|
83
|
-
|
|
87
|
+
|
|
84
88
|
Returns:
|
|
85
89
|
Result of the function call
|
|
86
|
-
|
|
90
|
+
|
|
87
91
|
Raises:
|
|
88
92
|
CircuitOpenError: If circuit is in OPEN state
|
|
89
93
|
Exception: Any exception raised by the wrapped function
|
|
90
94
|
"""
|
|
91
95
|
async with self._lock:
|
|
92
96
|
current_state = self._get_current_state()
|
|
93
|
-
|
|
97
|
+
|
|
94
98
|
if current_state == CircuitState.OPEN:
|
|
95
99
|
logger.warning("Circuit breaker is OPEN, failing fast")
|
|
96
100
|
raise CircuitOpenError("Circuit breaker is open")
|
|
97
|
-
|
|
101
|
+
|
|
98
102
|
if current_state == CircuitState.HALF_OPEN:
|
|
99
103
|
# In half-open state, we're testing recovery
|
|
100
104
|
logger.info("Circuit breaker is HALF_OPEN, allowing test call")
|
|
101
|
-
|
|
105
|
+
|
|
102
106
|
# Execute the function outside the lock to avoid blocking other calls
|
|
103
107
|
try:
|
|
104
|
-
result =
|
|
108
|
+
result = (
|
|
109
|
+
await func(*args, **kwargs)
|
|
110
|
+
if asyncio.iscoroutinefunction(func)
|
|
111
|
+
else func(*args, **kwargs)
|
|
112
|
+
)
|
|
105
113
|
await self._on_success()
|
|
106
114
|
return result
|
|
107
115
|
except Exception as e:
|
|
108
116
|
await self._on_failure()
|
|
109
117
|
raise e
|
|
110
|
-
|
|
118
|
+
|
|
111
119
|
def record_success(self) -> None:
|
|
112
120
|
"""Record a successful operation."""
|
|
113
121
|
asyncio.create_task(self._on_success())
|
|
114
|
-
|
|
122
|
+
|
|
115
123
|
def record_failure(self) -> None:
|
|
116
124
|
"""Record a failed operation."""
|
|
117
125
|
asyncio.create_task(self._on_failure())
|
|
118
|
-
|
|
126
|
+
|
|
119
127
|
def get_state(self) -> CircuitState:
|
|
120
128
|
"""Get current circuit breaker state."""
|
|
121
129
|
return self._get_current_state()
|
|
122
|
-
|
|
130
|
+
|
|
123
131
|
def is_open(self) -> bool:
|
|
124
132
|
"""Check if circuit breaker is in OPEN state."""
|
|
125
133
|
return self._get_current_state() == CircuitState.OPEN
|
|
126
|
-
|
|
134
|
+
|
|
127
135
|
def is_half_open(self) -> bool:
|
|
128
136
|
"""Check if circuit breaker is in HALF_OPEN state."""
|
|
129
137
|
return self._get_current_state() == CircuitState.HALF_OPEN
|
|
130
|
-
|
|
138
|
+
|
|
131
139
|
def is_closed(self) -> bool:
|
|
132
140
|
"""Check if circuit breaker is in CLOSED state."""
|
|
133
141
|
return self._get_current_state() == CircuitState.CLOSED
|
|
134
|
-
|
|
142
|
+
|
|
135
143
|
def reset(self) -> None:
|
|
136
144
|
"""Reset circuit breaker to CLOSED state and clear counters."""
|
|
137
145
|
logger.info("Resetting circuit breaker to CLOSED state")
|
|
@@ -139,13 +147,13 @@ class CircuitBreaker:
|
|
|
139
147
|
self._failure_count = 0
|
|
140
148
|
self._success_count = 0
|
|
141
149
|
self._last_failure_time = None
|
|
142
|
-
|
|
150
|
+
|
|
143
151
|
def force_open(self) -> None:
|
|
144
152
|
"""Force circuit breaker to OPEN state."""
|
|
145
153
|
logger.warning("Forcing circuit breaker to OPEN state")
|
|
146
154
|
self._state = CircuitState.OPEN
|
|
147
155
|
self._last_failure_time = time.time()
|
|
148
|
-
|
|
156
|
+
|
|
149
157
|
def force_close(self) -> None:
|
|
150
158
|
"""Force circuit breaker to CLOSED state and reset counters."""
|
|
151
159
|
logger.info("Forcing circuit breaker to CLOSED state")
|
|
@@ -153,11 +161,11 @@ class CircuitBreaker:
|
|
|
153
161
|
self._failure_count = 0
|
|
154
162
|
self._success_count = 0
|
|
155
163
|
self._last_failure_time = None
|
|
156
|
-
|
|
164
|
+
|
|
157
165
|
def _get_current_state(self) -> CircuitState:
|
|
158
166
|
"""
|
|
159
167
|
Get the current state, handling automatic transitions.
|
|
160
|
-
|
|
168
|
+
|
|
161
169
|
This method handles the automatic transition from OPEN to HALF_OPEN
|
|
162
170
|
after the timeout period has elapsed.
|
|
163
171
|
"""
|
|
@@ -165,54 +173,62 @@ class CircuitBreaker:
|
|
|
165
173
|
logger.info("Timeout reached, transitioning from OPEN to HALF_OPEN")
|
|
166
174
|
self._state = CircuitState.HALF_OPEN
|
|
167
175
|
self._success_count = 0 # Reset success counter for half-open testing
|
|
168
|
-
|
|
176
|
+
|
|
169
177
|
return self._state
|
|
170
|
-
|
|
178
|
+
|
|
171
179
|
def _should_attempt_reset(self) -> bool:
|
|
172
180
|
"""Check if enough time has passed to attempt reset from OPEN to HALF_OPEN."""
|
|
173
181
|
if self._last_failure_time is None:
|
|
174
182
|
return False
|
|
175
|
-
|
|
183
|
+
|
|
176
184
|
return time.time() - self._last_failure_time >= self.timeout
|
|
177
|
-
|
|
185
|
+
|
|
178
186
|
async def _on_success(self) -> None:
|
|
179
187
|
"""Handle successful operation."""
|
|
180
188
|
async with self._lock:
|
|
181
189
|
current_state = self._get_current_state()
|
|
182
|
-
|
|
190
|
+
|
|
183
191
|
if current_state == CircuitState.CLOSED:
|
|
184
192
|
# Reset failure count on success in closed state
|
|
185
193
|
if self._failure_count > 0:
|
|
186
194
|
logger.debug("Resetting failure count after success")
|
|
187
195
|
self._failure_count = 0
|
|
188
|
-
|
|
196
|
+
|
|
189
197
|
elif current_state == CircuitState.HALF_OPEN:
|
|
190
198
|
self._success_count += 1
|
|
191
|
-
logger.debug(
|
|
192
|
-
|
|
199
|
+
logger.debug(
|
|
200
|
+
f"Success in HALF_OPEN state: {self._success_count}/{self.success_threshold}"
|
|
201
|
+
)
|
|
202
|
+
|
|
193
203
|
if self._success_count >= self.success_threshold:
|
|
194
|
-
logger.info(
|
|
204
|
+
logger.info(
|
|
205
|
+
"Success threshold reached, transitioning from HALF_OPEN to CLOSED"
|
|
206
|
+
)
|
|
195
207
|
self._state = CircuitState.CLOSED
|
|
196
208
|
self._failure_count = 0
|
|
197
209
|
self._success_count = 0
|
|
198
210
|
self._last_failure_time = None
|
|
199
|
-
|
|
211
|
+
|
|
200
212
|
async def _on_failure(self) -> None:
|
|
201
213
|
"""Handle failed operation."""
|
|
202
214
|
async with self._lock:
|
|
203
215
|
current_state = self._get_current_state()
|
|
204
|
-
|
|
216
|
+
|
|
205
217
|
if current_state == CircuitState.CLOSED:
|
|
206
218
|
self._failure_count += 1
|
|
207
|
-
logger.debug(
|
|
208
|
-
|
|
219
|
+
logger.debug(
|
|
220
|
+
f"Failure in CLOSED state: {self._failure_count}/{self.failure_threshold}"
|
|
221
|
+
)
|
|
222
|
+
|
|
209
223
|
if self._failure_count >= self.failure_threshold:
|
|
210
|
-
logger.warning(
|
|
224
|
+
logger.warning(
|
|
225
|
+
"Failure threshold reached, transitioning from CLOSED to OPEN"
|
|
226
|
+
)
|
|
211
227
|
self._state = CircuitState.OPEN
|
|
212
228
|
self._last_failure_time = time.time()
|
|
213
|
-
|
|
229
|
+
|
|
214
230
|
elif current_state == CircuitState.HALF_OPEN:
|
|
215
231
|
logger.warning("Failure in HALF_OPEN state, transitioning back to OPEN")
|
|
216
232
|
self._state = CircuitState.OPEN
|
|
217
233
|
self._success_count = 0
|
|
218
|
-
self._last_failure_time = time.time()
|
|
234
|
+
self._last_failure_time = time.time()
|