Skip to content

Commit e538dd6

Browse files
committed
feat(errors): typed per-operation error hierarchy
1 parent a982db2 commit e538dd6

20 files changed

Lines changed: 418 additions & 185 deletions

File tree

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
)

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

Lines changed: 23 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77

88
from aws_durable_execution_sdk_python.config import ChildConfig
99
from aws_durable_execution_sdk_python.exceptions import (
10+
ChildContextError,
1011
InvocationError,
1112
SuspendExecution,
1213
)
@@ -78,7 +79,7 @@ def check_result_status(self) -> CheckResult[T]:
7879
CheckResult indicating the next action to take
7980
8081
Raises:
81-
CallableRuntimeError: For FAILED operations
82+
ChildContextError: For FAILED operations
8283
"""
8384
checkpointed_result: CheckpointedResult = self.state.get_checkpoint_result(
8485
self.operation_identifier.operation_id
@@ -114,7 +115,7 @@ def check_result_status(self) -> CheckResult[T]:
114115

115116
# Terminal failure
116117
if checkpointed_result.is_failed():
117-
checkpointed_result.raise_callable_error()
118+
checkpointed_result.raise_operation_error(ChildContextError)
118119

119120
# Create START checkpoint if not exists
120121
if not checkpointed_result.is_existent() and not self.is_virtual:
@@ -145,7 +146,7 @@ def execute(self, checkpointed_result: CheckpointedResult) -> T:
145146
Raises:
146147
SuspendExecution: Re-raised without checkpointing
147148
InvocationError: Re-raised after checkpointing FAIL
148-
CallableRuntimeError: Raised for other exceptions after checkpointing FAIL
149+
ChildContextError: Raised for other exceptions after checkpointing FAIL
149150
"""
150151
logger.debug(
151152
"▶️ Executing child context for id: %s, name: %s",
@@ -239,7 +240,24 @@ def execute(self, checkpointed_result: CheckpointedResult) -> T:
239240
# Don't checkpoint SuspendExecution - let it bubble up
240241
raise
241242
except Exception as e:
242-
error_object = ErrorObject.from_exception(e)
243+
# InvocationError and its derivatives bubble up (with their own type,
244+
# not wrapped) so the top-level handler can make the backend retry.
245+
if isinstance(e, InvocationError):
246+
if not self.is_virtual:
247+
self.state.create_checkpoint(
248+
operation_update=OperationUpdate.create_context_fail(
249+
identifier=self.operation_identifier,
250+
error=ErrorObject.from_exception(e),
251+
sub_type=self.sub_type,
252+
)
253+
)
254+
raise
255+
256+
# Wrap the failure in a ChildContextError (original kept as __cause__)
257+
# so the checkpoint records the "ChildContextError" discriminator.
258+
child_error: ChildContextError = ChildContextError(message=str(e))
259+
child_error.__cause__ = e
260+
error_object = ErrorObject.from_exception(child_error)
243261
# Virtual deliberately does not write checkpoints, but exception still propagates below
244262
if not self.is_virtual:
245263
fail_operation: OperationUpdate = OperationUpdate.create_context_fail(
@@ -252,14 +270,7 @@ def execute(self, checkpointed_result: CheckpointedResult) -> T:
252270
# This guarantees the error is durable and child operations won't be re-executed on replay.
253271
self.state.create_checkpoint(operation_update=fail_operation)
254272

255-
# InvocationError and its derivatives can be retried.
256-
# When we encounter an invocation error (in all of its forms), we
257-
# bubble that error upwards (with the checkpoint in place for
258-
# non-virtual) such that we reach the execution handler at the
259-
# very top, which will then make the backend retry.
260-
if isinstance(e, InvocationError):
261-
raise
262-
raise error_object.to_callable_runtime_error() from e
273+
raise child_error from e
263274

264275

265276
def child_handler(

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

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@
55
import logging
66
from typing import TYPE_CHECKING, TypeVar
77

8-
from aws_durable_execution_sdk_python.exceptions import ExecutionError
8+
from aws_durable_execution_sdk_python.exceptions import ExecutionError, InvokeError
99
from aws_durable_execution_sdk_python.lambda_service import (
1010
ChainedInvokeOptions,
1111
OperationUpdate,
@@ -81,7 +81,7 @@ def check_result_status(self) -> CheckResult[R]:
8181
CheckResult indicating the next action to take
8282
8383
Raises:
84-
CallableRuntimeError: For FAILED, TIMED_OUT, or STOPPED operations
84+
InvokeError: For FAILED, TIMED_OUT, or STOPPED operations
8585
SuspendExecution: For STARTED operations waiting for completion
8686
"""
8787
checkpointed_result: CheckpointedResult = self.state.get_checkpoint_result(
@@ -107,7 +107,7 @@ def check_result_status(self) -> CheckResult[R]:
107107
or checkpointed_result.is_timed_out()
108108
or checkpointed_result.is_stopped()
109109
):
110-
checkpointed_result.raise_callable_error()
110+
checkpointed_result.raise_operation_error(InvokeError)
111111

112112
# Still running - ready to suspend
113113
if checkpointed_result.is_started():

0 commit comments

Comments
 (0)