Skip to content

fix(langchain): don't record exception events for control-flow signals - #3545

Open
seanlee10 wants to merge 1 commit into
Arize-ai:mainfrom
seanlee10:fix/langchain-control-flow-exception-events
Open

fix(langchain): don't record exception events for control-flow signals#3545
seanlee10 wants to merge 1 commit into
Arize-ai:mainfrom
seanlee10:fix/langchain-control-flow-exception-events

Conversation

@seanlee10

Copy link
Copy Markdown

Problem

LangGraph routes between graphs by raising exceptions. When a supervisor node returns Command(goto=...), _control_branch() raises ParentCommand to 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's on_chain_error callback.

IGNORED_EXCEPTION_PATTERNS already recognizes these signals, but the filter was only consulted in one of the two places that react to an error:

Path Location Filtered Result
Span status _update_span yes StatusCode.OK
Exception event on_chain_error_record_exception no exception event recorded

So the span reported 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 in the trace view.

Reproduced against the current tracer with a real ParentCommand:

status : StatusCode.OK
events : [('exception', 'langgraph.errors.ParentCommand')]

This affects GraphInterrupt (#3316) and Command identically, 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 shape langchain_core's _get_stacktrace puts at the front of run.error (repr(error) + traceback). A genuine exception whose message merely contains Command(...) is therefore still recorded — covered by a test.

Tests

tests/test_ignored_exception_patterns.py previously called _update_span directly with a MagicMock span, which is why the gap went unnoticed. The new tests drive the real callback path — on_chain_starton_chain_error — through a real TracerProvider and InMemorySpanExporter, and assert on the exported span:

  • control-flow signals leave no exception event and set OK status
  • genuine exceptions still record an exception event with the right type/message and ERROR status
  • an exception whose message contains Command(...) is still recorded

The LangGraph exception types are stubbed locally rather than imported, since langgraph is not a test dependency of this package; only repr() matters to the code under test.

Verified both mutation directions fail: removing the gate fails the new tests, and forcing _is_ignored_exception to True fails the guard tests.

ruff check .  → All checks passed!
mypy .        → Success: no issues found in 23 source files
pytest .      → 235 passed, 2 skipped, 2 xfailed

🤖 Generated with Claude Code

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>
@seanlee10
seanlee10 requested a review from a team as a code owner August 11, 2026 07:06
@dosubot dosubot Bot added the size:S This PR changes 10-29 lines, ignoring generated files. label Aug 11, 2026
@github-actions

Copy link
Copy Markdown
Contributor

CLA Assistant Lite bot:
Thank you for your submission, we really appreciate it. Like many open-source projects, we ask that you sign our Contributor License Agreement before we can accept your contribution. You can sign the CLA by just posting a Pull Request Comment same as the below format.


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 gnanirahulnutakki left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

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

Labels

size:S This PR changes 10-29 lines, ignoring generated files.

Projects

Status: No status

Development

Successfully merging this pull request may close these issues.

2 participants