Skip to content

fix: collapse single-error ExceptionGroups from task group cancellations#2183

Open
giulio-leone wants to merge 6 commits intomodelcontextprotocol:mainfrom
giulio-leone:fix/exception-group-unwrapping
Open

fix: collapse single-error ExceptionGroups from task group cancellations#2183
giulio-leone wants to merge 6 commits intomodelcontextprotocol:mainfrom
giulio-leone:fix/exception-group-unwrapping

Conversation

@giulio-leone
Copy link

Summary

Fixes #2114

When a task in an anyio task group fails, sibling tasks are cancelled and the resulting Cancelled exceptions are wrapped alongside the real error in a BaseExceptionGroup. This makes it extremely difficult for callers to classify the root cause of failures — they must manually unwrap exception groups to find what actually went wrong.

Solution

Added a collapse_exception_group() utility and a drop-in create_task_group() context manager in mcp.shared._exception_utils that automatically unwraps single-error exception groups:

  • One real error + N cancelled → raises the real error directly (chained to the original group via __cause__)
  • Multiple real errors → preserves the BaseExceptionGroup as-is
  • All cancelled → preserves the group as-is

Applied Sites

The unwrapping is applied to all client-facing code paths:

File Change
shared/session.py BaseSession.__aexit__ — affects all session-based operations
client/stdio.py stdio transport task group
client/sse.py SSE transport task group
client/streamable_http.py Streamable HTTP transport task group
client/websocket.py WebSocket transport task group
client/_memory.py In-memory transport task group

Server-side task groups are left unchanged as they typically have internal error handling.

Tests

Added comprehensive tests in tests/shared/test_exception_utils.py:

  • TestCollapseExceptionGroup: 5 unit tests covering all edge cases
  • TestCreateTaskGroup: 3 integration tests verifying unwrapping, clean exit, and cause chaining

All 154 shared tests pass with 0 errors.

g97iulio1609 and others added 6 commits February 28, 2026 21:57
When a task in an anyio task group fails, sibling tasks are cancelled
and the resulting Cancelled exceptions are wrapped alongside the real
error in a BaseExceptionGroup. This makes it extremely difficult for
callers to classify the root cause of failures.

Added collapse_exception_group() utility and a drop-in create_task_group()
context manager that automatically unwraps single-error exception groups.
When there is exactly one non-cancellation error, callers now receive
the original exception directly instead of a wrapped group.

Applied to all client-facing code paths:
- BaseSession.__aexit__ (affects all session-based operations)
- Client transports: stdio, SSE, streamable HTTP, websocket, memory

Multiple real errors (non-cancellation) are preserved as-is in the
exception group.

Fixes modelcontextprotocol#2114

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
…00% coverage

The conditional import of BaseExceptionGroup for Python < 3.11 used
pragma:no-branch, which only suppresses branch coverage. On 3.11+ the
import statement itself is never executed, causing coverage to drop below
100%. Switch to pragma:no-cover which excludes the entire conditional
block from coverage reporting.

Also mark the re-raise in session.__aexit__ as pragma:no-cover since it
only fires when the task group has multiple simultaneous real failures
(an edge case that is already tested in test_exception_utils).

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
@giulio-leone
Copy link
Author

All 26 CI checks pass. This collapses single-error ExceptionGroups for cleaner error messages. Ready for review.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

ExceptionGroup wrapping obscures real errors from task groups

1 participant