From 038f371365c628232240b71ad60575dd88a656e0 Mon Sep 17 00:00:00 2001 From: Andy Staples Date: Fri, 24 Jul 2026 15:16:31 -0600 Subject: [PATCH] Add FailureDetails.is_caused_by() and fully-qualified error types Mirrors .NET's TaskFailureDetails.IsCausedBy() and Java's FailureDetails.isCausedBy(Class). Adds FailureDetails.is_caused_by(), which is base-type aware for exception types (via a subclass walk over already-imported classes, with no arbitrary imports) and name-based for strings. BREAKING: FailureDetails.error_type (and the errorType wire value) now uses the fully-qualified type name (e.g. builtins.ValueError, durabletask.task.TaskFailedError) instead of the bare class name, to match the .NET and Java SDKs. Bare names produced by older/foreign workers are still matched for back-compat. Fixes #162 Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 95968239-5578-4d49-a049-31ab007b18d8 --- CHANGELOG.md | 8 + durabletask-azuremanaged/CHANGELOG.md | 2 + durabletask/internal/helpers.py | 21 ++- durabletask/task.py | 82 +++++++++ durabletask/testing/in_memory_backend.py | 2 +- .../test_dts_entity_failure_handling.py | 4 +- .../test_dts_async_orchestration_e2e.py | 6 +- .../test_dts_orchestration_e2e.py | 6 +- .../entities/test_entity_failure_handling.py | 4 +- tests/durabletask/test_failure_details.py | 167 ++++++++++++++++++ tests/durabletask/test_orchestration_e2e.py | 4 +- .../test_orchestration_executor.py | 22 +-- .../durabletask/test_worker_activity_hooks.py | 2 +- 13 files changed, 304 insertions(+), 26 deletions(-) create mode 100644 tests/durabletask/test_failure_details.py diff --git a/CHANGELOG.md b/CHANGELOG.md index efcc952a..99372609 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,14 @@ adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). ## Unreleased +ADDED + +- Added `FailureDetails.is_caused_by()` to check whether a task failure was caused by a given exception type, mirroring .NET's `TaskFailureDetails.IsCausedBy()` and Java's `FailureDetails.isCausedBy()`. Passing an exception type performs a base-type-aware match (a failure caused by a subclass matches its base type) against exception classes already imported in the process; passing a string matches by qualified or unqualified name. + +CHANGED + +- **Breaking:** `FailureDetails.error_type` — and the `errorType` value sent over the wire — is now the fully-qualified type name (`module.ClassName`, e.g. `builtins.ValueError`, `durabletask.task.TaskFailedError`) instead of the bare class name, matching the .NET and Java SDKs. Code that compared `error_type` against a bare name (for example `== "ValueError"`) must be updated to the qualified name or, preferably, switched to `FailureDetails.is_caused_by()`. Because this value is persisted and crosses the orchestration boundary, failures produced by older workers may still carry a bare name; `is_caused_by()` accepts both. + ## v1.8.0 ADDED diff --git a/durabletask-azuremanaged/CHANGELOG.md b/durabletask-azuremanaged/CHANGELOG.md index d8b9d5fa..300d855b 100644 --- a/durabletask-azuremanaged/CHANGELOG.md +++ b/durabletask-azuremanaged/CHANGELOG.md @@ -7,6 +7,8 @@ adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). ## Unreleased +- `FailureDetails.error_type` now carries the fully-qualified type name (e.g. `durabletask.task.TaskFailedError`) instead of the bare class name, and the new `FailureDetails.is_caused_by()` helper is available (both inherited from durabletask). See the core `durabletask` changelog for details, including the breaking-change notes. + ## v1.8.0 - Updates base dependency to durabletask v1.8.0. diff --git a/durabletask/internal/helpers.py b/durabletask/internal/helpers.py index 23928e96..e96e88c9 100644 --- a/durabletask/internal/helpers.py +++ b/durabletask/internal/helpers.py @@ -122,6 +122,25 @@ def new_sub_orchestration_failed_event(event_id: int, ex: Exception) -> pb.Histo ) +def get_qualified_name(t: type) -> str: + """Return the fully-qualified ``module.qualname`` name of a type. + + This mirrors the fully-qualified error-type names produced by the other + Durable Task SDKs (.NET ``Type.ToString()`` and Java ``Class.getName()``), + so that :attr:`FailureDetails.error_type` values are consistent across SDKs + and can be matched unambiguously by :meth:`FailureDetails.is_caused_by`. + + Builtin exception types keep their ``builtins.`` prefix (e.g. + ``builtins.ValueError``), matching how .NET keeps ``System.`` and Java keeps + ``java.lang.``. + """ + module = getattr(t, "__module__", None) + qualname = getattr(t, "__qualname__", None) or getattr(t, "__name__", None) or str(t) + if not module: + return qualname + return f"{module}.{qualname}" + + def new_failure_details(ex: Exception, _visited: set[int] | None = None) -> pb.TaskFailureDetails: if _visited is None: _visited = set() @@ -130,7 +149,7 @@ def new_failure_details(ex: Exception, _visited: set[int] | None = None) -> pb.T if len(_visited) > 10 or (inner and id(inner) in _visited) or not isinstance(inner, Exception): inner = None return pb.TaskFailureDetails( - errorType=type(ex).__name__, + errorType=get_qualified_name(type(ex)), errorMessage=str(ex), stackTrace=wrappers_pb2.StringValue(value=''.join(traceback.format_tb(ex.__traceback__))), innerFailure=new_failure_details(inner, _visited) if inner else None diff --git a/durabletask/task.py b/durabletask/task.py index 5d51c31d..168966a8 100644 --- a/durabletask/task.py +++ b/durabletask/task.py @@ -456,6 +456,88 @@ class FailureDetails: error_type: str stack_trace: str | None + def is_caused_by(self, error_type: str | type[BaseException]) -> bool: + """Return ``True`` if this failure was caused by ``error_type``. + + This mirrors the .NET ``TaskFailureDetails.IsCausedBy()`` and Java + ``FailureDetails.isCausedBy(Class)`` helpers, letting a caller introspect + a failure instead of comparing :attr:`error_type` strings by hand:: + + try: + yield ctx.call_activity(my_activity) + except TaskFailedError as e: + if e.details.is_caused_by(ValueError): + ... + + ``error_type`` may be either an exception type or a name string: + + * When an exception **type** is given, the check is base-type aware: it + returns ``True`` if the failure's type is ``error_type`` or any of its + subclasses. To stay safe, this only inspects exception subclasses that + are already imported in the current process (it never imports a type by + name), so a subclass whose module has not been imported yet will not be + matched. Passing a non-exception type raises :class:`TypeError`. + * When a **string** is given, it is compared by name only (no base-type + awareness). The name may be fully qualified (``"module.ClassName"``) or + unqualified (``"ClassName"``); an unqualified name matches on the class + name alone and therefore cannot distinguish same-named types from + different modules. + + Only this failure's own :attr:`error_type` is considered; chained/inner + causes are not traversed. + """ + if isinstance(error_type, str): + return self._name_matches(error_type) + # The annotation restricts callers to exception types, but this is a + # public API, so validate at runtime against arbitrary objects. + if not (isinstance(error_type, type) and issubclass(error_type, BaseException)): # pyright: ignore[reportUnnecessaryIsInstance] + raise TypeError( + "is_caused_by() expects an exception type or a type-name string, " + f"but got {error_type!r}.") + + # Base-type-aware match without importing the failure's type: walk the + # target type and its already-loaded subclasses (visited-marked to guard + # against diamond inheritance) and compare each candidate's qualified + # name against this failure's error_type. + visited: set[int] = set() + stack: list[type] = [error_type] + while stack: + candidate = stack.pop() + if id(candidate) in visited: + continue + visited.add(id(candidate)) + if self._type_matches(candidate): + return True + stack.extend(candidate.__subclasses__()) + return False + + def _type_matches(self, candidate: type) -> bool: + if not self.error_type: + return False + candidate_name = pbh.get_qualified_name(candidate) + if self.error_type == candidate_name: + return True + # Back-compat: a bare (non-qualified) stored name -- e.g. produced by an + # older SDK version or a non-Python SDK -- can only be compared by the + # unqualified class name. + if "." not in self.error_type: + return self.error_type == candidate_name.rsplit(".", 1)[-1] + return False + + def _name_matches(self, name: str) -> bool: + stored = self.error_type + if not stored or not name: + return False + if stored == name: + return True + # When both names are fully qualified, require an exact match so that + # same-named types from different modules are not confused. Otherwise, + # honor the "qualified or unqualified" contract by comparing the + # unqualified (trailing) segment. + if "." in stored and "." in name: + return False + return stored.rsplit(".", 1)[-1] == name.rsplit(".", 1)[-1] + class TaskFailedError(Exception): """Exception type for all orchestration task failures.""" diff --git a/durabletask/testing/in_memory_backend.py b/durabletask/testing/in_memory_backend.py index 27ab2abe..98766795 100644 --- a/durabletask/testing/in_memory_backend.py +++ b/durabletask/testing/in_memory_backend.py @@ -799,7 +799,7 @@ def CompleteOrchestratorTask(self, request: pb.OrchestratorResponse, context: gr if not self._is_terminal_status(instance.status): instance.status = pb.ORCHESTRATION_STATUS_FAILED instance.failure_details = pb.TaskFailureDetails( - errorType=type(e).__name__, + errorType=helpers.get_qualified_name(type(e)), errorMessage=str(e), isNonRetriable=True, ) diff --git a/tests/durabletask-azuremanaged/entities/test_dts_entity_failure_handling.py b/tests/durabletask-azuremanaged/entities/test_dts_entity_failure_handling.py index 5cc1d444..a4c649e0 100644 --- a/tests/durabletask-azuremanaged/entities/test_dts_entity_failure_handling.py +++ b/tests/durabletask-azuremanaged/entities/test_dts_entity_failure_handling.py @@ -38,7 +38,7 @@ def test_orchestrator(ctx: task.OrchestrationContext, _): assert state.name == task.get_name(test_orchestrator) assert state.instance_id == id assert state.failure_details is not None - assert state.failure_details.error_type == "TaskFailedError" + assert state.failure_details.error_type == "durabletask.task.TaskFailedError" # NOTE: Because FailureDetails does not support inner_failure, we can't verify that the inner failure type is # EntityOperationFailedException. In the future, we should consider adding support for inner failures in # FailureDetails to make this more robust. This applies to all tests in this file. For now, the error message's @@ -72,7 +72,7 @@ def test_orchestrator(ctx: task.OrchestrationContext, _): assert state.name == task.get_name(test_orchestrator) assert state.instance_id == id assert state.failure_details is not None - assert state.failure_details.error_type == "TaskFailedError" + assert state.failure_details.error_type == "durabletask.task.TaskFailedError" assert state.failure_details.message == "Operation 'fail' on entity '@failing_entity@testEntity' failed with " \ "error: Something went wrong!" assert state.runtime_status == client.OrchestrationStatus.FAILED diff --git a/tests/durabletask-azuremanaged/test_dts_async_orchestration_e2e.py b/tests/durabletask-azuremanaged/test_dts_async_orchestration_e2e.py index 37887381..5f244382 100644 --- a/tests/durabletask-azuremanaged/test_dts_async_orchestration_e2e.py +++ b/tests/durabletask-azuremanaged/test_dts_async_orchestration_e2e.py @@ -411,7 +411,7 @@ def throw_activity_with_retry(ctx: task.ActivityContext, _): assert state is not None assert state.runtime_status == client.OrchestrationStatus.FAILED assert state.failure_details is not None - assert state.failure_details.error_type == "TaskFailedError" + assert state.failure_details.error_type == "durabletask.task.TaskFailedError" assert state.failure_details.message.startswith("Sub-orchestration task #1 failed:") assert state.failure_details.message.endswith("Activity task #1 failed: Kah-BOOOOM!!!") assert state.failure_details.stack_trace is not None @@ -449,7 +449,7 @@ def throw_activity(ctx: task.ActivityContext, _): assert state is not None assert state.runtime_status == client.OrchestrationStatus.FAILED assert state.failure_details is not None - assert state.failure_details.error_type == "TaskFailedError" + assert state.failure_details.error_type == "durabletask.task.TaskFailedError" assert state.failure_details.message.endswith("Activity task #1 failed: Kah-BOOOOM!!!") assert state.failure_details.stack_trace is not None assert throw_activity_counter == 4 @@ -536,7 +536,7 @@ def test_orchestrator(ctx: task.OrchestrationContext, _): assert state.name == task.get_name(test_orchestrator) assert state.instance_id == id assert state.failure_details is not None - assert state.failure_details.error_type == "JsonEncodeOutputException" + assert state.failure_details.error_type == "durabletask.internal.json_encode_output_exception.JsonEncodeOutputException" assert state.failure_details.message.startswith("The orchestration result could not be encoded. Object details:") assert state.failure_details.message.find("This is not JSON serializable") != -1 assert state.runtime_status == client.OrchestrationStatus.FAILED diff --git a/tests/durabletask-azuremanaged/test_dts_orchestration_e2e.py b/tests/durabletask-azuremanaged/test_dts_orchestration_e2e.py index 5fa54b17..14f9ce05 100644 --- a/tests/durabletask-azuremanaged/test_dts_orchestration_e2e.py +++ b/tests/durabletask-azuremanaged/test_dts_orchestration_e2e.py @@ -538,7 +538,7 @@ def throw_activity_with_retry(ctx: task.ActivityContext, _): assert state is not None assert state.runtime_status == client.OrchestrationStatus.FAILED assert state.failure_details is not None - assert state.failure_details.error_type == "TaskFailedError" + assert state.failure_details.error_type == "durabletask.task.TaskFailedError" assert state.failure_details.message.startswith("Sub-orchestration task #1 failed:") assert state.failure_details.message.endswith("Activity task #1 failed: Kah-BOOOOM!!!") assert state.failure_details.stack_trace is not None @@ -580,7 +580,7 @@ def throw_activity(ctx: task.ActivityContext, _): assert state is not None assert state.runtime_status == client.OrchestrationStatus.FAILED assert state.failure_details is not None - assert state.failure_details.error_type == "TaskFailedError" + assert state.failure_details.error_type == "durabletask.task.TaskFailedError" assert state.failure_details.message.endswith("Activity task #1 failed: Kah-BOOOOM!!!") assert state.failure_details.stack_trace is not None assert throw_activity_counter == 4 @@ -667,7 +667,7 @@ def test_orchestrator(ctx: task.OrchestrationContext, _): assert state.name == task.get_name(test_orchestrator) assert state.instance_id == id assert state.failure_details is not None - assert state.failure_details.error_type == "JsonEncodeOutputException" + assert state.failure_details.error_type == "durabletask.internal.json_encode_output_exception.JsonEncodeOutputException" assert state.failure_details.message.startswith("The orchestration result could not be encoded. Object details:") assert state.failure_details.message.find("This is not JSON serializable") != -1 assert state.runtime_status == client.OrchestrationStatus.FAILED diff --git a/tests/durabletask/entities/test_entity_failure_handling.py b/tests/durabletask/entities/test_entity_failure_handling.py index 44639645..8a43d75c 100644 --- a/tests/durabletask/entities/test_entity_failure_handling.py +++ b/tests/durabletask/entities/test_entity_failure_handling.py @@ -50,7 +50,7 @@ def test_orchestrator(ctx: task.OrchestrationContext, _): assert state.name == task.get_name(test_orchestrator) assert state.instance_id == id assert state.failure_details is not None - assert state.failure_details.error_type == "TaskFailedError" + assert state.failure_details.error_type == "durabletask.task.TaskFailedError" assert "Something went wrong!" in state.failure_details.message assert state.runtime_status == client.OrchestrationStatus.FAILED @@ -77,7 +77,7 @@ def test_orchestrator(ctx: task.OrchestrationContext, _): assert state.name == task.get_name(test_orchestrator) assert state.instance_id == id assert state.failure_details is not None - assert state.failure_details.error_type == "TaskFailedError" + assert state.failure_details.error_type == "durabletask.task.TaskFailedError" assert "Something went wrong!" in state.failure_details.message assert state.runtime_status == client.OrchestrationStatus.FAILED diff --git a/tests/durabletask/test_failure_details.py b/tests/durabletask/test_failure_details.py new file mode 100644 index 00000000..69a2dab5 --- /dev/null +++ b/tests/durabletask/test_failure_details.py @@ -0,0 +1,167 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + +"""Unit tests for :class:`durabletask.task.FailureDetails` introspection.""" + +import pytest + +from durabletask import task, worker +from durabletask.internal import helpers + + +class _BaseError(Exception): + pass + + +class _DerivedError(_BaseError): + pass + + +def _fd(error_type: str) -> task.FailureDetails: + return task.FailureDetails(message="boom", error_type=error_type, stack_trace=None) + + +# --------------------------------------------------------------------------- +# get_qualified_name +# --------------------------------------------------------------------------- + +def test_get_qualified_name_builtin_keeps_builtins_prefix(): + assert helpers.get_qualified_name(ValueError) == "builtins.ValueError" + + +def test_get_qualified_name_module_type(): + assert helpers.get_qualified_name(task.NonDeterminismError) == \ + "durabletask.task.NonDeterminismError" + + +def test_get_qualified_name_preserves_nested_qualname(): + class Outer: + class Inner(Exception): + pass + + assert helpers.get_qualified_name(Outer.Inner).endswith(".Outer.Inner") + + +# --------------------------------------------------------------------------- +# new_failure_details now emits fully-qualified names +# --------------------------------------------------------------------------- + +def test_new_failure_details_uses_fqn_for_builtin(): + assert helpers.new_failure_details(ValueError("x")).errorType == "builtins.ValueError" + + +def test_new_failure_details_uses_fqn_for_custom_type(): + details = helpers.new_failure_details(_DerivedError("x")) + assert details.errorType == helpers.get_qualified_name(_DerivedError) + + +# --------------------------------------------------------------------------- +# is_caused_by with an exception type (base-type aware) +# --------------------------------------------------------------------------- + +def test_is_caused_by_exact_builtin_type(): + assert _fd("builtins.ValueError").is_caused_by(ValueError) is True + + +def test_is_caused_by_matches_base_type(): + fd = _fd("builtins.FileNotFoundError") # subclass of OSError + assert fd.is_caused_by(FileNotFoundError) is True + assert fd.is_caused_by(OSError) is True + assert fd.is_caused_by(ValueError) is False + + +def test_is_caused_by_custom_hierarchy(): + fd = _fd(helpers.get_qualified_name(_DerivedError)) + assert fd.is_caused_by(_DerivedError) is True + assert fd.is_caused_by(_BaseError) is True + assert fd.is_caused_by(Exception) is True + assert fd.is_caused_by(KeyError) is False + + +def test_is_caused_by_does_not_match_more_derived_type(): + # A base-class failure is not "caused by" one of its subclasses. + fd = _fd(helpers.get_qualified_name(_BaseError)) + assert fd.is_caused_by(_DerivedError) is False + + +def test_is_caused_by_base_type_across_modules(): + # OrchestratorNotRegisteredError (durabletask.worker) subclasses ValueError. + fd = _fd("durabletask.worker.OrchestratorNotRegisteredError") + assert fd.is_caused_by(worker.OrchestratorNotRegisteredError) is True + assert fd.is_caused_by(ValueError) is True + + +def test_is_caused_by_fqn_disambiguates_same_simple_name(): + # A fully-qualified stored name from a different module must not match a + # local type that merely shares the simple name. + fd = _fd("some.other.module.ValueError") + assert fd.is_caused_by(ValueError) is False + + +# --------------------------------------------------------------------------- +# is_caused_by with a name string +# --------------------------------------------------------------------------- + +def test_is_caused_by_string_exact_fqn(): + assert _fd("builtins.ValueError").is_caused_by("builtins.ValueError") is True + + +def test_is_caused_by_string_unqualified_name(): + assert _fd("builtins.ValueError").is_caused_by("ValueError") is True + + +def test_is_caused_by_string_no_match(): + assert _fd("builtins.ValueError").is_caused_by("KeyError") is False + + +def test_is_caused_by_string_two_fqns_from_different_modules_do_not_match(): + assert _fd("pkg_a.Foo").is_caused_by("pkg_b.Foo") is False + + +# --------------------------------------------------------------------------- +# Back-compat: a bare (non-qualified) stored error_type +# --------------------------------------------------------------------------- + +def test_is_caused_by_type_with_legacy_bare_stored_name(): + assert _fd("ValueError").is_caused_by(ValueError) is True + assert _fd("FileNotFoundError").is_caused_by(OSError) is True + + +def test_is_caused_by_string_qualified_arg_matches_bare_stored_name(): + assert _fd("ValueError").is_caused_by("builtins.ValueError") is True + + +# --------------------------------------------------------------------------- +# Edge cases / errors +# --------------------------------------------------------------------------- + +def test_is_caused_by_empty_error_type_is_false(): + assert _fd("").is_caused_by(ValueError) is False + assert _fd("").is_caused_by("ValueError") is False + + +def test_is_caused_by_non_exception_type_raises_type_error(): + with pytest.raises(TypeError): + _fd("builtins.ValueError").is_caused_by(int) # type: ignore[arg-type] + + +def test_is_caused_by_non_type_argument_raises_type_error(): + with pytest.raises(TypeError): + _fd("builtins.ValueError").is_caused_by(123) # type: ignore[arg-type] + + +def test_is_caused_by_exception_instance_raises_type_error(): + with pytest.raises(TypeError): + _fd("builtins.ValueError").is_caused_by(ValueError("x")) # type: ignore[arg-type] + + +# --------------------------------------------------------------------------- +# End-to-end through TaskFailedError.details +# --------------------------------------------------------------------------- + +def test_task_failed_error_details_is_caused_by(): + err = task.TaskFailedError("failed", ValueError("boom")) + assert err.details.error_type == "builtins.ValueError" + assert err.details.is_caused_by(ValueError) is True + assert err.details.is_caused_by(Exception) is True + assert err.details.is_caused_by(KeyError) is False diff --git a/tests/durabletask/test_orchestration_e2e.py b/tests/durabletask/test_orchestration_e2e.py index 2a22ec09..74fcf9ec 100644 --- a/tests/durabletask/test_orchestration_e2e.py +++ b/tests/durabletask/test_orchestration_e2e.py @@ -516,7 +516,7 @@ def throw_activity_with_retry(ctx: task.ActivityContext, _): assert state is not None assert state.runtime_status == client.OrchestrationStatus.FAILED assert state.failure_details is not None - assert state.failure_details.error_type == "TaskFailedError" + assert state.failure_details.error_type == "durabletask.task.TaskFailedError" assert state.failure_details.message.startswith("Sub-orchestration task #1 failed:") assert state.failure_details.message.endswith("Activity task #1 failed: Kah-BOOOOM!!!") assert state.failure_details.stack_trace is not None @@ -556,7 +556,7 @@ def throw_activity(ctx: task.ActivityContext, _): assert state is not None assert state.runtime_status == client.OrchestrationStatus.FAILED assert state.failure_details is not None - assert state.failure_details.error_type == "TaskFailedError" + assert state.failure_details.error_type == "durabletask.task.TaskFailedError" assert state.failure_details.message.endswith("Activity task #1 failed: Kah-BOOOOM!!!") assert state.failure_details.stack_trace is not None assert throw_activity_counter == 4 diff --git a/tests/durabletask/test_orchestration_executor.py b/tests/durabletask/test_orchestration_executor.py index 98068825..3df00a76 100644 --- a/tests/durabletask/test_orchestration_executor.py +++ b/tests/durabletask/test_orchestration_executor.py @@ -124,7 +124,7 @@ def test_orchestrator_not_registered(): complete_action = get_and_validate_complete_orchestration_action_list(1, actions) assert complete_action.orchestrationStatus == pb.ORCHESTRATION_STATUS_FAILED - assert complete_action.failureDetails.errorType == "OrchestratorNotRegisteredError" + assert complete_action.failureDetails.errorType == "durabletask.worker.OrchestratorNotRegisteredError" assert complete_action.failureDetails.errorMessage @@ -666,7 +666,7 @@ def orchestrator(ctx: task.OrchestrationContext, orchestrator_input): complete_action = get_and_validate_complete_orchestration_action_list(1, actions) assert complete_action.orchestrationStatus == pb.ORCHESTRATION_STATUS_FAILED - assert complete_action.failureDetails.errorType == 'TaskFailedError' # TODO: Should this be the specific error? + assert complete_action.failureDetails.errorType == 'durabletask.task.TaskFailedError' # TODO: Should this be the specific error? assert str(ex) in complete_action.failureDetails.errorMessage # Make sure the line of code where the exception was raised is included in the stack trace @@ -1128,7 +1128,7 @@ def orchestrator(ctx: task.OrchestrationContext, _): complete_action = get_and_validate_complete_orchestration_action_list(1, actions) assert complete_action.orchestrationStatus == pb.ORCHESTRATION_STATUS_FAILED - assert complete_action.failureDetails.errorType == 'NonDeterminismError' + assert complete_action.failureDetails.errorType == 'durabletask.task.NonDeterminismError' assert "1" in complete_action.failureDetails.errorMessage # task ID assert "create_timer" in complete_action.failureDetails.errorMessage # expected method name assert "call_activity" in complete_action.failureDetails.errorMessage # actual method name @@ -1156,7 +1156,7 @@ def orchestrator(ctx: task.OrchestrationContext, _): complete_action = get_and_validate_complete_orchestration_action_list(1, actions) assert complete_action.orchestrationStatus == pb.ORCHESTRATION_STATUS_FAILED - assert complete_action.failureDetails.errorType == 'NonDeterminismError' + assert complete_action.failureDetails.errorType == 'durabletask.task.NonDeterminismError' assert "1" in complete_action.failureDetails.errorMessage # task ID assert "call_activity" in complete_action.failureDetails.errorMessage # expected method name @@ -1186,7 +1186,7 @@ def orchestrator(ctx: task.OrchestrationContext, _): complete_action = get_and_validate_complete_orchestration_action_list(1, actions) assert complete_action.orchestrationStatus == pb.ORCHESTRATION_STATUS_FAILED - assert complete_action.failureDetails.errorType == 'NonDeterminismError' + assert complete_action.failureDetails.errorType == 'durabletask.task.NonDeterminismError' assert "1" in complete_action.failureDetails.errorMessage # task ID assert "call_activity" in complete_action.failureDetails.errorMessage # expected method name assert "create_timer" in complete_action.failureDetails.errorMessage # unexpected method name @@ -1217,7 +1217,7 @@ def orchestrator(ctx: task.OrchestrationContext, _): complete_action = get_and_validate_complete_orchestration_action_list(1, actions) assert complete_action.orchestrationStatus == pb.ORCHESTRATION_STATUS_FAILED - assert complete_action.failureDetails.errorType == 'NonDeterminismError' + assert complete_action.failureDetails.errorType == 'durabletask.task.NonDeterminismError' assert "1" in complete_action.failureDetails.errorMessage # task ID assert "call_activity" in complete_action.failureDetails.errorMessage # expected method name assert "original_activity" in complete_action.failureDetails.errorMessage # expected activity name @@ -1281,7 +1281,7 @@ def orchestrator(ctx: task.OrchestrationContext, _): complete_action = get_and_validate_complete_orchestration_action_list(1, actions) assert complete_action.orchestrationStatus == pb.ORCHESTRATION_STATUS_FAILED - assert complete_action.failureDetails.errorType == 'TaskFailedError' # TODO: Should this be the specific error? + assert complete_action.failureDetails.errorType == 'durabletask.task.TaskFailedError' # TODO: Should this be the specific error? assert str(ex) in complete_action.failureDetails.errorMessage # Make sure the line of code where the exception was raised is included in the stack trace @@ -1312,7 +1312,7 @@ def orchestrator(ctx: task.OrchestrationContext, _): complete_action = get_and_validate_complete_orchestration_action_list(1, actions) assert complete_action.orchestrationStatus == pb.ORCHESTRATION_STATUS_FAILED - assert complete_action.failureDetails.errorType == 'NonDeterminismError' + assert complete_action.failureDetails.errorType == 'durabletask.task.NonDeterminismError' assert "1" in complete_action.failureDetails.errorMessage # task ID assert "call_sub_orchestrator" in complete_action.failureDetails.errorMessage # expected method name @@ -1342,7 +1342,7 @@ def orchestrator(ctx: task.OrchestrationContext, _): complete_action = get_and_validate_complete_orchestration_action_list(1, actions) assert complete_action.orchestrationStatus == pb.ORCHESTRATION_STATUS_FAILED - assert complete_action.failureDetails.errorType == 'NonDeterminismError' + assert complete_action.failureDetails.errorType == 'durabletask.task.NonDeterminismError' assert "1" in complete_action.failureDetails.errorMessage # task ID assert "call_sub_orchestrator" in complete_action.failureDetails.errorMessage # expected method name @@ -1642,7 +1642,7 @@ def orchestrator(ctx: task.OrchestrationContext, _): complete_action = get_and_validate_complete_orchestration_action_list(1, actions) assert complete_action.orchestrationStatus == pb.ORCHESTRATION_STATUS_FAILED - assert complete_action.failureDetails.errorType == 'TaskFailedError' # TODO: Is this the right error type? + assert complete_action.failureDetails.errorType == 'durabletask.task.TaskFailedError' # TODO: Is this the right error type? assert str(ex) in complete_action.failureDetails.errorMessage @@ -2137,7 +2137,7 @@ def orchestrator(ctx: task.OrchestrationContext, _): actions = result.actions complete_action = get_and_validate_complete_orchestration_action_list(4, actions) assert complete_action.orchestrationStatus == pb.ORCHESTRATION_STATUS_FAILED - assert complete_action.failureDetails.errorType == 'TaskFailedError' # TODO: Should this be the specific error? + assert complete_action.failureDetails.errorType == 'durabletask.task.TaskFailedError' # TODO: Should this be the specific error? assert str(ex) in complete_action.failureDetails.errorMessage diff --git a/tests/durabletask/test_worker_activity_hooks.py b/tests/durabletask/test_worker_activity_hooks.py index 31816d8e..5e59e731 100644 --- a/tests/durabletask/test_worker_activity_hooks.py +++ b/tests/durabletask/test_worker_activity_hooks.py @@ -56,7 +56,7 @@ def fails(_ctx, _value): assert worker.events == [("started", "fails"), ("completed", "fails")] assert len(stub.completed) == 1 - assert stub.completed[0].failureDetails.errorType == "ValueError" + assert stub.completed[0].failureDetails.errorType == "builtins.ValueError" def _activity_request(name: str, value: str) -> pb.ActivityRequest: