Skip to content

Commit a92d9cc

Browse files
committed
feat(errors): typed per-operation error hierarchy
1 parent 887eb4a commit a92d9cc

24 files changed

Lines changed: 669 additions & 185 deletions

File tree

packages/aws-durable-execution-sdk-python-examples/examples-catalog.json

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -748,6 +748,17 @@
748748
"FILESYSTEM_MOUNT_PATH": "/mnt/s3"
749749
},
750750
"path": "./src/filesystem_serdes/filesystem_serdes_preview.py"
751+
},
752+
{
753+
"name": "Catch Typed Step Error",
754+
"description": "Catch a typed StepError raised by a failed step",
755+
"handler": "catch_typed_step_error.handler",
756+
"integration": true,
757+
"durableConfig": {
758+
"RetentionPeriodInDays": 7,
759+
"ExecutionTimeout": 300
760+
},
761+
"path": "./src/error_handling/catch_typed_step_error.py"
751762
}
752763
]
753764
}
Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
"""Demonstrates catching a typed StepError raised by a failed step.
2+
3+
A failure inside a durable operation surfaces as a typed
4+
DurableOperationError subclass (StepError, InvokeError, ChildContextError,
5+
WaitForConditionError) rather than a single catch-all error, so callers can
6+
handle each operation's failure distinctly.
7+
"""
8+
9+
from typing import Any
10+
11+
from aws_durable_execution_sdk_python import StepError
12+
from aws_durable_execution_sdk_python.config import StepConfig
13+
from aws_durable_execution_sdk_python.context import DurableContext
14+
from aws_durable_execution_sdk_python.execution import durable_execution
15+
from aws_durable_execution_sdk_python.retries import (
16+
RetryStrategyConfig,
17+
create_retry_strategy,
18+
)
19+
from aws_durable_execution_sdk_python.types import StepContext
20+
21+
22+
@durable_execution
23+
def handler(_event: Any, context: DurableContext) -> dict[str, Any]:
24+
"""Run a step that fails and catch the typed StepError."""
25+
26+
def failing_step(_step_context: StepContext) -> None:
27+
raise ValueError("payment declined")
28+
29+
# A single attempt so the step fails without scheduling retries.
30+
config = StepConfig(
31+
retry_strategy=create_retry_strategy(RetryStrategyConfig(max_attempts=1))
32+
)
33+
34+
try:
35+
context.step(failing_step, name="charge-card", config=config)
36+
except StepError as error:
37+
return {
38+
"handled": True,
39+
"error_type": error.error_type,
40+
"message": error.message,
41+
}
42+
43+
return {"handled": False}
Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
"""Tests for catch_typed_step_error."""
2+
3+
import pytest
4+
from aws_durable_execution_sdk_python.execution import InvocationStatus
5+
6+
from src.error_handling import catch_typed_step_error
7+
from test.conftest import deserialize_operation_payload
8+
9+
10+
@pytest.mark.example
11+
@pytest.mark.durable_execution(
12+
handler=catch_typed_step_error.handler,
13+
lambda_function_name="Catch Typed Step Error",
14+
)
15+
def test_catch_typed_step_error(durable_runner):
16+
"""A failed step surfaces as a catchable StepError with a typed discriminator."""
17+
with durable_runner:
18+
result = durable_runner.run(input=None, timeout=10)
19+
20+
assert result.status is InvocationStatus.SUCCEEDED
21+
22+
result_data = deserialize_operation_payload(result.result)
23+
assert result_data == {
24+
"handled": True,
25+
"error_type": "StepError",
26+
"message": "payment declined",
27+
}

packages/aws-durable-execution-sdk-python-examples/test/wait_for_callback/test_wait_for_callback_failure.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,5 +23,5 @@ def test_wait_for_callback_failure(durable_runner):
2323
assert isinstance(result.error, ErrorObject)
2424
assert result.error.to_dict() == {
2525
"ErrorMessage": "my callback error",
26-
"ErrorType": "CallableRuntimeError",
26+
"ErrorType": "ChildContextError",
2727
}

packages/aws-durable-execution-sdk-python/src/aws_durable_execution_sdk_python/__init__.py

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,9 +18,14 @@
1818

1919
# Most common exceptions - users need to handle these exceptions
2020
from aws_durable_execution_sdk_python.exceptions import (
21+
ChildContextError,
2122
DurableExecutionsError,
23+
DurableOperationError,
2224
InvocationError,
25+
InvokeError,
26+
StepError,
2327
ValidationError,
28+
WaitForConditionError,
2429
)
2530

