# Copyright 2026 Google LLC # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. # You may obtain a copy of the License at # # http://www.apache.org/licenses/LICENSE-2.0 # # Unless required by applicable law or agreed to in writing, software # distributed under the License is distributed on an "AS IS" BASIS, # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. """Tests for Workflow error handling, graceful shutdown, and retry logic.""" import asyncio from typing import Any from typing import AsyncGenerator from unittest import mock from google.adk.agents.context import Context from google.adk.apps.app import App from google.adk.events.event import Event # Added for the moved test from google.adk.runners import Runner from google.adk.sessions.in_memory_session_service import InMemorySessionService from google.adk.workflow import BaseNode from google.adk.workflow import Edge from google.adk.workflow import START from google.adk.workflow._graph import Graph from google.adk.workflow._node import node from google.adk.workflow._node import Node from google.adk.workflow._node_status import NodeStatus from google.adk.workflow._retry_config import RetryConfig from google.adk.workflow._workflow import Workflow from google.genai import types from pydantic import ConfigDict from pydantic import Field import pytest from typing_extensions import override from .. import testing_utils from .workflow_testing_utils import create_parent_invocation_context from .workflow_testing_utils import simplify_events_with_node from .workflow_testing_utils import TestingNode class CustomError(Exception): """A custom error for testing.""" class CustomRetryableError(Exception): """A custom error meant to be retried.""" class CustomNonRetryableError(Exception): """A custom error not meant to be retried.""" class _FlakyNode(BaseNode): model_config = ConfigDict(arbitrary_types_allowed=True) message: str = Field(default='') succeed_on_iteration: int = Field(default=0) tracker: dict[str, Any] = Field(default_factory=dict) exception_to_raise: Exception = Field(...) @override async def run( self, *, ctx: Context, node_input: Any, ) -> AsyncGenerator[Any, None]: iteration_count = self.tracker.get('iteration_count', 0) + 1 self.tracker['iteration_count'] = iteration_count self.tracker.setdefault('attempt_counts', []).append(ctx.attempt_count) if iteration_count < self.succeed_on_iteration: raise self.exception_to_raise yield Event( output=self.message, ) async def _run_workflow(wf, message='start'): """Run a Workflow through Runner, return collected events.""" ss = InMemorySessionService() runner = Runner(app_name='test', node=wf, session_service=ss) session = await ss.create_session(app_name='test', user_id='u') msg = types.Content(parts=[types.Part(text=message)], role='user') events = [] try: async for event in runner.run_async( user_id='u', session_id=session.id, new_message=msg ): events.append(event) except CustomError: pass return events, ss, session # --- Tests originally in test_workflow_agent_failures.py --- @pytest.mark.asyncio async def test_retry_on_matching_exception(request: pytest.FixtureRequest): tracker = {'iteration_count': 0} node_a = TestingNode(name='NodeA', output='Executing A') flaky_node = _FlakyNode( name='FlakyNode', message='Executing B', succeed_on_iteration=3, tracker=tracker, exception_to_raise=CustomRetryableError('Simulated failure'), retry_config=RetryConfig( initial_delay=0.0, exceptions=['CustomRetryableError'], ), ) node_c = TestingNode(name='NodeC', output='Executing C') graph = Graph( edges=[ Edge(from_node=START, to_node=node_a), Edge(from_node=node_a, to_node=flaky_node), Edge(from_node=flaky_node, to_node=node_c), ], ) agent = Workflow( name='test_workflow_agent_retry', graph=graph, ) app = App(name=request.function.__name__, root_agent=agent) runner = testing_utils.InMemoryRunner(app=app) events = await runner.run_async(testing_utils.get_user_content('start')) assert simplify_events_with_node(events) == [ ( 'test_workflow_agent_retry@1/NodeA@1', {'output': 'Executing A'}, ), ( 'test_workflow_agent_retry@1/FlakyNode@1', {'output': 'Executing B'}, ), ( 'test_workflow_agent_retry@1/NodeC@1', {'output': 'Executing C'}, ), ] flaky_node_in_agent = next( n for n in agent.graph.nodes if n.name == 'FlakyNode' ) assert flaky_node_in_agent.tracker['iteration_count'] == 3 @pytest.mark.asyncio async def test_no_retry_on_non_matching_exception( request: pytest.FixtureRequest, ): tracker = {'iteration_count': 0} node_a = TestingNode(name='NodeA', output='Executing A') flaky_node = _FlakyNode( name='FlakyNode', message='Executing B', succeed_on_iteration=2, tracker=tracker, exception_to_raise=CustomNonRetryableError('Unexpected failure'), retry_config=RetryConfig( initial_delay=0.0, exceptions=['CustomRetryableError'], ), ) node_c = TestingNode(name='NodeC', output='Executing C') graph = Graph( edges=[ Edge(from_node=START, to_node=node_a), Edge(from_node=node_a, to_node=flaky_node), Edge(from_node=flaky_node, to_node=node_c), ], ) agent = Workflow( name='test_workflow_agent_no_retry', graph=graph, ) app = App(name=request.function.__name__, root_agent=agent) runner = testing_utils.InMemoryRunner(app=app) with pytest.raises(CustomNonRetryableError, match='Unexpected failure'): await runner.run_async(testing_utils.get_user_content('start')) events = runner.session.events assert simplify_events_with_node(events) == [ ('user', 'start'), ( 'test_workflow_agent_no_retry@1/NodeA@1', {'output': 'Executing A'}, ), ] @pytest.mark.asyncio async def test_retry_on_all_exceptions_if_not_specified( request: pytest.FixtureRequest, ): tracker = {'iteration_count': 0} node_a = TestingNode(name='NodeA', output='Executing A') flaky_node = _FlakyNode( name='FlakyNode', message='Executing B', succeed_on_iteration=2, tracker=tracker, exception_to_raise=ValueError('Any failure'), retry_config=RetryConfig( initial_delay=0.0, exceptions=None, ), ) graph = Graph( edges=[ Edge(from_node=START, to_node=node_a), Edge(from_node=node_a, to_node=flaky_node), ], ) agent = Workflow( name='test_workflow_agent_retry_all', graph=graph, ) app = App(name=request.function.__name__, root_agent=agent) runner = testing_utils.InMemoryRunner(app=app) events = await runner.run_async(testing_utils.get_user_content('start')) assert simplify_events_with_node(events) == [ ( 'test_workflow_agent_retry_all@1/NodeA@1', {'output': 'Executing A'}, ), ( 'test_workflow_agent_retry_all@1/FlakyNode@1', {'output': 'Executing B'}, ), ] @pytest.mark.asyncio async def test_attempt_count_populated_correctly( request: pytest.FixtureRequest, ): tracker = {'iteration_count': 0} node_a = TestingNode(name='NodeA', output='Executing A') flaky_node = _FlakyNode( name='FlakyNode', message='Executing B', succeed_on_iteration=3, tracker=tracker, exception_to_raise=CustomRetryableError('Simulated failure'), retry_config=RetryConfig( initial_delay=0.0, exceptions=['CustomRetryableError'] ), ) node_c = TestingNode(name='NodeC', output='Executing C') graph = Graph( edges=[ Edge(from_node=START, to_node=node_a), Edge(from_node=node_a, to_node=flaky_node), Edge(from_node=flaky_node, to_node=node_c), ], ) agent = Workflow( name='test_retry_count_populated_correctly', graph=graph, ) app = App(name=request.function.__name__, root_agent=agent) runner = testing_utils.InMemoryRunner(app=app) events = await runner.run_async(testing_utils.get_user_content('start')) assert simplify_events_with_node(events) == [ ( 'test_retry_count_populated_correctly@1/NodeA@1', {'output': 'Executing A'}, ), ( 'test_retry_count_populated_correctly@1/FlakyNode@1', {'output': 'Executing B'}, ), ( 'test_retry_count_populated_correctly@1/NodeC@1', {'output': 'Executing C'}, ), ] flaky_node_in_agent = next( n for n in agent.graph.nodes if n.name == 'FlakyNode' ) assert flaky_node_in_agent.tracker['iteration_count'] == 3 assert flaky_node_in_agent.tracker['attempt_counts'] == [1, 2, 3] @pytest.mark.asyncio async def test_retry_max_attempts_exceeded( request: pytest.FixtureRequest, ): tracker = {'iteration_count': 0} node_a = TestingNode(name='NodeA', output='Executing A') flaky_node = _FlakyNode( name='FlakyNode', message='Executing B', succeed_on_iteration=5, tracker=tracker, exception_to_raise=CustomRetryableError('Persisted failure'), retry_config=RetryConfig( initial_delay=0.0, max_attempts=3, exceptions=['CustomRetryableError'], ), ) node_c = TestingNode(name='NodeC', output='Executing C') graph = Graph( edges=[ Edge(from_node=START, to_node=node_a), Edge(from_node=node_a, to_node=flaky_node), Edge(from_node=flaky_node, to_node=node_c), ], ) agent = Workflow( name='test_workflow_agent_max_attempts', graph=graph, ) app = App(name=request.function.__name__, root_agent=agent) runner = testing_utils.InMemoryRunner(app=app) with pytest.raises(CustomRetryableError, match='Persisted failure'): await runner.run_async(testing_utils.get_user_content('start')) events = runner.session.events assert simplify_events_with_node(events) == [ ('user', 'start'), ( 'test_workflow_agent_max_attempts@1/NodeA@1', {'output': 'Executing A'}, ), ] @pytest.mark.asyncio async def test_fails_without_retry_config( request: pytest.FixtureRequest, ): tracker = {'iteration_count': 0} node_a = TestingNode(name='NodeA', output='Executing A') flaky_node = _FlakyNode( name='FlakyNode', message='Executing B', succeed_on_iteration=2, tracker=tracker, exception_to_raise=ValueError('Any failure'), retry_config=None, ) graph = Graph( edges=[ Edge(from_node=START, to_node=node_a), Edge(from_node=node_a, to_node=flaky_node), ], ) agent = Workflow( name='test_workflow_agent_fails_without_retry_config', graph=graph, ) app = App(name=request.function.__name__, root_agent=agent) runner = testing_utils.InMemoryRunner(app=app) with pytest.raises(ValueError, match='Any failure'): await runner.run_async(testing_utils.get_user_content('start')) events = runner.session.events assert simplify_events_with_node(events) == [ ('user', 'start'), ( 'test_workflow_agent_fails_without_retry_config@1/NodeA@1', {'output': 'Executing A'}, ), ] @pytest.mark.asyncio async def test_retries_with_empty_retry_config( request: pytest.FixtureRequest, ): tracker = {'iteration_count': 0} node_a = TestingNode(name='NodeA', output='Executing A') flaky_node = _FlakyNode( name='FlakyNode', message='Executing B', succeed_on_iteration=2, tracker=tracker, exception_to_raise=ValueError('Another failure'), retry_config=RetryConfig(), ) graph = Graph( edges=[ Edge(from_node=START, to_node=node_a), Edge(from_node=node_a, to_node=flaky_node), ], ) agent = Workflow( name='test_workflow_agent_retries_with_empty_retry_config', graph=graph, ) app = App(name=request.function.__name__, root_agent=agent) runner = testing_utils.InMemoryRunner(app=app) events = await runner.run_async(testing_utils.get_user_content('start')) assert simplify_events_with_node(events) == [ ( 'test_workflow_agent_retries_with_empty_retry_config@1/NodeA@1', {'output': 'Executing A'}, ), ( 'test_workflow_agent_retries_with_empty_retry_config@1/FlakyNode@1', {'output': 'Executing B'}, ), ] @pytest.mark.asyncio async def test_retry_with_delay(request: pytest.FixtureRequest): tracker = {'iteration_count': 0} node_a = TestingNode(name='NodeA', output='Executing A') flaky_node = _FlakyNode( name='FlakyNode', message='Executing B', succeed_on_iteration=2, tracker=tracker, exception_to_raise=CustomRetryableError('Sleep test failure'), retry_config=RetryConfig( initial_delay=5.0, max_attempts=3, jitter=0.0, exceptions=['CustomRetryableError'], ), ) node_c = TestingNode(name='NodeC', output='Executing C') graph = Graph( edges=[ Edge(from_node=START, to_node=node_a), Edge(from_node=node_a, to_node=flaky_node), Edge(from_node=flaky_node, to_node=node_c), ], ) agent = Workflow( name='test_workflow_agent_retry_delay', graph=graph, ) app = App(name=request.function.__name__, root_agent=agent) runner = testing_utils.InMemoryRunner(app=app) with mock.patch.object( asyncio, 'sleep', new_callable=mock.AsyncMock ) as mock_sleep: events = await runner.run_async(testing_utils.get_user_content('start')) mock_sleep.assert_any_await(5.0) assert simplify_events_with_node(events) == [ ( 'test_workflow_agent_retry_delay@1/NodeA@1', {'output': 'Executing A'}, ), ( 'test_workflow_agent_retry_delay@1/FlakyNode@1', {'output': 'Executing B'}, ), ( 'test_workflow_agent_retry_delay@1/NodeC@1', {'output': 'Executing C'}, ), ] @pytest.mark.asyncio async def test_retry_with_backoff_and_jitter(request: pytest.FixtureRequest): tracker = {'iteration_count': 0} node_a = TestingNode(name='NodeA', output='Executing A') flaky_node = _FlakyNode( name='FlakyNode', message='Executing B', succeed_on_iteration=4, tracker=tracker, exception_to_raise=CustomRetryableError('Backoff test failure'), retry_config=RetryConfig( initial_delay=2.0, max_attempts=5, backoff_factor=3.0, jitter=0.0, exceptions=['CustomRetryableError'], ), ) node_c = TestingNode(name='NodeC', output='Executing C') graph = Graph( edges=[ Edge(from_node=START, to_node=node_a), Edge(from_node=node_a, to_node=flaky_node), Edge(from_node=flaky_node, to_node=node_c), ], ) agent = Workflow( name='test_workflow_agent_retry_backoff', graph=graph, ) app = App(name=request.function.__name__, root_agent=agent) runner = testing_utils.InMemoryRunner(app=app) with mock.patch('asyncio.sleep', new_callable=mock.AsyncMock) as mock_sleep: events = await runner.run_async(testing_utils.get_user_content('start')) mock_sleep.assert_has_awaits( [mock.call(2.0), mock.call(6.0), mock.call(18.0)] ) assert simplify_events_with_node(events) == [ ( 'test_workflow_agent_retry_backoff@1/NodeA@1', {'output': 'Executing A'}, ), ( 'test_workflow_agent_retry_backoff@1/FlakyNode@1', {'output': 'Executing B'}, ), ( 'test_workflow_agent_retry_backoff@1/NodeC@1', {'output': 'Executing C'}, ), ] @pytest.mark.asyncio async def test_retry_with_jitter(request: pytest.FixtureRequest): tracker = {'iteration_count': 0} node_a = TestingNode(name='NodeA', output='Executing A') flaky_node = _FlakyNode( name='FlakyNode', message='Executing B', succeed_on_iteration=2, tracker=tracker, exception_to_raise=CustomRetryableError('Jitter test failure'), retry_config=RetryConfig( initial_delay=4.0, max_attempts=3, backoff_factor=1.0, jitter=0.5, exceptions=['CustomRetryableError'], ), ) node_c = TestingNode(name='NodeC', output='Executing C') graph = Graph( edges=[ Edge(from_node=START, to_node=node_a), Edge(from_node=node_a, to_node=flaky_node), Edge(from_node=flaky_node, to_node=node_c), ], ) agent = Workflow( name='test_workflow_agent_retry_jitter', graph=graph, ) app = App(name=request.function.__name__, root_agent=agent) runner = testing_utils.InMemoryRunner(app=app) with ( mock.patch('asyncio.sleep', new_callable=mock.AsyncMock) as mock_sleep, mock.patch('random.uniform', return_value=-1.0) as mock_random, ): events = await runner.run_async(testing_utils.get_user_content('start')) mock_sleep.assert_any_await(3.0) mock_random.assert_called_once_with(-2.0, 2.0) assert simplify_events_with_node(events) == [ ( 'test_workflow_agent_retry_jitter@1/NodeA@1', {'output': 'Executing A'}, ), ( 'test_workflow_agent_retry_jitter@1/FlakyNode@1', {'output': 'Executing B'}, ), ( 'test_workflow_agent_retry_jitter@1/NodeC@1', {'output': 'Executing C'}, ), ] @pytest.mark.asyncio async def test_retry_with_exception_classes(request: pytest.FixtureRequest): tracker = {'iteration_count': 0} node_a = TestingNode(name='NodeA', output='Executing A') flaky_node = _FlakyNode( name='FlakyNode', message='Executing B', succeed_on_iteration=3, tracker=tracker, exception_to_raise=CustomRetryableError('Simulated failure'), retry_config=RetryConfig( initial_delay=0.0, exceptions=[CustomRetryableError], ), ) node_c = TestingNode(name='NodeC', output='Executing C') graph = Graph( edges=[ Edge(from_node=START, to_node=node_a), Edge(from_node=node_a, to_node=flaky_node), Edge(from_node=flaky_node, to_node=node_c), ], ) agent = Workflow( name='test_retry_exception_classes', graph=graph, ) app = App(name=request.function.__name__, root_agent=agent) runner = testing_utils.InMemoryRunner(app=app) events = await runner.run_async(testing_utils.get_user_content('start')) assert simplify_events_with_node(events) == [ ( 'test_retry_exception_classes@1/NodeA@1', {'output': 'Executing A'}, ), ( 'test_retry_exception_classes@1/FlakyNode@1', {'output': 'Executing B'}, ), ( 'test_retry_exception_classes@1/NodeC@1', {'output': 'Executing C'}, ), ] @pytest.mark.asyncio async def test_retry_with_mixed_exception_types(request: pytest.FixtureRequest): tracker = {'iteration_count': 0} node_a = TestingNode(name='NodeA', output='Executing A') flaky_node = _FlakyNode( name='FlakyNode', message='Executing B', succeed_on_iteration=2, tracker=tracker, exception_to_raise=CustomRetryableError('Simulated failure'), retry_config=RetryConfig( initial_delay=0.0, exceptions=[CustomRetryableError, 'ValueError'], ), ) node_c = TestingNode(name='NodeC', output='Executing C') graph = Graph( edges=[ Edge(from_node=START, to_node=node_a), Edge(from_node=node_a, to_node=flaky_node), Edge(from_node=flaky_node, to_node=node_c), ], ) agent = Workflow( name='test_retry_mixed_exceptions', graph=graph, ) app = App(name=request.function.__name__, root_agent=agent) runner = testing_utils.InMemoryRunner(app=app) events = await runner.run_async(testing_utils.get_user_content('start')) assert simplify_events_with_node(events) == [ ( 'test_retry_mixed_exceptions@1/NodeA@1', {'output': 'Executing A'}, ), ( 'test_retry_mixed_exceptions@1/FlakyNode@1', {'output': 'Executing B'}, ), ( 'test_retry_mixed_exceptions@1/NodeC@1', {'output': 'Executing C'}, ), ] @pytest.mark.asyncio async def test_retry_exception_class_no_match(request: pytest.FixtureRequest): tracker = {'iteration_count': 0} node_a = TestingNode(name='NodeA', output='Executing A') flaky_node = _FlakyNode( name='FlakyNode', message='Executing B', succeed_on_iteration=3, tracker=tracker, exception_to_raise=CustomNonRetryableError('Unexpected failure'), retry_config=RetryConfig( initial_delay=0.0, exceptions=[CustomRetryableError], ), ) graph = Graph( edges=[ Edge(from_node=START, to_node=node_a), Edge(from_node=node_a, to_node=flaky_node), ], ) agent = Workflow( name='test_retry_exception_class_no_match', graph=graph, ) app = App(name=request.function.__name__, root_agent=agent) runner = testing_utils.InMemoryRunner(app=app) with pytest.raises(CustomNonRetryableError, match='Unexpected failure'): await runner.run_async(testing_utils.get_user_content('start')) flaky_node_in_agent = next( n for n in agent.graph.nodes if n.name == 'FlakyNode' ) assert flaky_node_in_agent.tracker['iteration_count'] == 1 def test_retry_config_rejects_invalid_exception_types(): with pytest.raises(ValueError, match='exception class names'): RetryConfig(exceptions=[42]) def test_retry_config_normalizes_classes_to_strings(): config = RetryConfig(exceptions=[ValueError, 'KeyError']) assert config.exceptions == ['ValueError', 'KeyError'] @pytest.mark.asyncio async def test_node_cancellation_on_sibling_failure( request: pytest.FixtureRequest, ): slow_node_started = False slow_node_cancelled = False async def slow_node(): nonlocal slow_node_started, slow_node_cancelled slow_node_started = True try: await asyncio.sleep(10) except asyncio.CancelledError: slow_node_cancelled = True raise yield 'Slow' async def fail_node(): await asyncio.sleep(0.1) raise ValueError('Fail') agent = Workflow( name='test_workflow_cancellation_sibling', edges=[ (START, slow_node), (START, fail_node), ], ) app = App(name=request.function.__name__, root_agent=agent) runner = testing_utils.InMemoryRunner(app=app) with pytest.raises(ValueError, match='Fail'): await runner.run_async(testing_utils.get_user_content('start')) assert slow_node_started is True assert slow_node_cancelled is True @pytest.mark.asyncio async def test_parallel_worker_cancellation_on_sibling_failure( request: pytest.FixtureRequest, ): slow_node_started = False slow_node_cancelled = False async def slow_node_impl(ctx: Context, node_input: Any): nonlocal slow_node_started, slow_node_cancelled slow_node_started = True try: await asyncio.sleep(10) except asyncio.CancelledError: slow_node_cancelled = True raise yield f'Slow {node_input}' async def fail_node(): await asyncio.sleep(0.1) raise ValueError('Fail') node_parallel = node( slow_node_impl, name='node_parallel', parallel_worker=True ) agent = Workflow( name='test_workflow_parallel_cancellation_sibling', edges=[ (START, node_parallel), (START, fail_node), ], ) app = App(name=request.function.__name__, root_agent=agent) runner = testing_utils.InMemoryRunner(app=app) with pytest.raises(ValueError, match='Fail'): await runner.run_async(testing_utils.get_user_content('start')) assert slow_node_started is True assert slow_node_cancelled is True @pytest.mark.asyncio async def test_parallel_worker_cancellation_on_worker_failure( request: pytest.FixtureRequest, ): slow_worker_started = False slow_worker_cancelled = False async def worker_node_impl(ctx: Context, node_input: Any): nonlocal slow_worker_started, slow_worker_cancelled if node_input == 'fail': await asyncio.sleep(0.1) raise ValueError('Worker Fail') else: slow_worker_started = True try: await asyncio.sleep(10) except asyncio.CancelledError: slow_worker_cancelled = True raise yield f'Success {node_input}' from tests.unittests.workflow.workflow_testing_utils import TestingNode node_list = TestingNode(name='NodeList', output=['fail', 'slow']) node_parallel = node( worker_node_impl, name='node_parallel', parallel_worker=True ) agent = Workflow( name='test_workflow_parallel_cancellation_worker', edges=[ (START, node_list), (node_list, node_parallel), ], ) app = App(name=request.function.__name__, root_agent=agent) runner = testing_utils.InMemoryRunner(app=app) with pytest.raises(ValueError, match='Worker Fail'): await runner.run_async(testing_utils.get_user_content('start')) assert slow_worker_started is True assert slow_worker_cancelled is True @pytest.mark.asyncio async def test_nested_workflow_cancellation_on_sibling_failure( request: pytest.FixtureRequest, ): inner_node_started = False inner_node_cancelled = False async def inner_slow_node(): nonlocal inner_node_started, inner_node_cancelled inner_node_started = True try: await asyncio.sleep(10) except asyncio.CancelledError: inner_node_cancelled = True raise yield 'Inner Slow' inner_agent = Workflow( name='inner_workflow', edges=[ (START, inner_slow_node), ], ) async def fail_node(): await asyncio.sleep(0.1) raise ValueError('Fail') outer_agent = Workflow( name='outer_workflow', edges=[ (START, inner_agent), (START, fail_node), ], ) app = App(name=request.function.__name__, root_agent=outer_agent) runner = testing_utils.InMemoryRunner(app=app) with pytest.raises(ValueError, match='Fail'): await runner.run_async(testing_utils.get_user_content('start')) assert inner_node_started is True assert inner_node_cancelled is True @pytest.mark.asyncio async def test_error_event_emitted_on_failure( request: pytest.FixtureRequest, ): tracker = {'iteration_count': 0} node_a = TestingNode(name='NodeA', output='Executing A') flaky_node = _FlakyNode( name='FlakyNode', message='Executing B', succeed_on_iteration=999, tracker=tracker, exception_to_raise=ValueError('Something went wrong'), retry_config=None, ) graph = Graph( edges=[ Edge(from_node=START, to_node=node_a), Edge(from_node=node_a, to_node=flaky_node), ], ) agent = Workflow( name='test_error_event', graph=graph, ) ctx = await create_parent_invocation_context( request.function.__name__, agent, resumable=True ) app = App(name=request.function.__name__, root_agent=agent) runner = testing_utils.InMemoryRunner(app=app) with pytest.raises(ValueError, match='Something went wrong'): await runner.run_async(testing_utils.get_user_content('start')) events = runner.session.events error_events = [ e for e in events if isinstance(e, Event) and e.error_code is not None and e.node_name == 'FlakyNode' ] assert len(error_events) == 1 assert error_events[0].error_code == 'ValueError' assert error_events[0].error_message == 'Something went wrong' @pytest.mark.asyncio async def test_error_event_emitted_on_each_retry( request: pytest.FixtureRequest, ): tracker = {'iteration_count': 0} flaky_node = _FlakyNode( name='FlakyNode', message='Success', succeed_on_iteration=3, tracker=tracker, exception_to_raise=CustomRetryableError('Transient error'), retry_config=RetryConfig( initial_delay=0.0, exceptions=['CustomRetryableError'], ), ) graph = Graph( edges=[ Edge(from_node=START, to_node=flaky_node), ], ) agent = Workflow( name='test_error_event_retry', graph=graph, ) ctx = await create_parent_invocation_context( request.function.__name__, agent, resumable=True ) app = App(name=request.function.__name__, root_agent=agent) runner = testing_utils.InMemoryRunner(app=app) events = await runner.run_async(testing_utils.get_user_content('start')) error_events = [ e for e in events if isinstance(e, Event) and e.error_code is not None and e.node_name == 'FlakyNode' ] assert len(error_events) == 2 for err in error_events: assert err.error_code == 'CustomRetryableError' assert err.error_message == 'Transient error' assert simplify_events_with_node(events) == [ ( 'test_error_event_retry@1/FlakyNode@1', {'output': 'Success'}, ), ] # --- Moved from test_workflow_failure.py --- @pytest.mark.asyncio async def test_workflow_returns_normally_on_node_failure(): """Workflow returns normally when a node fails, without duplicate error events.""" @node() def failing_node(ctx: Context): raise CustomError('Node failed') yield 'output' wf = Workflow( name='test_error_workflow', edges=[ (START, failing_node), ], ) events, ss, session = await _run_workflow(wf) error_events = [ e for e in events if isinstance(e, Event) and e.error_code == 'CustomError' ] assert len(error_events) == 1 assert error_events[0].error_message == 'Node failed' workflow_error_events = [ e for e in events if isinstance(e, Event) and e.error_code is not None and e.node_info and e.node_info.path == 'test_error_workflow@1' ] assert len(workflow_error_events) == 0 @pytest.mark.asyncio async def test_fail_fast_preserves_completed_siblings( request: pytest.FixtureRequest, ): """Tests that when one node fails, other sibling nodes completed in the same tick still have their outputs preserved.""" node_success_started = False node_success_completed = False @node() async def succeeding_node(ctx: Context): nonlocal node_success_started, node_success_completed node_success_started = True await asyncio.sleep(0) node_success_completed = True return 'success_output' @node() async def failing_node(ctx: Context): await asyncio.sleep(0) raise ValueError('Fail') wf = Workflow( name='test_fail_fast_workflow', edges=[ (START, failing_node), (START, succeeding_node), ], ) original_handle_completion = Workflow._handle_completion handle_completion_calls = [] def spy_handle_completion(self, loop_state, node_name, node_obj, child_ctx): handle_completion_calls.append(node_name) return original_handle_completion( self, loop_state, node_name, node_obj, child_ctx ) app = App(name=request.function.__name__, root_agent=wf) runner = testing_utils.InMemoryRunner(app=app) with mock.patch.object( Workflow, '_handle_completion', new=spy_handle_completion ): with pytest.raises(ValueError, match='Fail'): await runner.run_async(testing_utils.get_user_content('start')) # The succeeding_node should have successfully completed. assert node_success_started is True assert node_success_completed is True # Under the bug, succeeding_node's completion handler was skipped. # With the fix, succeeding_node's completion is handled. assert 'failing_node' not in handle_completion_calls assert 'succeeding_node' in handle_completion_calls @pytest.mark.asyncio async def test_multiple_failures_first_error_wins( request: pytest.FixtureRequest, ): """Tests that when multiple parallel nodes fail in the same tick, the first error is preserved.""" @node() async def failing_node_1(ctx: Context): await asyncio.sleep(0.1) raise ValueError('Fail 1') @node() async def failing_node_2(ctx: Context): await asyncio.sleep(0.1) raise ValueError('Fail 2') wf = Workflow( name='test_multiple_failures_workflow', edges=[ (START, failing_node_1), (START, failing_node_2), ], ) app = App(name=request.function.__name__, root_agent=wf) runner = testing_utils.InMemoryRunner(app=app) with pytest.raises(ValueError, match='Fail 1'): await runner.run_async(testing_utils.get_user_content('start'))