From c445a75deaaaf56a4c80d44219b4023bf3982d2d Mon Sep 17 00:00:00 2001 From: Vishnu Mishra Date: Mon, 9 Feb 2026 13:54:36 +0530 Subject: [PATCH 1/2] fix: add CONTROL_FLOW_EXCEPTION_TYPES check to on_tool_error, on_retriever_error, and on_llm_error LangGraph uses exceptions inheriting from GraphBubbleUp (GraphInterrupt, NodeInterrupt, ParentCommand) for control flow, not actual errors. on_chain_error already filters these via CONTROL_FLOW_EXCEPTION_TYPES, but the other three error handlers did not, causing interrupt() and Command() calls in tools to be incorrectly marked as ERROR in traces. Apply the same guard pattern to on_tool_error, on_retriever_error, and on_llm_error so control-flow exceptions are not reported as errors. Fixes langfuse/langfuse#10962 Fixes langfuse/langfuse#5035 --- langfuse/langchain/CallbackHandler.py | 35 ++++++++++++++++++++++----- 1 file changed, 29 insertions(+), 6 deletions(-) diff --git a/langfuse/langchain/CallbackHandler.py b/langfuse/langchain/CallbackHandler.py index a2e9816da..bc5025f69 100644 --- a/langfuse/langchain/CallbackHandler.py +++ b/langfuse/langchain/CallbackHandler.py @@ -255,12 +255,20 @@ def on_retriever_error( self._log_debug_event( "on_retriever_error", run_id, parent_run_id, error=error ) + if any(isinstance(error, t) for t in CONTROL_FLOW_EXCEPTION_TYPES): + level = None + else: + level = "ERROR" + observation = self._detach_observation(run_id) if observation is not None: observation.update( - level="ERROR", - status_message=str(error), + level=cast( + Optional[Literal["DEBUG", "DEFAULT", "WARNING", "ERROR"]], + level, + ), + status_message=str(error) if level else None, input=kwargs.get("inputs"), cost_details={"total": 0}, ).end() @@ -803,12 +811,20 @@ def on_tool_error( ) -> Any: try: self._log_debug_event("on_tool_error", run_id, parent_run_id, error=error) + if any(isinstance(error, t) for t in CONTROL_FLOW_EXCEPTION_TYPES): + level = None + else: + level = "ERROR" + observation = self._detach_observation(run_id) if observation is not None: observation.update( - status_message=str(error), - level="ERROR", + level=cast( + Optional[Literal["DEBUG", "DEFAULT", "WARNING", "ERROR"]], + level, + ), + status_message=str(error) if level else None, input=kwargs.get("inputs"), cost_details={"total": 0}, ).end() @@ -997,13 +1013,20 @@ def on_llm_error( ) -> Any: try: self._log_debug_event("on_llm_error", run_id, parent_run_id, error=error) + if any(isinstance(error, t) for t in CONTROL_FLOW_EXCEPTION_TYPES): + level = None + else: + level = "ERROR" generation = self._detach_observation(run_id) if generation is not None: generation.update( - status_message=str(error), - level="ERROR", + level=cast( + Optional[Literal["DEBUG", "DEFAULT", "WARNING", "ERROR"]], + level, + ), + status_message=str(error) if level else None, input=kwargs.get("inputs"), cost_details={"total": 0}, ).end() From f40fda73aa765395d15bdf3463ebe805c107a290 Mon Sep 17 00:00:00 2001 From: Vishnu Mishra Date: Mon, 9 Feb 2026 17:47:56 +0530 Subject: [PATCH 2/2] fix: use DEFAULT level and preserve status_message for control-flow exceptions Instead of setting level=None and clearing status_message for LangGraph control-flow exceptions, use level="DEFAULT" and keep the status message. This preserves visibility of interrupt/command events in traces without marking them as errors. Also applies the same improvement to on_chain_error for consistency across all four error handlers. --- langfuse/langchain/CallbackHandler.py | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/langfuse/langchain/CallbackHandler.py b/langfuse/langchain/CallbackHandler.py index bc5025f69..6ed6f7339 100644 --- a/langfuse/langchain/CallbackHandler.py +++ b/langfuse/langchain/CallbackHandler.py @@ -256,7 +256,7 @@ def on_retriever_error( "on_retriever_error", run_id, parent_run_id, error=error ) if any(isinstance(error, t) for t in CONTROL_FLOW_EXCEPTION_TYPES): - level = None + level = "DEFAULT" else: level = "ERROR" @@ -268,7 +268,7 @@ def on_retriever_error( Optional[Literal["DEBUG", "DEFAULT", "WARNING", "ERROR"]], level, ), - status_message=str(error) if level else None, + status_message=str(error), input=kwargs.get("inputs"), cost_details={"total": 0}, ).end() @@ -589,7 +589,7 @@ def on_chain_error( try: self._log_debug_event("on_chain_error", run_id, parent_run_id, error=error) if any(isinstance(error, t) for t in CONTROL_FLOW_EXCEPTION_TYPES): - level = None + level = "DEFAULT" else: level = "ERROR" @@ -601,7 +601,7 @@ def on_chain_error( Optional[Literal["DEBUG", "DEFAULT", "WARNING", "ERROR"]], level, ), - status_message=str(error) if level else None, + status_message=str(error), input=kwargs.get("inputs"), cost_details={"total": 0}, ).end() @@ -812,7 +812,7 @@ def on_tool_error( try: self._log_debug_event("on_tool_error", run_id, parent_run_id, error=error) if any(isinstance(error, t) for t in CONTROL_FLOW_EXCEPTION_TYPES): - level = None + level = "DEFAULT" else: level = "ERROR" @@ -824,7 +824,7 @@ def on_tool_error( Optional[Literal["DEBUG", "DEFAULT", "WARNING", "ERROR"]], level, ), - status_message=str(error) if level else None, + status_message=str(error), input=kwargs.get("inputs"), cost_details={"total": 0}, ).end() @@ -1014,7 +1014,7 @@ def on_llm_error( try: self._log_debug_event("on_llm_error", run_id, parent_run_id, error=error) if any(isinstance(error, t) for t in CONTROL_FLOW_EXCEPTION_TYPES): - level = None + level = "DEFAULT" else: level = "ERROR" @@ -1026,7 +1026,7 @@ def on_llm_error( Optional[Literal["DEBUG", "DEFAULT", "WARNING", "ERROR"]], level, ), - status_message=str(error) if level else None, + status_message=str(error), input=kwargs.get("inputs"), cost_details={"total": 0}, ).end()