Sentry Python anyio ExceptionGroup filtering fails for stream transport disconnects
A Sentry before_send hook that filters expected errors by walking hint["exc_info"][1].__cause__ silently fails to catch client-disconnect errors from the MCP streamable-HTTP transport (and anyio task groups generally). Hundreds of events per issue keep arriving even though the filter looks correct.
Two independent reasons:
- anyio wraps transport errors in an
ExceptionGroup("unhandled errors in a TaskGroup"). The realanyio.ClosedResourceErrorlives ongroup.exceptions, which is neither__cause__nor__context__. A__cause__-only walk never reaches it. - Some of these events carry NO exception at all.
mcp.server.lowlevel.serveremitslogger.error("Received exception from stream: "), which sentry-sdk'sLoggingIntegrationturns into an event whose hint haslog_recordand noexc_info. No exception walk of any kind can see it.
Walk the full exception tree, and add a separate log_record rule.
_DISCONNECT_EXC_NAMES = frozenset(
{"ClientDisconnect", "ClosedResourceError", "BrokenResourceError", "EndOfStream"}
)
def _walk_exc_tree(exc):
"""exc plus everything via __cause__, __context__, and ExceptionGroup children."""
seen: set[int] = set()
stack = [exc]
while stack:
cur = stack.pop()
if cur is None or id(cur) in seen:
continue
seen.add(id(cur))
yield cur
stack.append(getattr(cur, "__cause__", None))
stack.append(getattr(cur, "__context__", None))
stack.extend(getattr(cur, "exceptions", None) or ())
def before_send(event, hint):
log_record = hint.get("log_record")
if (
log_record is not None
and log_record.name == "mcp.server.lowlevel.server"
and log_record.getMessage().startswith("Received exception from stream")
):
return None
exc_info = hint.get("exc_info")
if exc_info:
for exc in _walk_exc_tree(exc_info[1]):
if type(exc).__name__ in _DISCONNECT_EXC_NAMES:
return None
# Starlette's own "the client vanished" signal
if type(exc).__name__ == "RuntimeError" and str(exc) == "No response returned.":
return None
return eventThree things that matter in practice:
- Match on
type(exc).__name__, notisinstance.before_sendmust not import anyio/starlette/mcp at module scope; the string check keeps the hook dependency-free and works across versions. id()-based cycle guard is required.__context__chains can be cyclic; a naive walk hangs the SDK inside the event pipeline.- Add the tree walk as a SEPARATE pass, do not retrofit
__context__into an existing__cause__loop.__context__picks up any exception that merely happened to be in flight, so rules like "drop ToolError caused by ValidationError" start firing on unrelated events. Keep the old loop byte-for-byte and run the new walk after it.
Do NOT also filter gunicorn.error WORKER TIMEOUT / SIGKILL while you're in here. A request killed at the worker timeout never completes, so no transaction is recorded either; the arbiter log line is the only signal those requests exist at all.
Verified against real events: 7 Sentry issues / ~579 events retired, with unit tests covering a direct ClientDisconnect, ExceptionGroup("...", [ClosedResourceError()]), RuntimeError("No response returned.") dropped vs RuntimeError("something else") kept, and the log-record rule dropped vs the same message under a different logger name kept.