fix(mcp): keep waiting for box recovery

This commit is contained in:
Junyan Qin
2026-07-23 17:41:27 +08:00
parent 06e03af994
commit 73e47ea2d4
2 changed files with 86 additions and 8 deletions
+19 -8
View File
@@ -313,10 +313,9 @@ class RuntimeMCPSession:
# that is reconnecting is handled above and waits for availability. # that is reconnecting is handled above and waits for availability.
# #
# Set ``error_phase = BOX_UNAVAILABLE`` BEFORE raising so the retry # Set ``error_phase = BOX_UNAVAILABLE`` BEFORE raising so the retry
# wrapper can short-circuit (retrying is pointless when Box is # wrapper can distinguish a deliberately disabled Box from an enabled
# deliberately off) and the frontend can render a localized, # runtime that is still reconnecting. Keep the message itself short —
# actionable message instead of this raw RuntimeError. Keep the # the frontend ignores it for this phase.
# message itself short — the frontend ignores it for this phase.
box_service = getattr(self.ap, 'box_service', None) box_service = getattr(self.ap, 'box_service', None)
if box_service is not None and not getattr(box_service, 'available', False): if box_service is not None and not getattr(box_service, 'available', False):
self.error_phase = MCPSessionErrorPhase.BOX_UNAVAILABLE self.error_phase = MCPSessionErrorPhase.BOX_UNAVAILABLE
@@ -620,17 +619,29 @@ class RuntimeMCPSession:
await asyncio.sleep(2) await asyncio.sleep(2)
continue continue
except Exception as e: except Exception as e:
self.retry_count = attempt + 1
if self._shutdown_event.is_set(): if self._shutdown_event.is_set():
return # Shutdown requested, don't retry return # Shutdown requested, don't retry
# BOX_UNAVAILABLE is a deliberate refusal, not a transient
# failure — retrying produces log spam and a misleading
# "Failed after N attempts" message. Surface it immediately.
if self.error_phase == MCPSessionErrorPhase.BOX_UNAVAILABLE: if self.error_phase == MCPSessionErrorPhase.BOX_UNAVAILABLE:
box_service = getattr(self.ap, 'box_service', None)
if box_service is not None and getattr(box_service, 'enabled', True):
# Box is configured and may recover independently of
# this MCP session. Keep retrying without consuming the
# fatal budget; _wait_for_box_runtime() rate-limits the
# loop to one warning per startup timeout.
self.status = MCPSessionStatus.CONNECTING
self.error_message = None
self.error_phase = None
await asyncio.sleep(1)
continue
# Explicitly disabled Box is a deliberate refusal, not a
# transient failure. Surface it immediately without log
# spam or a misleading "Failed after N attempts" message.
self.retry_count = attempt + 1
self.status = MCPSessionStatus.ERROR self.status = MCPSessionStatus.ERROR
self.error_message = str(e) self.error_message = str(e)
self._ready_event.set() self._ready_event.set()
return return
self.retry_count = attempt + 1
if attempt >= self._MAX_RETRIES: if attempt >= self._MAX_RETRIES:
self.status = MCPSessionStatus.ERROR self.status = MCPSessionStatus.ERROR
self.error_message = f'Failed after {self._MAX_RETRIES + 1} attempts: {self._describe_exception(e)}' self.error_message = f'Failed after {self._MAX_RETRIES + 1} attempts: {self._describe_exception(e)}'
@@ -793,6 +793,73 @@ class TestGetRuntimeInfoDict:
assert ap.box_service.available is True assert ap.box_service.available is True
@pytest.mark.asyncio
async def test_enabled_box_timeout_does_not_exhaust_mcp_retry_budget(self, mcp_module, monkeypatch):
ap = _make_ap()
ap.box_service.available = False
ap.box_service.enabled = True
session = _make_session(
mcp_module,
{
'name': 'test',
'uuid': 'test-uuid',
'mode': 'stdio',
'command': 'python',
'args': [],
},
ap=ap,
)
attempts = 0
async def lifecycle():
nonlocal attempts
attempts += 1
if attempts == 1:
session.error_phase = mcp_module.MCPSessionErrorPhase.BOX_UNAVAILABLE
raise RuntimeError('Box runtime is not available after 1 seconds')
session._lifecycle_loop = lifecycle
sleep = AsyncMock()
monkeypatch.setattr(mcp_module.asyncio, 'sleep', sleep)
await session._lifecycle_loop_with_retry()
assert attempts == 2
assert session.retry_count == 0
sleep.assert_awaited_once_with(1)
@pytest.mark.asyncio
async def test_disabled_box_still_stops_mcp_retry_loop(self, mcp_module):
ap = _make_ap()
ap.box_service.available = False
ap.box_service.enabled = False
session = _make_session(
mcp_module,
{
'name': 'test',
'uuid': 'test-uuid',
'mode': 'stdio',
'command': 'python',
'args': [],
},
ap=ap,
)
attempts = 0
async def lifecycle():
nonlocal attempts
attempts += 1
session.error_phase = mcp_module.MCPSessionErrorPhase.BOX_UNAVAILABLE
raise RuntimeError('box_disabled_in_config')
session._lifecycle_loop = lifecycle
await session._lifecycle_loop_with_retry()
assert attempts == 1
assert session.status == mcp_module.MCPSessionStatus.ERROR
assert session.retry_count == 1
def test_stdio_session_without_box_service_uses_local_stdio(self, mcp_module): def test_stdio_session_without_box_service_uses_local_stdio(self, mcp_module):
ap = _make_ap() ap = _make_ap()
del ap.box_service del ap.box_service