Skip to content

Commit 27ff0ae

Browse files
bokelleyclaude
andauthored
fix: handle ExceptionGroup and CancelledError in MCP error flow (#87)
* fix: handle ExceptionGroup and CancelledError in MCP error flow When an MCP connection fails, two issues were masking the real error: 1. Cleanup raises ExceptionGroup containing the HTTP error (Python 3.11+) 2. Session initialization raises CancelledError instead of the real error This fix addresses both: **ExceptionGroup handling:** - Catch BaseException in cleanup to handle ExceptionGroup - Extract and log individual exceptions from the group - Gracefully handle Python 3.10 (no ExceptionGroup) and 3.11+ - Log HTTPStatusError at debug level (expected during cleanup) **CancelledError handling:** - Catch BaseException in connection loop (not just Exception) - Properly convert CancelledError to ADCPConnectionError - Preserve KeyboardInterrupt and SystemExit behavior Now users see the actual connection error (e.g., "405 Method Not Allowed") instead of confusing cleanup error messages. Added test to verify ExceptionGroup handling works correctly. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com> * fix: improve ExceptionGroup and error handling robustness Addresses code review feedback to make error handling more robust: 1. Fix ExceptionGroup import - use built-in type directly, not from builtins 2. Add KeyboardInterrupt/SystemExit re-raise in cleanup path 3. Replace string-based HTTPStatusError check with isinstance 4. Update all references from BaseExceptionGroup to ExceptionGroup These changes improve type safety, handle critical interrupts correctly, and make the code more maintainable. All tests pass and mypy type checking succeeds. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com> * fix: use try/except for ExceptionGroup to satisfy ruff Ruff doesn't recognize sys.version_info guards for undefined names. Changed to try/except pattern which both ruff and mypy accept. Also removed unused sys import since we no longer need version checking. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com> * fix: skip ExceptionGroup test on Python 3.10 The test_cleanup_handles_exception_group test uses ExceptionGroup which doesn't exist in Python 3.10. Added skipif marker to skip this test on Python < 3.11. The functionality still works correctly in 3.10 (it gracefully handles the absence of ExceptionGroup), we just can't test it directly since we can't create an ExceptionGroup in the test. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com> --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent aa933f6 commit 27ff0ae

File tree

2 files changed

+94
-16
lines changed

2 files changed

+94
-16
lines changed

src/adcp/protocols/mcp.py

Lines changed: 59 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,14 @@
99
from typing import TYPE_CHECKING, Any
1010
from urllib.parse import urlparse
1111

12+
# ExceptionGroup is available in Python 3.11+
13+
# In 3.11+, it's a built-in type. For 3.10, we need to handle its absence.
14+
try:
15+
_ExceptionGroup: type[BaseException] | None = ExceptionGroup # type: ignore[name-defined]
16+
except NameError:
17+
# Python 3.10 - ExceptionGroup doesn't exist
18+
_ExceptionGroup = None
19+
1220
logger = logging.getLogger(__name__)
1321

1422
if TYPE_CHECKING:
@@ -23,6 +31,14 @@
2331
except ImportError:
2432
MCP_AVAILABLE = False
2533

34+
try:
35+
from httpx import HTTPStatusError
36+
37+
HTTPX_AVAILABLE = True
38+
except ImportError:
39+
HTTPX_AVAILABLE = False
40+
HTTPStatusError = None # type: ignore[assignment, misc]
41+
2642
from adcp.exceptions import ADCPConnectionError, ADCPTimeoutError
2743
from adcp.protocols.base import ProtocolAdapter
2844
from adcp.types.core import DebugInfo, TaskResult, TaskStatus
@@ -56,22 +72,45 @@ async def _cleanup_failed_connection(self, context: str) -> None:
5672
self._session = None
5773
try:
5874
await old_stack.aclose()
59-
except asyncio.CancelledError:
60-
logger.debug(f"MCP session cleanup cancelled {context}")
61-
except RuntimeError as cleanup_error:
62-
# Known anyio task group cleanup issue
63-
error_msg = str(cleanup_error).lower()
64-
if "cancel scope" in error_msg or "async context" in error_msg:
65-
logger.debug(f"Ignoring anyio cleanup error {context}: {cleanup_error}")
75+
except BaseException as cleanup_error:
76+
# Handle all cleanup errors including ExceptionGroup
77+
# Re-raise KeyboardInterrupt and SystemExit immediately
78+
if isinstance(cleanup_error, (KeyboardInterrupt, SystemExit)):
79+
raise
80+
81+
if isinstance(cleanup_error, asyncio.CancelledError):
82+
logger.debug(f"MCP session cleanup cancelled {context}")
83+
return
84+
85+
# Handle ExceptionGroup from task group failures (Python 3.11+)
86+
if _ExceptionGroup is not None and isinstance(
87+
cleanup_error, _ExceptionGroup
88+
):
89+
for exc in cleanup_error.exceptions: # type: ignore[attr-defined]
90+
self._log_cleanup_error(exc, context)
6691
else:
67-
logger.warning(
68-
f"Unexpected RuntimeError during cleanup {context}: {cleanup_error}"
69-
)
70-
except Exception as cleanup_error:
71-
# Log unexpected cleanup errors but don't raise to preserve original error
72-
logger.warning(
73-
f"Unexpected error during cleanup {context}: {cleanup_error}", exc_info=True
74-
)
92+
self._log_cleanup_error(cleanup_error, context)
93+
94+
def _log_cleanup_error(self, exc: BaseException, context: str) -> None:
95+
"""Log a cleanup error without raising."""
96+
# Check for known cleanup error patterns from httpx/anyio
97+
exc_str = str(exc).lower()
98+
99+
# Common cleanup errors that are expected when connection fails
100+
is_known_cleanup_error = (
101+
isinstance(exc, RuntimeError)
102+
and ("cancel scope" in exc_str or "async context" in exc_str)
103+
) or (
104+
# HTTP errors during cleanup (if httpx is available)
105+
HTTPX_AVAILABLE and HTTPStatusError is not None and isinstance(exc, HTTPStatusError)
106+
)
107+
108+
if is_known_cleanup_error:
109+
# Expected cleanup errors - log at debug level without stack trace
110+
logger.debug(f"Ignoring expected cleanup error {context}: {exc}")
111+
else:
112+
# Truly unexpected cleanup errors - log at warning with full context
113+
logger.warning(f"Unexpected error during cleanup {context}: {exc}", exc_info=True)
75114

76115
async def _get_session(self) -> ClientSession:
77116
"""
@@ -146,7 +185,11 @@ async def _get_session(self) -> ClientSession:
146185
)
147186

148187
return self._session # type: ignore[no-any-return]
149-
except Exception as e:
188+
except BaseException as e:
189+
# Catch BaseException to handle CancelledError from failed initialization
190+
# Re-raise KeyboardInterrupt and SystemExit immediately
191+
if isinstance(e, (KeyboardInterrupt, SystemExit)):
192+
raise
150193
last_error = e
151194
# Clean up the exit stack on failure to avoid resource leaks
152195
await self._cleanup_failed_connection("during connection attempt")

tests/test_protocols.py

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
"""Tests for protocol adapters."""
22

3+
import sys
34
from unittest.mock import AsyncMock, MagicMock, patch
45

56
import pytest
@@ -844,3 +845,37 @@ def create_mock_exit_stack():
844845
# Verify adapter state is clean after all failed attempts
845846
assert adapter._exit_stack is None
846847
assert adapter._session is None
848+
849+
@pytest.mark.asyncio
850+
@pytest.mark.skipif(
851+
sys.version_info < (3, 11),
852+
reason="ExceptionGroup is only available in Python 3.11+",
853+
)
854+
async def test_cleanup_handles_exception_group(self, mcp_config):
855+
"""Test that cleanup handles ExceptionGroup from task group failures."""
856+
from contextlib import AsyncExitStack
857+
858+
import httpx
859+
860+
adapter = MCPAdapter(mcp_config)
861+
862+
# Create an ExceptionGroup like what anyio task groups raise
863+
http_error = httpx.HTTPStatusError(
864+
"Client error '405 Method Not Allowed' for url 'https://test.example.com'",
865+
request=MagicMock(),
866+
response=MagicMock(status_code=405),
867+
)
868+
exception_group = ExceptionGroup("unhandled errors in a TaskGroup", [http_error])
869+
870+
# Mock exit stack that raises ExceptionGroup on cleanup
871+
mock_exit_stack = AsyncMock(spec=AsyncExitStack)
872+
mock_exit_stack.aclose = AsyncMock(side_effect=exception_group)
873+
adapter._exit_stack = mock_exit_stack
874+
875+
# cleanup should not raise despite the ExceptionGroup
876+
await adapter._cleanup_failed_connection("during test")
877+
878+
# Verify cleanup was attempted and state is clean
879+
mock_exit_stack.aclose.assert_called_once()
880+
assert adapter._exit_stack is None
881+
assert adapter._session is None

0 commit comments

Comments
 (0)