Skip to content

feat(errors): typed per-operation error hierarchy#540

Open
ayushiahjolia wants to merge 3 commits into
mainfrom
feat/typed-operation-error-hierarchy
Open

feat(errors): typed per-operation error hierarchy#540
ayushiahjolia wants to merge 3 commits into
mainfrom
feat/typed-operation-error-hierarchy

Conversation

@ayushiahjolia

@ayushiahjolia ayushiahjolia commented Jul 14, 2026

Copy link
Copy Markdown
Contributor

Issue #, if available:
#120
#546

Description of changes:

Replaces the single catch-all CallableRuntimeError with a typed, per-operation error hierarchy. A failed step, invoke, child context, or wait_for_condition now surfaces as a distinct, catchable exception type. It also lands the from_exception/throw_if_error changes those types require. The CallbackError rework and the nested-boundary regression test are intentionally deferred to follow-up PRs.

Breaking changes

  • Operation failures now raise the typed subclasses (StepError, InvokeError, ChildContextError, WaitForConditionError) instead of CallableRuntimeError (removed).
  • Catch on the exception class to identify the operation. The error_type attribute carries the original Python exception name (e.g. "ValueError"); the original message is on .message, and the original error is attached as cause (the live exception on first run, a reconstructed stand-in on replay). At the wire/checkpoint level, an escaping wrapper is recorded under its class-name discriminator (e.g. "StepError") one level up, while the operation's own FAIL checkpoint records the raw error type.
  • wait_for_condition check failures are now wrapped in WaitForConditionError rather than re-raised raw.
  • wait_for_callback/callback failures now surface as CallbackError (previously CallableRuntimeError); CallbackError remains in the termination tree until the follow-up.
  • An exhausted interrupted step fails immediately as StepError rather than round-tripping through a Lambda retry.

Testing

  • Updated/added unit tests across exceptions, lambda_service, state, concurrency, and the four operation handlers.
  • Added coverage for: registry + from_error_fields (including fallback), from_exception field preservation across a single operation boundary, raise_operation_error, wait_for_condition control-flow errors propagating unwrapped, and the execution result recording the StepError discriminator while the step's own FAIL checkpoint records the raw error type.
  • Added an integration test and an example; the example also verifies the caught error is identical across a wait/replay boundary.

Follow-ups

  • Move CallbackError under DurableOperationError with graded subclasses (CallbackExternalError, CallbackTimeoutError, CallbackSubmitterError) and drop CALLBACK_ERROR metadata.
  • Regression test for data surviving ≥2 nested run_in_child_context boundaries

By submitting this pull request, I confirm that you can use, modify, copy, and redistribute this contribution, under the terms of your choice.

@ayushiahjolia ayushiahjolia added this to the v2 milestone Jul 14, 2026
@ayushiahjolia
ayushiahjolia force-pushed the feat/typed-operation-error-hierarchy branch from e538dd6 to 5a8e07e Compare July 14, 2026 22:20
@ayushiahjolia
ayushiahjolia force-pushed the feat/typed-operation-error-hierarchy branch from 5a8e07e to a92d9cc Compare July 14, 2026 23:48
@ayushiahjolia ayushiahjolia removed this from the v2 milestone Jul 16, 2026
@ayushiahjolia
ayushiahjolia force-pushed the feat/typed-operation-error-hierarchy branch 6 times, most recently from 51efedf to f97cd67 Compare July 16, 2026 21:01
@ayushiahjolia
ayushiahjolia marked this pull request as ready for review July 16, 2026 23:05
sub_type=self.sub_type,
)
)
raise

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Not replay safe if ExecutionError is thrown

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.

Good catch. first-run raises raw ExecutionError but replay gives ChildContextError. I'll wrap ExecutionError as ChildContextError so first run and replay match, and while I am here, I'll do the same in wait_for_condition.

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.

Bug for InvocationError - #546

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Right now a non-retryable BotoClientError skips the FAIL checkpoint and the context op stays STARTED in a FAILED execution. Same in wait_for_condition.py.

Gate prob needs to be isinstance(e, InvocationError) and e.is_retryable() per #546? If so, please add the check and a non-retryable test case.

# error records its own type; a typed wrapper records its operation kind.
# For a DurableOperationError also preserve its own data and stack_trace
# so the inner info survives across a single operation boundary.
if isinstance(exception, DurableOperationError):

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Issue #297 again?

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.

yes I am going to fix it separately

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

thanks! will #297 stay open for tracking?

@ayushiahjolia
ayushiahjolia force-pushed the feat/typed-operation-error-hierarchy branch from 52614ad to e913712 Compare July 17, 2026 21:03
@ayushiahjolia
ayushiahjolia force-pushed the feat/typed-operation-error-hierarchy branch from e913712 to 44d9963 Compare July 17, 2026 21:08

def __init_subclass__(cls, **kwargs: object) -> None:
super().__init_subclass__(**kwargs)
_DURABLE_OPERATION_ERROR_REGISTRY[cls.__name__] = cls

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

__init_subclass__ registers every subclass anyone defines, including user-space code, so the wire-reconstruction path ends up calling constructors the SDK doesn't control?

what happens if this raises out of the Step?

class CustomError(StepError):
    def __init__(self, my_arg: str):
        super().__init__(f"arb: {my_arg}")
  1. The step fails, and the FAIL checkpoint records ErrorType: "CustomError" (the checkpoint always stores the raw escaping type.
  2. On retry-exhaustion (first run) or replay, the SDK reconstructs: registry lookup finds CustomError, calls it with message=..., error_type=..., data=..., stack_trace=....
  3. But init only accepts my_arg, so
TypeError: PaymentDeclinedError.__init__() got an unexpected keyword argument 'message'

It might be an idea to close the registry, so instead of init_subclass with an explicit dict literal of the SDK's own classes, built once after the class definitions, without metaclass magic. i.e something like

@classmethod
def from_error_fields(cls, error_type, message, data, stack_trace) -> DurableOperationError:
    target_cls: type[DurableOperationError] = _DURABLE_OPERATION_ERROR_REGISTRY.get(
        error_type or "", DurableOperationError
    )
    return target_cls(message=message, error_type=error_type, data=data, stack_trace=stack_trace)

For comparison, the JS reference implementation demotes any unknown type to StepError, so a JS user subclassing StepError gets the same fallback.

# error records its own type; a typed wrapper records its operation kind.
# For a DurableOperationError also preserve its own data and stack_trace
# so the inner info survives across a single operation boundary.
if isinstance(exception, DurableOperationError):

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

thanks! will #297 stay open for tracking?

def raise_operation_error(
self,
operation_error_cls: type[DurableOperationError],
msg: str | None = None,

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

NoReturn, rather than None?

InvokeError,
StepError,
ValidationError,
WaitForConditionError,

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

CallbackError not here?

saying that, we could argue only to keep the "big" or notable err types here in the root export. InvocationError/ExecutionError/DurableOperationError/ValidationError? Not necessarily convinced of this, mind, you could equally argue these should be top-level exports too 😅

# ChildContextError wrapping the escaping error (reconstructed as its
# typed form where possible). Remaining errors stay in get_errors().
cause: DurableOperationError = first_error.to_durable_operation_error()
raise ChildContextError(

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

doesn't this duplicate what first_error.raise_as_operation_error(ChildContextError) does?

sub_type=self.sub_type,
)
)
raise

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Right now a non-retryable BotoClientError skips the FAIL checkpoint and the context op stays STARTED in a FAILED execution. Same in wait_for_condition.py.

Gate prob needs to be isinstance(e, InvocationError) and e.is_retryable() per #546? If so, please add the check and a non-retryable test case.

# Each item runs in a child context, so a failed item surfaces as a
# ChildContextError wrapping the escaping error (reconstructed as its
# typed form where possible). Remaining errors stay in get_errors().
cause: DurableOperationError = first_error.to_durable_operation_error()

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

first_error here holds a different record live vs replay, I think.

the live path stores from_exception(ChildContextError) (type="ChildContextError", executor.py _create_result), replay stores the raw checkpoint error (type="ValueError").

So the ChildContextError raised here carries a different error_type across replay.

This is a pre-existing issue, but we might as well take this oppo. to fix this and normalize the live path in _create_result to build the ErrorObject from the wrapper's fields (type=e.error_type) so both paths equal the checkpoint record.

assert result.error.to_dict() == {
"ErrorMessage": "my callback error",
"ErrorType": "CallableRuntimeError",
"ErrorType": "ChildContextError",

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

I see in PR description callback is deferred for follow up, so jotting this down so I don't forget:

with this PR, wait_for_callback failures surface as ChildContextError.

The desired end state is JS's behavior (wait-for-callback-handler.ts errorMapper): callback errors pass through typed (CallbackError/CallbackTimeoutError/CallbackExternalError), submitter StepError maps to CallbackSubmitterError.

So the graded-callback follow-up will change this surface again.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants