Skip to content

Commit ad94b08

Browse files
committed
Log Durable API errors during state fetch, and save partially fetched state.
1 parent b76d213 commit ad94b08

2 files changed

Lines changed: 112 additions & 9 deletions

File tree

src/aws_durable_execution_sdk_python/state.py

Lines changed: 28 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@
1616
BackgroundThreadError,
1717
CallableRuntimeError,
1818
DurableExecutionsError,
19+
GetExecutionStateError,
1920
OrphanedChildException,
2021
)
2122
from aws_durable_execution_sdk_python.lambda_service import (
@@ -275,20 +276,38 @@ def fetch_paginated_operations(
275276
initial_operations: initial operations to be added to ExecutionState
276277
checkpoint_token: checkpoint token used to call Durable Functions API.
277278
next_marker: a marker indicates that there are paginated operations.
279+
280+
Raises:
281+
GetExecutionStateError: If the API call fails. The error is logged
282+
with structured extras before re-raising. Callers are responsible
283+
for deciding whether to fail the execution or allow Lambda retry
284+
based on is_retryable().
278285
"""
279286
all_operations: list[Operation] = (
280287
initial_operations.copy() if initial_operations else []
281288
)
282-
while next_marker:
283-
output: StateOutput = self._service_client.get_execution_state(
284-
durable_execution_arn=self.durable_execution_arn,
285-
checkpoint_token=checkpoint_token,
286-
next_marker=next_marker,
289+
try:
290+
while next_marker:
291+
output: StateOutput = self._service_client.get_execution_state(
292+
durable_execution_arn=self.durable_execution_arn,
293+
checkpoint_token=checkpoint_token,
294+
next_marker=next_marker,
295+
)
296+
all_operations.extend(output.operations)
297+
next_marker = output.next_marker
298+
except GetExecutionStateError as e:
299+
logger.exception(
300+
"Durable API error during state fetch.",
301+
extra=e.build_logger_extras(),
287302
)
288-
all_operations.extend(output.operations)
289-
next_marker = output.next_marker
290-
with self._operations_lock:
291-
self.operations.update({op.operation_id: op for op in all_operations})
303+
raise
304+
finally:
305+
# Always store whatever operations we successfully fetched
306+
if all_operations:
307+
with self._operations_lock:
308+
self.operations.update(
309+
{op.operation_id: op for op in all_operations}
310+
)
292311

293312
def get_input_payload(self) -> str | None:
294313
# It is possible that backend will not provide an execution operation

tests/state_test.py

Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,8 @@
1616
from aws_durable_execution_sdk_python.exceptions import (
1717
BackgroundThreadError,
1818
CallableRuntimeError,
19+
DurableApiErrorCategory,
20+
GetExecutionStateError,
1921
OrphanedChildException,
2022
)
2123
from aws_durable_execution_sdk_python.identifier import OperationIdentifier
@@ -724,6 +726,88 @@ def mock_get_execution_state(durable_execution_arn, checkpoint_token, next_marke
724726
assert operation.operation_id == expected_op.operation_id
725727

726728

729+
def test_fetch_paginated_operations_stores_partial_results_on_error():
730+
"""Test that operations from successful pages are stored even when a later page fails."""
731+
mock_lambda_client = Mock(spec=LambdaClient)
732+
733+
non_retryable_error = GetExecutionStateError(
734+
message="KMS access denied",
735+
error_category=DurableApiErrorCategory.EXECUTION,
736+
error={"Code": "KMSAccessDeniedException", "Message": "KMS access denied"},
737+
response_metadata={"HTTPStatusCode": 502},
738+
)
739+
740+
def mock_get_execution_state(durable_execution_arn, checkpoint_token, next_marker):
741+
if next_marker == "marker1":
742+
return StateOutput(
743+
operations=[
744+
Operation(
745+
operation_id="1",
746+
operation_type=OperationType.STEP,
747+
status=OperationStatus.STARTED,
748+
)
749+
],
750+
next_marker="marker2",
751+
)
752+
raise non_retryable_error
753+
754+
mock_lambda_client.get_execution_state.side_effect = mock_get_execution_state
755+
756+
state = ExecutionState(
757+
durable_execution_arn="test_arn",
758+
initial_checkpoint_token="token123", # noqa: S106
759+
operations={},
760+
service_client=mock_lambda_client,
761+
)
762+
763+
with pytest.raises(GetExecutionStateError):
764+
state.fetch_paginated_operations(
765+
initial_operations=[
766+
Operation(
767+
operation_id="0",
768+
operation_type=OperationType.STEP,
769+
status=OperationStatus.STARTED,
770+
)
771+
],
772+
checkpoint_token="test_token", # noqa: S106
773+
next_marker="marker1",
774+
)
775+
776+
# Initial operation + page 1 should be stored despite page 2 failing
777+
assert "0" in state.operations
778+
assert "1" in state.operations
779+
assert len(state.operations) == 2
780+
781+
782+
def test_fetch_paginated_operations_logs_error(caplog):
783+
"""Test that GetExecutionStateError is logged with structured extras."""
784+
mock_lambda_client = Mock(spec=LambdaClient)
785+
786+
error = GetExecutionStateError(
787+
message="Service error",
788+
error_category=DurableApiErrorCategory.INVOCATION,
789+
error={"Code": "ServiceException", "Message": "Service error"},
790+
response_metadata={"HTTPStatusCode": 500},
791+
)
792+
mock_lambda_client.get_execution_state.side_effect = error
793+
794+
state = ExecutionState(
795+
durable_execution_arn="test_arn",
796+
initial_checkpoint_token="token123", # noqa: S106
797+
operations={},
798+
service_client=mock_lambda_client,
799+
)
800+
801+
with pytest.raises(GetExecutionStateError):
802+
state.fetch_paginated_operations(
803+
initial_operations=[],
804+
checkpoint_token="test_token", # noqa: S106
805+
next_marker="marker1",
806+
)
807+
808+
assert "Durable API error during state fetch." in caplog.text
809+
810+
727811
# ============================================================================
728812
# Checkpoint Batching Tests
729813
# ============================================================================

0 commit comments

Comments
 (0)