Skip to content

Commit 30d3411

Browse files
GWealecopybara-github
authored andcommitted
fix: Prevent retry_on_errors from retrying asyncio.CancelledError
The retry_on_errors decorator in mcp_session_manager.py now catches asyncio.CancelledError and re-raises it immediately, ensuring that cancellation requests are not suppressed or retried Close #4009 Co-authored-by: George Weale <gweale@google.com> PiperOrigin-RevId: 852468382
1 parent 6f2c70f commit 30d3411

File tree

2 files changed

+105
-15
lines changed

2 files changed

+105
-15
lines changed

src/google/adk/tools/mcp_tool/mcp_session_manager.py

Lines changed: 36 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@
1515
from __future__ import annotations
1616

1717
import asyncio
18+
from collections import deque
1819
from contextlib import AsyncExitStack
1920
from datetime import timedelta
2021
import functools
@@ -30,7 +31,6 @@
3031
from typing import TextIO
3132
from typing import Union
3233

33-
import anyio
3434
from mcp import ClientSession
3535
from mcp import StdioServerParameters
3636
from mcp.client.sse import sse_client
@@ -44,6 +44,30 @@
4444
logger = logging.getLogger('google_adk.' + __name__)
4545

4646

47+
def _has_cancelled_error_context(exc: BaseException) -> bool:
48+
"""Returns True if `exc` is/was caused by `asyncio.CancelledError`.
49+
50+
Cancellation can be translated into other exceptions during teardown (e.g.
51+
connection errors) while still retaining the original cancellation in an
52+
exception's context chain.
53+
"""
54+
55+
seen: set[int] = set()
56+
queue = deque([exc])
57+
while queue:
58+
current = queue.popleft()
59+
if id(current) in seen:
60+
continue
61+
seen.add(id(current))
62+
if isinstance(current, asyncio.CancelledError):
63+
return True
64+
if current.__cause__ is not None:
65+
queue.append(current.__cause__)
66+
if current.__context__ is not None:
67+
queue.append(current.__context__)
68+
return False
69+
70+
4771
class StdioConnectionParams(BaseModel):
4872
"""Parameters for the MCP Stdio connection.
4973
@@ -119,6 +143,10 @@ def retry_on_errors(func):
119143
action once. The create_session method will handle creating a new session
120144
if the old one was disconnected.
121145
146+
Cancellation is not retried and must be allowed to propagate. In async
147+
runtimes, cancellation may surface as `asyncio.CancelledError` or as another
148+
exception while the task is cancelling.
149+
122150
Args:
123151
func: The function to decorate.
124152
@@ -131,6 +159,13 @@ async def wrapper(self, *args, **kwargs):
131159
try:
132160
return await func(self, *args, **kwargs)
133161
except Exception as e:
162+
task = asyncio.current_task()
163+
if task is not None:
164+
cancelling = getattr(task, 'cancelling', None)
165+
if cancelling is not None and cancelling() > 0:
166+
raise
167+
if _has_cancelled_error_context(e):
168+
raise
134169
# If an error is thrown, we will retry the function to reconnect to the
135170
# server. create_session will handle detecting and replacing disconnected
136171
# sessions.

tests/unittests/tools/mcp_tool/test_mcp_session_manager.py

Lines changed: 69 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -391,7 +391,8 @@ async def test_close_with_errors(self):
391391
assert "Close error 1" in error_output
392392

393393

394-
def test_retry_on_errors_decorator():
394+
@pytest.mark.asyncio
395+
async def test_retry_on_errors_decorator():
395396
"""Test the retry_on_errors decorator."""
396397

397398
call_count = 0
@@ -401,23 +402,77 @@ async def mock_function(self):
401402
nonlocal call_count
402403
call_count += 1
403404
if call_count == 1:
404-
import anyio
405-
406-
raise anyio.ClosedResourceError("Resource closed")
405+
raise ConnectionError("Resource closed")
407406
return "success"
408407

409-
@pytest.mark.asyncio
410-
async def test_retry():
408+
mock_self = Mock()
409+
result = await mock_function(mock_self)
410+
411+
assert result == "success"
412+
assert call_count == 2 # First call fails, second succeeds
413+
414+
415+
@pytest.mark.asyncio
416+
async def test_retry_on_errors_decorator_does_not_retry_cancelled_error():
417+
"""Test the retry_on_errors decorator does not retry cancellation."""
418+
419+
call_count = 0
420+
421+
@retry_on_errors
422+
async def mock_function(self):
411423
nonlocal call_count
412-
call_count = 0
424+
call_count += 1
425+
raise asyncio.CancelledError()
426+
427+
mock_self = Mock()
428+
with pytest.raises(asyncio.CancelledError):
429+
await mock_function(mock_self)
430+
431+
assert call_count == 1
413432

414-
mock_self = Mock()
415-
result = await mock_function(mock_self)
416433

417-
assert result == "success"
418-
assert call_count == 2 # First call fails, second succeeds
434+
@pytest.mark.asyncio
435+
async def test_retry_on_errors_decorator_does_not_retry_when_task_is_cancelling():
436+
"""Test the retry_on_errors decorator does not retry when cancelling."""
437+
438+
call_count = 0
439+
440+
@retry_on_errors
441+
async def mock_function(self):
442+
nonlocal call_count
443+
call_count += 1
444+
raise ConnectionError("Resource closed")
445+
446+
class _MockTask:
447+
448+
def cancelling(self):
449+
return 1
450+
451+
mock_self = Mock()
452+
with patch.object(asyncio, "current_task", return_value=_MockTask()):
453+
with pytest.raises(ConnectionError):
454+
await mock_function(mock_self)
455+
456+
assert call_count == 1
457+
458+
459+
@pytest.mark.asyncio
460+
async def test_retry_on_errors_decorator_does_not_retry_exception_from_cancel():
461+
"""Test the retry_on_errors decorator does not retry exceptions on cancel."""
462+
463+
call_count = 0
464+
465+
@retry_on_errors
466+
async def mock_function(self):
467+
nonlocal call_count
468+
call_count += 1
469+
try:
470+
raise asyncio.CancelledError()
471+
except asyncio.CancelledError:
472+
raise ConnectionError("Resource closed")
419473

420-
# Run the test
421-
import asyncio
474+
mock_self = Mock()
475+
with pytest.raises(ConnectionError):
476+
await mock_function(mock_self)
422477

423-
asyncio.run(test_retry())
478+
assert call_count == 1

0 commit comments

Comments
 (0)