Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@ class Failed:
"""The execution failed with ``error``."""

execution_arn: str
error: ErrorObject
error: ErrorObject | None


@dataclass(frozen=True)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,6 @@
from typing import TYPE_CHECKING

from aws_durable_execution_sdk_python.lambda_service import (
ErrorObject,
Operation,
OperationAction,
OperationUpdate,
Expand Down Expand Up @@ -41,15 +40,8 @@ def process(
)
case _:
# intentional. actual service will fail any EXECUTION update that is not SUCCEED.
error = (
update.error
if update.error
else ErrorObject.from_message(
"There is no error details but EXECUTION checkpoint action is not SUCCEED."
)
)
# All EXECUTION failures go through normal fail path
# Timeout/Stop status is set by executor based on the operation that caused it
notifier.notify_failed(execution_arn=execution_arn, error=error)
notifier.notify_failed(execution_arn=execution_arn, error=update.error)
# TODO: Svc doesn't actually create checkpoint for EXECUTION. might have to for localrunner though.
return None
Original file line number Diff line number Diff line change
Expand Up @@ -406,7 +406,9 @@ def complete_success(self, result: str | None, now: datetime | None = None) -> N
self.close_status = ExecutionStatus.SUCCEEDED
self._end_execution(OperationStatus.SUCCEEDED, now)

def complete_fail(self, error: ErrorObject, now: datetime | None = None) -> None:
def complete_fail(
self, error: ErrorObject | None, now: datetime | None = None
) -> None:
"""Complete execution with failure (DecisionType.FAIL_WORKFLOW_EXECUTION)."""
self.result = DurableExecutionInvocationOutput(
status=InvocationStatus.FAILED, error=error
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -1285,9 +1285,7 @@ def _validate_invocation_response_and_store(
)
raise InvalidParameterValueException(msg_failed_result)
logger.info("[%s] Execution failed", execution_arn)
self._complete_workflow(
execution_arn, result=None, error=response.error
)
self._fail_workflow(execution_arn, response.error)
Comment thread
hln33 marked this conversation as resolved.

case InvocationStatus.SUCCEEDED:
if response.error is not None:
Expand Down Expand Up @@ -1591,7 +1589,7 @@ def _complete_workflow(
else:
self.complete_execution(execution_arn, result)

def _fail_workflow(self, execution_arn: str, error: ErrorObject):
def _fail_workflow(self, execution_arn: str, error: ErrorObject | None):
"""Fail workflow with terminal state validation."""
execution = self._store.load(execution_arn)

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

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

def on_failed(self, execution_arn: str, error: ErrorObject) -> None:
def on_failed(self, execution_arn: str, error: ErrorObject | None) -> None:
Comment thread
hln33 marked this conversation as resolved.
"""Fail execution. Observer method triggered by notifier."""
self.fail_execution(execution_arn, error)

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,7 @@ def on_completed(self, execution_arn: str, result: str | None = None) -> None:
"""Called when execution completes successfully."""

@abstractmethod
def on_failed(self, execution_arn: str, error: ErrorObject) -> None:
def on_failed(self, execution_arn: str, error: ErrorObject | None) -> None:
"""Called when execution fails."""

@abstractmethod
Expand Down Expand Up @@ -77,7 +77,7 @@ def notify_completed(self, execution_arn: str, result: str | None = None) -> Non
"""Record that the execution completed successfully."""
self.effects.append(Completed(execution_arn=execution_arn, result=result))

def notify_failed(self, execution_arn: str, error: ErrorObject) -> None:
def notify_failed(self, execution_arn: str, error: ErrorObject | None) -> None:
"""Record that the execution failed."""
self.effects.append(Failed(execution_arn=execution_arn, error=error))

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,7 @@
ResourceNotFoundException,
)
from aws_durable_execution_sdk_python_testing.executor import Executor
from aws_durable_execution_sdk_python_testing.execution import ExecutionStatus
from aws_durable_execution_sdk_python_testing.invoker import (
InProcessInvoker,
LambdaInvoker,
Expand Down Expand Up @@ -498,6 +499,7 @@ class DurableFunctionTestResult:
operations: list[Operation]
result: OperationPayload | None = None
error: ErrorObject | None = None
execution_status: ExecutionStatus | None = None

@classmethod
def create(cls, execution: Execution) -> DurableFunctionTestResult:
Expand All @@ -513,12 +515,16 @@ def create(cls, execution: Execution) -> DurableFunctionTestResult:
if execution.result is None:
msg: str = "Execution result must exist to create test result."
raise DurableFunctionsTestError(msg)
if execution.close_status is None:
msg_status: str = "Execution close status must exist to create test result."
raise DurableFunctionsTestError(msg_status)

return cls(
status=execution.result.status,
operations=operations,
result=execution.result.result,
error=execution.result.error,
execution_status=execution.close_status,
)

@classmethod
Expand All @@ -541,6 +547,16 @@ def from_execution_history(
)
status = InvocationStatus.FAILED

# Map overall execution status string separately from invocation status.
try:
execution_status = ExecutionStatus[execution_response.status]
except KeyError:
logger.warning(
"Unknown execution status: %s, defaulting to FAILED",
execution_response.status,
)
execution_status = ExecutionStatus.FAILED

# Convert Events to Operations - group by operation_id and merge
try:
svc_operations = events_to_operations(history_response.events)
Expand All @@ -561,6 +577,7 @@ def from_execution_history(
operations=operations,
result=execution_response.result,
error=execution_response.error,
execution_status=execution_status,
)

def get_operation_by_name(self, name: str) -> Operation:
Expand Down Expand Up @@ -1185,7 +1202,7 @@ def _wait_for_completion(
if execution.status == "FAILED":
logger.warning("Execution failed")
return execution
if execution.status in ["TIMED_OUT", "ABORTED"]:

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

I do not think "ABORTED" is a valid execution status. Could not find any references to it in other SDKs. The API documentation also does not list "ABORTED" as being a valid execution status.

I think it should instead be "STOPPED", as that specific status is not covered in this function.

I noticed this when implementing a new "execution_status" field on the test result class and my AI coding agent flagged this to me.

if execution.status in ["TIMED_OUT", "STOPPED"]:
logger.warning("Execution terminated: %s", execution.status)
return execution

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -136,13 +136,7 @@ def test_process_fail_action_without_error():

assert result is None
assert len(notifier.failed_calls) == 1
execution_arn_arg, error_arg = notifier.failed_calls[0]
assert execution_arn_arg == execution_arn
assert isinstance(error_arg, ErrorObject)
assert (
"There is no error details but EXECUTION checkpoint action is not SUCCEED"
in str(error_arg)
)
assert notifier.failed_calls[0] == (execution_arn, None)


def test_process_start_action():
Expand All @@ -160,9 +154,7 @@ def test_process_start_action():

assert result is None
assert len(notifier.failed_calls) == 1
execution_arn_arg, error_arg = notifier.failed_calls[0]
assert execution_arn_arg == execution_arn
assert isinstance(error_arg, ErrorObject)
assert notifier.failed_calls[0] == (execution_arn, None)


def test_process_retry_action():
Expand All @@ -180,9 +172,7 @@ def test_process_retry_action():

assert result is None
assert len(notifier.failed_calls) == 1
execution_arn_arg, error_arg = notifier.failed_calls[0]
assert execution_arn_arg == execution_arn
assert isinstance(error_arg, ErrorObject)
assert notifier.failed_calls[0] == (execution_arn, None)


def test_process_cancel_action():
Expand All @@ -200,9 +190,7 @@ def test_process_cancel_action():

assert result is None
assert len(notifier.failed_calls) == 1
execution_arn_arg, error_arg = notifier.failed_calls[0]
assert execution_arn_arg == execution_arn
assert isinstance(error_arg, ErrorObject)
assert notifier.failed_calls[0] == (execution_arn, None)


def test_process_with_current_operation_and_error():
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
"""End-to-end child context failure handling through the test runner."""

import json
from typing import Any

from aws_durable_execution_sdk_python.config import StepConfig
from aws_durable_execution_sdk_python.context import (
DurableContext,
durable_step,
durable_with_child_context,
)
from aws_durable_execution_sdk_python.execution import durable_execution
from aws_durable_execution_sdk_python.lambda_service import (
InvocationStatus,
OperationStatus,
)
from aws_durable_execution_sdk_python.retries import RetryPresets
from aws_durable_execution_sdk_python.types import StepContext

from aws_durable_execution_sdk_python_testing.runner import (
ContextOperation,
DurableFunctionTestResult,
DurableFunctionTestRunner,
)


def test_caught_child_context_failure_does_not_fail_root_execution() -> None:
@durable_step
def failing_step(step_context: StepContext) -> str: # noqa: ARG001
msg = "Child step failed"
raise RuntimeError(msg)

@durable_with_child_context
def failing_child(ctx: DurableContext) -> str:
return ctx.step(
failing_step(),
config=StepConfig(retry_strategy=RetryPresets.none()),
)

@durable_step
def recovery_step(step_context: StepContext, value: str) -> str: # noqa: ARG001
return value

@durable_execution
def handler(event: Any, context: DurableContext) -> str: # noqa: ARG001
try:
context.run_in_child_context(failing_child(), name="failing-child")
except Exception:
pass

return context.step(recovery_step("handled"))

with DurableFunctionTestRunner(handler=handler, execution_timeout=10) as runner:
result: DurableFunctionTestResult = runner.run(input="input str")

assert result.status is InvocationStatus.SUCCEEDED
assert result.result == json.dumps("handled")

child_op: ContextOperation = result.get_context("failing-child")
assert child_op.status is OperationStatus.FAILED
assert child_op.error is not None
assert child_op.error.message is not None
assert "Child step failed" in child_op.error.message
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
"""End-to-end failed invocation handling through the test runner."""

from typing import Any

from aws_durable_execution_sdk_python.execution import InvocationStatus

from aws_durable_execution_sdk_python_testing.execution import ExecutionStatus
from aws_durable_execution_sdk_python_testing.runner import (
DurableFunctionTestResult,
DurableFunctionTestRunner,
)


def test_failed_invocation_without_error_sets_execution_status() -> None:
def handler(event: Any, context: Any) -> dict[str, str]: # noqa: ARG001
return {"Status": "FAILED"}

with DurableFunctionTestRunner(handler=handler, execution_timeout=10) as runner:
execution_arn = runner.run_async(input="input str")
result: DurableFunctionTestResult = runner.wait_for_result(
execution_arn, timeout=10
)

assert result.status is InvocationStatus.FAILED
assert result.error is None
assert result.execution_status is ExecutionStatus.FAILED
Original file line number Diff line number Diff line change
Expand Up @@ -158,6 +158,41 @@ def test_create_execution_failed():
assert event.execution_failed_details.error.payload.message == "Execution failed"


def test_create_execution_failed_without_error_payload():
from aws_durable_execution_sdk_python.execution import (
DurableExecutionInvocationOutput,
InvocationStatus,
)

operation = create_mock_operation("op-1", status=OperationStatus.FAILED)
operation.end_timestamp = datetime.now(UTC)

error_result = DurableExecutionInvocationOutput(
status=InvocationStatus.FAILED,
error=None,
)
context = EventCreationContext.create(
operation=operation,
event_id=3,
durable_execution_arn="arn:test",
start_input=StartDurableExecutionInput(
account_id="123",
function_name="test",
function_qualifier="$LATEST",
execution_name="test",
execution_timeout_seconds=300,
execution_retention_period_days=7,
),
result=error_result,
include_execution_data=True,
)
event = Event.create_execution_event(context)

assert event.event_type == "ExecutionFailed"
assert event.execution_failed_details.error is not None
assert event.execution_failed_details.error.payload is None


def test_create_execution_timed_out():
from aws_durable_execution_sdk_python.execution import (
DurableExecutionInvocationOutput,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -507,6 +507,26 @@ def test_complete_fail():
assert execution.result.error == error


def test_complete_fail_without_error():
"""Test complete_fail preserves a missing error payload."""
start_input = StartDurableExecutionInput(
account_id="123456789012",
function_name="test-function",
function_qualifier="$LATEST",
execution_name="test-execution",
execution_timeout_seconds=300,
execution_retention_period_days=7,
invocation_id="test-invocation-id",
)
execution = Execution("test-arn", start_input, [Mock()])

execution.complete_fail(None)

assert execution.is_complete is True
assert execution.result.status is InvocationStatus.FAILED
assert execution.result.error is None


def test_find_operation_exists():
"""Test find_operation method when operation exists."""
start_input = StartDurableExecutionInput(
Expand Down
Loading
Loading