2631
# Core decorator - used in every durable function
@@ -33,12 +38,17 @@
3338

3439
__all__ = [
3540
"BatchResult",
41+
"ChildContextError",
3642
"DurableContext",
3743
"DurableExecutionsError",
44+
"DurableOperationError",
3845
"InvocationError",
46+
"InvokeError",
3947
"ParallelBranch",
4048
"StepContext",
49+
"StepError",
4150
"ValidationError",
51+
"WaitForConditionError",
4252
"WithRetryConfig",
4353
"__version__",
4454
"durable_execution",

packages/aws-durable-execution-sdk-python/src/aws_durable_execution_sdk_python/concurrency/models.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -261,7 +261,7 @@ def throw_if_error(self) -> None:
261261
None,
262262
)
263263
if first_error:
264-
raise first_error.to_callable_runtime_error()
264+
raise first_error.to_durable_operation_error()
265265

266266
def get_results(self) -> list[R]:
267267
return [

packages/aws-durable-execution-sdk-python/src/aws_durable_execution_sdk_python/exceptions.py

Lines changed: 70 additions & 43 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,6 @@
66
from __future__ import annotations
77

88
import time
9-
from dataclasses import dataclass
109
from enum import Enum
1110
from typing import TYPE_CHECKING, Self, TypedDict
1211

@@ -30,6 +29,11 @@
3029
}
3130
)
3231

32+
# Registry mapping a DurableOperationError subclass name to its class, used to
33+
# reconstruct the correct typed error from a checkpointed ErrorObject on replay.
34+
# Populated automatically via DurableOperationError.__init_subclass__.
35+
_DURABLE_OPERATION_ERROR_REGISTRY: dict[str, type[DurableOperationError]] = {}
36+
3337
if TYPE_CHECKING:
3438
import datetime
3539

@@ -266,25 +270,79 @@ class InvalidStateError(DurableExecutionsError):
266270
"""Raised when an operation is attempted on an object in an invalid state."""
267271

268272

269-
class UserlandError(DurableExecutionsError):
270-
"""Failure in user-land - i.e code passed into durable executions from the caller."""
273+
class DurableOperationError(DurableExecutionsError):
274+
"""Base class for typed, per-operation failures.
271275
276+
Wraps a failure that escaped a Durable Function operation (step, invoke,
277+
child context, wait_for_condition). The original exception is preserved as
278+
``__cause__`` on the first run; on replay the error is reconstructed from
279+
its checkpointed ``ErrorObject`` via :meth:`from_error_fields`.
272280
273-
class CallableRuntimeError(UserlandError):
274-
"""This error wraps any failure from inside the callable code that you pass to a Durable Function operation."""
281+
Attributes:
282+
message: Human-readable failure message.
283+
error_type: Discriminator identifying the concrete subclass. Defaults
284+
to the class name and is serialized as the checkpoint ``ErrorType``.
285+
data: Optional serialized error payload, preserved across operation
286+
boundaries.
287+
stack_trace: Optional stack trace lines captured from the origin.
288+
"""
275289

276290
def __init__(
277291
self,
278-
message: str | None,
292+
message: str | None = None,
293+
error_type: str | None = None,
294+
data: str | None = None,
295+
stack_trace: list[str] | None = None,
296+
) -> None:
297+
super().__init__(message)
298+
self.message: str | None = message
299+
self.error_type: str = error_type or type(self).__name__
300+
self.data: str | None = data
301+
self.stack_trace: list[str] | None = stack_trace
302+
303+
def __init_subclass__(cls, **kwargs: object) -> None:
304+
super().__init_subclass__(**kwargs)
305+
_DURABLE_OPERATION_ERROR_REGISTRY[cls.__name__] = cls
306+
307+
@classmethod
308+
def from_error_fields(
309+
cls,
279310
error_type: str | None,
311+
message: str | None,
280312
data: str | None,
281313
stack_trace: list[str] | None,
282-
) -> None:
283-
super().__init__(message)
284-
self.message = message
285-
self.error_type = error_type
286-
self.data = data
287-
self.stack_trace = stack_trace
314+
) -> DurableOperationError:
315+
"""Rebuild the correct subclass from serialized checkpoint error fields.
316+
317+
Looks the discriminator up in the reconstruction registry, falling back
318+
to the base :class:`DurableOperationError` when ``error_type`` is unknown
319+
(e.g. a downstream error surfaced by an async invoke/callback checkpoint).
320+
"""
321+
target_cls: type[DurableOperationError] = _DURABLE_OPERATION_ERROR_REGISTRY.get(
322+
error_type or "", DurableOperationError
323+
)
324+
return target_cls(
325+
message=message,
326+
error_type=error_type,
327+
data=data,
328+
stack_trace=stack_trace,
329+
)
330+
331+
332+
class StepError(DurableOperationError):
333+
"""Raised when a step operation fails."""
334+
335+
336+
class InvokeError(DurableOperationError):
337+
"""Raised when a durable invoke operation fails."""
338+
339+
340+
class ChildContextError(DurableOperationError):
341+
"""Raised when a child context (run_in_child_context, map, parallel) fails."""
342+
343+
344+
class WaitForConditionError(DurableOperationError):
345+
"""Raised when a wait_for_condition operation fails."""
288346

