fix(langchain): don't record exception events for control-flow signals - #3545
fix(langchain): don't record exception events for control-flow signals#3545seanlee10 wants to merge 1 commit into
Conversation
LangGraph routes between graphs by raising exceptions (`ParentCommand`, `GraphInterrupt`), which reach the tracer through LangChain's error callbacks even though nothing failed. `IGNORED_EXCEPTION_PATTERNS` already recognized these, but the filter was only consulted by `_update_span` when setting span status. The separate `_record_exception` path used by `on_chain_error` (and the LLM, tool, and retriever error callbacks) ignored it, so the span ended up reporting OK status while still carrying an `exception` event. Observability UIs flag any span holding an exception event, so normal agent routing surfaced as an error. Extract the match into `_is_ignored_exception()` and apply it on both paths so status and events agree. The filter anchors on `repr(error)`, so a genuine exception whose message merely contains `Command(...)` is still recorded. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
CLA Assistant Lite bot: I have read the CLA Document and I hereby sign the CLA You can retrigger this bot by commenting recheck in this Pull Request |
gnanirahulnutakki
left a comment
There was a problem hiding this comment.
The new early return at _record_exception can suppress a real application failure solely because its exception class has the same unqualified name as a LangGraph control-flow signal. Python's default exception repr starts with the class name regardless of its module, so the regex has no provenance to distinguish langgraph.errors.ParentCommand from my_app.routing.ParentCommand.
On exact head 1f79ad89, I exercised Command, ParentCommand, and GraphInterrupt subclasses from my_app.routing through the chain, LLM, retriever, and tool error callbacks. All 12 combinations finished with status=OK and events=[]; on the exact parent, the same errors still emitted an exception event. This patch therefore removes the remaining diagnostic signal for those unrelated failures. The added test_exception_named_like_a_control_flow_signal_is_still_recorded uses a ValueError whose message contains Command(...); its repr begins with ValueError( and does not cover a genuinely same-named exception class.
Please restrict event suppression to genuine LangGraph control-flow exception provenance (for example, module/type or MRO validation without making LangGraph a mandatory import) and add a regression using an unrelated exception whose class is actually named Command, ParentCommand, or GraphInterrupt.
Validation on this exact head: uvx --with tox-uv==1.11.2 tox run -e ruff-mypy-test-langchain passed Ruff and Mypy with 237 tests passed / 2 skipped.
Problem
LangGraph routes between graphs by raising exceptions. When a supervisor node returns
Command(goto=...),_control_branch()raisesParentCommandto hand routing to the parent graph. Nothing has failed — it is a control-flow signal that happens to travel as an exception, and it reaches the tracer through LangChain'son_chain_errorcallback.IGNORED_EXCEPTION_PATTERNSalready recognizes these signals, but the filter was only consulted in one of the two places that react to an error:_update_spanStatusCode.OKon_chain_error→_record_exceptionexceptionevent recordedSo the span reported OK status while still carrying an
exceptionevent. Observability UIs flag any span holding an exception event, so normal agent routing surfaced as an error in the trace view.Reproduced against the current tracer with a real
ParentCommand:This affects
GraphInterrupt(#3316) andCommandidentically, and applies to the LLM, tool, and retriever error callbacks too, since they share_record_exception.Fix
Extract the pattern match into
_is_ignored_exception()and consult it on both paths, so status and events cannot disagree.The filter anchors on
repr(error), which is the same shapelangchain_core's_get_stacktraceputs at the front ofrun.error(repr(error) + traceback). A genuine exception whose message merely containsCommand(...)is therefore still recorded — covered by a test.Tests
tests/test_ignored_exception_patterns.pypreviously called_update_spandirectly with aMagicMockspan, which is why the gap went unnoticed. The new tests drive the real callback path —on_chain_start→on_chain_error— through a realTracerProviderandInMemorySpanExporter, and assert on the exported span:Command(...)is still recordedThe LangGraph exception types are stubbed locally rather than imported, since
langgraphis not a test dependency of this package; onlyrepr()matters to the code under test.Verified both mutation directions fail: removing the gate fails the new tests, and forcing
_is_ignored_exceptiontoTruefails the guard tests.🤖 Generated with Claude Code