Skip to content

Commit 09acaf6

Browse files
committed
fix(testing): handle workflow failure states correctly
1 parent 4bbb9f2 commit 09acaf6

6 files changed

Lines changed: 162 additions & 8 deletions

File tree

packages/aws-durable-execution-sdk-python-testing/src/aws_durable_execution_sdk_python_testing/execution.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -406,7 +406,9 @@ def complete_success(self, result: str | None, now: datetime | None = None) -> N
406406
self.close_status = ExecutionStatus.SUCCEEDED
407407
self._end_execution(OperationStatus.SUCCEEDED, now)
408408

409-
def complete_fail(self, error: ErrorObject, now: datetime | None = None) -> None:
409+
def complete_fail(
410+
self, error: ErrorObject | None, now: datetime | None = None
411+
) -> None:
410412
"""Complete execution with failure (DecisionType.FAIL_WORKFLOW_EXECUTION)."""
411413
self.result = DurableExecutionInvocationOutput(
412414
status=InvocationStatus.FAILED, error=error

packages/aws-durable-execution-sdk-python-testing/src/aws_durable_execution_sdk_python_testing/executor.py

Lines changed: 5 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1285,9 +1285,7 @@ def _validate_invocation_response_and_store(
12851285
)
12861286
raise InvalidParameterValueException(msg_failed_result)
12871287
logger.info("[%s] Execution failed", execution_arn)
1288-
self._complete_workflow(
1289-
execution_arn, result=None, error=response.error
1290-
)
1288+
self._fail_workflow(execution_arn, response.error)
12911289

12921290
case InvocationStatus.SUCCEEDED:
12931291
if response.error is not None:
@@ -1591,7 +1589,7 @@ def _complete_workflow(
15911589
else:
15921590
self.complete_execution(execution_arn, result)
15931591

1594-
def _fail_workflow(self, execution_arn: str, error: ErrorObject):
1592+
def _fail_workflow(self, execution_arn: str, error: ErrorObject | None):
15951593
"""Fail workflow with terminal state validation."""
15961594
execution = self._store.load(execution_arn)
15971595

@@ -1671,8 +1669,8 @@ def complete_execution(self, execution_arn: str, result: str | None = None) -> N
16711669
raise IllegalStateException(msg)
16721670
self._complete_events(execution_arn=execution_arn)
16731671

1674-
def fail_execution(self, execution_arn: str, error: ErrorObject) -> None:
1675-
"""Fail execution with error (FAIL_WORKFLOW_EXECUTION decision)."""
1672+
def fail_execution(self, execution_arn: str, error: ErrorObject | None) -> None:
1673+
"""Fail execution with optional error (FAIL_WORKFLOW_EXECUTION decision)."""
16761674
logger.error("[%s] Completing execution with error: %s", execution_arn, error)
16771675
execution: Execution = self._store.load(execution_arn=execution_arn)
16781676
execution.complete_fail(error=error, now=self._clock.now())
@@ -1688,7 +1686,7 @@ def on_completed(self, execution_arn: str, result: str | None = None) -> None:
16881686
"""Complete execution successfully. Observer method triggered by notifier."""
16891687
self.complete_execution(execution_arn, result)
16901688

1691-
def on_failed(self, execution_arn: str, error: ErrorObject) -> None:
1689+
def on_failed(self, execution_arn: str, error: ErrorObject | None) -> None:
16921690
"""Fail execution. Observer method triggered by notifier."""
16931691
self.fail_execution(execution_arn, error)
16941692

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
"""End-to-end child context failure handling through the test runner."""
2+
3+
import json
4+
from typing import Any
5+
6+
from aws_durable_execution_sdk_python.config import StepConfig
7+
from aws_durable_execution_sdk_python.context import (
8+
DurableContext,
9+
durable_step,
10+
durable_with_child_context,
11+
)
12+
from aws_durable_execution_sdk_python.execution import durable_execution
13+
from aws_durable_execution_sdk_python.lambda_service import (
14+
InvocationStatus,
15+
OperationStatus,
16+
)
17+
from aws_durable_execution_sdk_python.retries import RetryPresets
18+
from aws_durable_execution_sdk_python.types import StepContext
19+
20+
from aws_durable_execution_sdk_python_testing.runner import (
21+
ContextOperation,
22+
DurableFunctionTestResult,
23+
DurableFunctionTestRunner,
24+
)
25+
26+
27+
def test_caught_child_context_failure_does_not_fail_root_execution() -> None:
28+
@durable_step
29+
def failing_step(step_context: StepContext) -> str: # noqa: ARG001
30+
msg = "Child step failed"
31+
raise RuntimeError(msg)
32+
33+
@durable_with_child_context
34+
def failing_child(ctx: DurableContext) -> str:
35+
return ctx.step(
36+
failing_step(),
37+
config=StepConfig(retry_strategy=RetryPresets.none()),
38+
)
39+
40+
@durable_step
41+
def recovery_step(step_context: StepContext, value: str) -> str: # noqa: ARG001
42+
return value
43+
44+
@durable_execution
45+
def handler(event: Any, context: DurableContext) -> str: # noqa: ARG001
46+
try:
47+
context.run_in_child_context(failing_child(), name="failing-child")
48+
except Exception:
49+
pass
50+
51+
return context.step(recovery_step("handled"))
52+
53+
with DurableFunctionTestRunner(handler=handler, execution_timeout=10) as runner:
54+
result: DurableFunctionTestResult = runner.run(input="input str")
55+
56+
assert result.status is InvocationStatus.SUCCEEDED
57+
assert result.result == json.dumps("handled")
58+
59+
child_op: ContextOperation = result.get_context("failing-child")
60+
assert child_op.status == OperationStatus.FAILED
61+
assert child_op.error is not None
62+
assert child_op.error.message is not None
63+
assert "Child step failed" in child_op.error.message

packages/aws-durable-execution-sdk-python-testing/tests/event_factory_test.py

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -158,6 +158,41 @@ def test_create_execution_failed():
158158
assert event.execution_failed_details.error.payload.message == "Execution failed"
159159

160160

161+
def test_create_execution_failed_without_error_payload():
162+
from aws_durable_execution_sdk_python.execution import (
163+
DurableExecutionInvocationOutput,
164+
InvocationStatus,
165+
)
166+
167+
operation = create_mock_operation("op-1", status=OperationStatus.FAILED)
168+
operation.end_timestamp = datetime.now(UTC)
169+
170+
error_result = DurableExecutionInvocationOutput(
171+
status=InvocationStatus.FAILED,
172+
error=None,
173+
)
174+
context = EventCreationContext.create(
175+
operation=operation,
176+
event_id=3,
177+
durable_execution_arn="arn:test",
178+
start_input=StartDurableExecutionInput(
179+
account_id="123",
180+
function_name="test",
181+
function_qualifier="$LATEST",
182+
execution_name="test",
183+
execution_timeout_seconds=300,
184+
execution_retention_period_days=7,
185+
),
186+
result=error_result,
187+
include_execution_data=True,
188+
)
189+
event = Event.create_execution_event(context)
190+
191+
assert event.event_type == "ExecutionFailed"
192+
assert event.execution_failed_details.error is not None
193+
assert event.execution_failed_details.error.payload is None
194+
195+
161196
def test_create_execution_timed_out():
162197
from aws_durable_execution_sdk_python.execution import (
163198
DurableExecutionInvocationOutput,

packages/aws-durable-execution-sdk-python-testing/tests/execution_test.py

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -507,6 +507,26 @@ def test_complete_fail():
507507
assert execution.result.error == error
508508

509509

510+
def test_complete_fail_without_error():
511+
"""Test complete_fail preserves a missing error payload."""
512+
start_input = StartDurableExecutionInput(
513+
account_id="123456789012",
514+
function_name="test-function",
515+
function_qualifier="$LATEST",
516+
execution_name="test-execution",
517+
execution_timeout_seconds=300,
518+
execution_retention_period_days=7,
519+
invocation_id="test-invocation-id",
520+
)
521+
execution = Execution("test-arn", start_input, [Mock()])
522+
523+
execution.complete_fail(None)
524+
525+
assert execution.is_complete is True
526+
assert execution.result.status == InvocationStatus.FAILED
527+
assert execution.result.error is None
528+
529+
510530
def test_find_operation_exists():
511531
"""Test find_operation method when operation exists."""
512532
start_input = StartDurableExecutionInput(

packages/aws-durable-execution-sdk-python-testing/tests/executor_test.py

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -310,6 +310,42 @@ def test_should_complete_workflow_with_error_when_invocation_fails(
310310
mock_fail.assert_called_once_with("test-arn", failed_response.error)
311311

312312

313+
def test_validate_invocation_response_failed_without_error_still_fails():
314+
"""FAILED without an error must fail (not succeed), preserving the null error."""
315+
316+
store = InMemoryExecutionStore()
317+
executor = Executor(store, Mock(), Mock(), Mock())
318+
319+
start_input = StartDurableExecutionInput(
320+
account_id="123456789012",
321+
function_name="test-function",
322+
function_qualifier="$LATEST",
323+
execution_name="test-execution",
324+
execution_timeout_seconds=300,
325+
execution_retention_period_days=7,
326+
invocation_id="test-invocation-id",
327+
)
328+
execution = Execution.new(start_input)
329+
execution.start()
330+
store.save(execution)
331+
332+
response = DurableExecutionInvocationOutput(
333+
status=InvocationStatus.FAILED, error=None
334+
)
335+
336+
executor._validate_invocation_response_and_store( # noqa: SLF001
337+
execution.durable_execution_arn, response, execution
338+
)
339+
340+
stored = store.load(execution.durable_execution_arn)
341+
assert stored.is_complete is True
342+
assert stored.close_status is not None
343+
assert stored.close_status.value == "FAILED"
344+
assert stored.result is not None
345+
assert stored.result.status == InvocationStatus.FAILED
346+
assert stored.result.error is None
347+
348+
313349
def test_should_complete_workflow_with_result_when_invocation_succeeds(
314350
executor, mock_store, mock_scheduler, mock_invoker, start_input
315351
):

0 commit comments

Comments
 (0)