289347

290348
class StepInterruptedError(InvocationError):
@@ -397,37 +455,6 @@ def __init__(self, message: str, source_exception: Exception | None = None) -> N
397455
self.source_exception: Exception | None = source_exception
398456

399457

400-
@dataclass(frozen=True)
401-
class CallableRuntimeErrorSerializableDetails:
402-
"""Serializable error details."""
403-
404-
type: str
405-
message: str
406-
407-
@classmethod
408-
def from_exception(
409-
cls, exception: Exception
410-
) -> CallableRuntimeErrorSerializableDetails:
411-
"""Create an instance from an Exception, using its type and message.
412-
413-
Args:
414-
exception: An Exception instance
415-
416-
Returns:
417-
A CallableRuntimeErrorDetails instance with the exception's type name and message
418-
"""
419-
return cls(type=exception.__class__.__name__, message=str(exception))
420-
421-
def __str__(self) -> str:
422-
"""
423-
Return a string representation of the object.
424-
425-
Returns:
426-
A string in the format "type: message"
427-
"""
428-
return f"{self.type}: {self.message}"
429-
430-
431458
class SerDesError(DurableExecutionsError):
432459
"""Raised when serialization fails."""
433460

packages/aws-durable-execution-sdk-python/src/aws_durable_execution_sdk_python/lambda_service.py

Lines changed: 32 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -13,8 +13,8 @@
1313

1414
from aws_durable_execution_sdk_python.__about__ import __version__
1515
from aws_durable_execution_sdk_python.exceptions import (
16-
CallableRuntimeError,
1716
CheckpointError,
17+
DurableOperationError,
1818
GetExecutionStateError,
1919
)
2020

@@ -235,13 +235,41 @@ def from_dict(cls, data: MutableMapping[str, Any]) -> ErrorObject:
235235

236236
@classmethod
237237
def from_exception(cls, exception: Exception) -> ErrorObject:
238+
# Preserve a DurableOperationError's discriminator/data/stack_trace so the
239+
# typed info survives across operation boundaries.
240+
if isinstance(exception, DurableOperationError):
241+
return cls(
242+
message=exception.message,
243+
type=exception.error_type,
244+
data=cls._resolve_error_data(exception),
245+
stack_trace=exception.stack_trace,
246+
)
238247
return cls(
239248
message=str(exception),
240249
type=type(exception).__name__,
241250
data=None,
242251
stack_trace=None,
243252
)
244253

254+
@staticmethod
255+
def _resolve_error_data(error: DurableOperationError) -> str | None:
256+
"""Surface the first non-None ``data`` along the cause chain.
257+
258+
Ensures error data is not lost when a DurableOperationError is re-wrapped
259+
across nested operation boundaries (e.g. a step failure escaping through
260+
multiple run_in_child_context calls).
261+
"""
262+
if error.data is not None:
263+
return error.data
264+
node: BaseException | None = error.__cause__
265+
for _ in range(10):
266+
if node is None:
267+
break
268+
if isinstance(node, DurableOperationError) and node.data is not None:
269+
return node.data
270+
node = node.__cause__
271+
return None
272+
245273
@classmethod
246274
def from_message(cls, message: str) -> ErrorObject:
247275
return cls(
@@ -263,10 +291,10 @@ def to_dict(self) -> MutableMapping[str, Any]:
263291
result["StackTrace"] = self.stack_trace
264292
return result
265293

266-
def to_callable_runtime_error(self) -> CallableRuntimeError:
267-
return CallableRuntimeError(
268-
message=self.message,
294+
def to_durable_operation_error(self) -> DurableOperationError:
295+
return DurableOperationError.from_error_fields(
269296
error_type=self.type,
297+
message=self.message,
270298
data=self.data,
271299
stack_trace=self.stack_trace,
272300
)

0 commit comments

Comments
 (0)