Skip to content

Commit 0bd4214

Browse files
committed
Merge branch 'andystaples/add-functions-support' of https://github.com/andystaples/durabletask-python into andystaples/add-functions-support
2 parents c0f50a1 + acf0a34 commit 0bd4214

13 files changed

Lines changed: 304 additions & 26 deletions

‎CHANGELOG.md‎

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,14 @@ adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
77

88
## Unreleased
99

10+
ADDED
11+
12+
- Added `FailureDetails.is_caused_by()` to check whether a task failure was caused by a given exception type, mirroring .NET's `TaskFailureDetails.IsCausedBy<T>()` 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.
13+
14+
CHANGED
15+
16+
- **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.
17+
1018
## v1.8.0
1119

1220
ADDED

‎durabletask-azuremanaged/CHANGELOG.md‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,8 @@ adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
77

88
## Unreleased
99

10+
- `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.
11+
1012
## v1.8.0
1113

1214
- Updates base dependency to durabletask v1.8.0.

‎durabletask/internal/helpers.py‎

Lines changed: 20 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -122,6 +122,25 @@ def new_sub_orchestration_failed_event(event_id: int, ex: Exception) -> pb.Histo
122122
)
123123

124124

125+
def get_qualified_name(t: type) -> str:
126+
"""Return the fully-qualified ``module.qualname`` name of a type.
127+
128+
This mirrors the fully-qualified error-type names produced by the other
129+
Durable Task SDKs (.NET ``Type.ToString()`` and Java ``Class.getName()``),
130+
so that :attr:`FailureDetails.error_type` values are consistent across SDKs
131+
and can be matched unambiguously by :meth:`FailureDetails.is_caused_by`.
132+
133+
Builtin exception types keep their ``builtins.`` prefix (e.g.
134+
``builtins.ValueError``), matching how .NET keeps ``System.`` and Java keeps
135+
``java.lang.``.
136+
"""
137+
module = getattr(t, "__module__", None)
138+
qualname = getattr(t, "__qualname__", None) or getattr(t, "__name__", None) or str(t)
139+
if not module:
140+
return qualname
141+
return f"{module}.{qualname}"
142+
143+
125144
def new_failure_details(ex: Exception, _visited: set[int] | None = None) -> pb.TaskFailureDetails:
126145
if _visited is None:
127146
_visited = set()
@@ -130,7 +149,7 @@ def new_failure_details(ex: Exception, _visited: set[int] | None = None) -> pb.T
130149
if len(_visited) > 10 or (inner and id(inner) in _visited) or not isinstance(inner, Exception):
131150
inner = None
132151
return pb.TaskFailureDetails(
133-
errorType=type(ex).__name__,
152+
errorType=get_qualified_name(type(ex)),
134153
errorMessage=str(ex),
135154
stackTrace=wrappers_pb2.StringValue(value=''.join(traceback.format_tb(ex.__traceback__))),
136155
innerFailure=new_failure_details(inner, _visited) if inner else None

‎durabletask/task.py‎

Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -456,6 +456,88 @@ class FailureDetails:
456456
error_type: str
457457
stack_trace: str | None
458458

459+
def is_caused_by(self, error_type: str | type[BaseException]) -> bool:
460+
"""Return ``True`` if this failure was caused by ``error_type``.
461+
462+
This mirrors the .NET ``TaskFailureDetails.IsCausedBy<T>()`` and Java
463+
``FailureDetails.isCausedBy(Class)`` helpers, letting a caller introspect
464+
a failure instead of comparing :attr:`error_type` strings by hand::
465+
466+
try:
467+
yield ctx.call_activity(my_activity)
468+
except TaskFailedError as e:
469+
if e.details.is_caused_by(ValueError):
470+
...
471+
472+
``error_type`` may be either an exception type or a name string:
473+
474+
* When an exception **type** is given, the check is base-type aware: it
475+
returns ``True`` if the failure's type is ``error_type`` or any of its
476+
subclasses. To stay safe, this only inspects exception subclasses that
477+
are already imported in the current process (it never imports a type by
478+
name), so a subclass whose module has not been imported yet will not be
479+
matched. Passing a non-exception type raises :class:`TypeError`.
480+
* When a **string** is given, it is compared by name only (no base-type
481+
awareness). The name may be fully qualified (``"module.ClassName"``) or
482+
unqualified (``"ClassName"``); an unqualified name matches on the class
483+
name alone and therefore cannot distinguish same-named types from
484+
different modules.
485+
486+
Only this failure's own :attr:`error_type` is considered; chained/inner
487+
causes are not traversed.
488+
"""
489+
if isinstance(error_type, str):
490+
return self._name_matches(error_type)
491+
# The annotation restricts callers to exception types, but this is a
492+
# public API, so validate at runtime against arbitrary objects.
493+
if not (isinstance(error_type, type) and issubclass(error_type, BaseException)): # pyright: ignore[reportUnnecessaryIsInstance]
494+
raise TypeError(
495+
"is_caused_by() expects an exception type or a type-name string, "
496+
f"but got {error_type!r}.")
497+
498+
# Base-type-aware match without importing the failure's type: walk the
499+
# target type and its already-loaded subclasses (visited-marked to guard
500+
# against diamond inheritance) and compare each candidate's qualified
501+
# name against this failure's error_type.
502+
visited: set[int] = set()
503+
stack: list[type] = [error_type]
504+
while stack:
505+
candidate = stack.pop()
506+
if id(candidate) in visited:
507+
continue
508+
visited.add(id(candidate))
509+
if self._type_matches(candidate):
510+
return True
511+
stack.extend(candidate.__subclasses__())
512+
return False
513+
514+
def _type_matches(self, candidate: type) -> bool:
515+
if not self.error_type:
516+
return False
517+
candidate_name = pbh.get_qualified_name(candidate)
518+
if self.error_type == candidate_name:
519+
return True
520+
# Back-compat: a bare (non-qualified) stored name -- e.g. produced by an
521+
# older SDK version or a non-Python SDK -- can only be compared by the
522+
# unqualified class name.
523+
if "." not in self.error_type:
524+
return self.error_type == candidate_name.rsplit(".", 1)[-1]
525+
return False
526+
527+
def _name_matches(self, name: str) -> bool:
528+
stored = self.error_type
529+
if not stored or not name:
530+
return False
531+
if stored == name:
532+
return True
533+
# When both names are fully qualified, require an exact match so that
534+
# same-named types from different modules are not confused. Otherwise,
535+
# honor the "qualified or unqualified" contract by comparing the
536+
# unqualified (trailing) segment.
537+
if "." in stored and "." in name:
538+
return False
539+
return stored.rsplit(".", 1)[-1] == name.rsplit(".", 1)[-1]
540+
459541

460542
class TaskFailedError(Exception):
461543
"""Exception type for all orchestration task failures."""

‎durabletask/testing/in_memory_backend.py‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -799,7 +799,7 @@ def CompleteOrchestratorTask(self, request: pb.OrchestratorResponse, context: gr
799799
if not self._is_terminal_status(instance.status):
800800
instance.status = pb.ORCHESTRATION_STATUS_FAILED
801801
instance.failure_details = pb.TaskFailureDetails(
802-
errorType=type(e).__name__,
802+
errorType=helpers.get_qualified_name(type(e)),
803803
errorMessage=str(e),
804804
isNonRetriable=True,
805805
)

‎tests/durabletask-azuremanaged/entities/test_dts_entity_failure_handling.py‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -38,7 +38,7 @@ def test_orchestrator(ctx: task.OrchestrationContext, _):
3838
assert state.name == task.get_name(test_orchestrator)
3939
assert state.instance_id == id
4040
assert state.failure_details is not None
41-
assert state.failure_details.error_type == "TaskFailedError"
41+
assert state.failure_details.error_type == "durabletask.task.TaskFailedError"
4242
# NOTE: Because FailureDetails does not support inner_failure, we can't verify that the inner failure type is
4343
# EntityOperationFailedException. In the future, we should consider adding support for inner failures in
4444
# 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, _):
7272
assert state.name == task.get_name(test_orchestrator)
7373
assert state.instance_id == id
7474
assert state.failure_details is not None
75-
assert state.failure_details.error_type == "TaskFailedError"
75+
assert state.failure_details.error_type == "durabletask.task.TaskFailedError"
7676
assert state.failure_details.message == "Operation 'fail' on entity '@failing_entity@testEntity' failed with " \
7777
"error: Something went wrong!"
7878
assert state.runtime_status == client.OrchestrationStatus.FAILED

‎tests/durabletask-azuremanaged/test_dts_async_orchestration_e2e.py‎

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -411,7 +411,7 @@ def throw_activity_with_retry(ctx: task.ActivityContext, _):
411411
assert state is not None
412412
assert state.runtime_status == client.OrchestrationStatus.FAILED
413413
assert state.failure_details is not None
414-
assert state.failure_details.error_type == "TaskFailedError"
414+
assert state.failure_details.error_type == "durabletask.task.TaskFailedError"
415415
assert state.failure_details.message.startswith("Sub-orchestration task #1 failed:")
416416
assert state.failure_details.message.endswith("Activity task #1 failed: Kah-BOOOOM!!!")
417417
assert state.failure_details.stack_trace is not None
@@ -449,7 +449,7 @@ def throw_activity(ctx: task.ActivityContext, _):
449449
assert state is not None
450450
assert state.runtime_status == client.OrchestrationStatus.FAILED
451451
assert state.failure_details is not None
452-
assert state.failure_details.error_type == "TaskFailedError"
452+
assert state.failure_details.error_type == "durabletask.task.TaskFailedError"
453453
assert state.failure_details.message.endswith("Activity task #1 failed: Kah-BOOOOM!!!")
454454
assert state.failure_details.stack_trace is not None
455455
assert throw_activity_counter == 4
@@ -536,7 +536,7 @@ def test_orchestrator(ctx: task.OrchestrationContext, _):
536536
assert state.name == task.get_name(test_orchestrator)
537537
assert state.instance_id == id
538538
assert state.failure_details is not None
539-
assert state.failure_details.error_type == "JsonEncodeOutputException"
539+
assert state.failure_details.error_type == "durabletask.internal.json_encode_output_exception.JsonEncodeOutputException"
540540
assert state.failure_details.message.startswith("The orchestration result could not be encoded. Object details:")
541541
assert state.failure_details.message.find("This is not JSON serializable") != -1
542542
assert state.runtime_status == client.OrchestrationStatus.FAILED

‎tests/durabletask-azuremanaged/test_dts_orchestration_e2e.py‎

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -538,7 +538,7 @@ def throw_activity_with_retry(ctx: task.ActivityContext, _):
538538
assert state is not None
539539
assert state.runtime_status == client.OrchestrationStatus.FAILED
540540
assert state.failure_details is not None
541-
assert state.failure_details.error_type == "TaskFailedError"
541+
assert state.failure_details.error_type == "durabletask.task.TaskFailedError"
542542
assert state.failure_details.message.startswith("Sub-orchestration task #1 failed:")
543543
assert state.failure_details.message.endswith("Activity task #1 failed: Kah-BOOOOM!!!")
544544
assert state.failure_details.stack_trace is not None
@@ -580,7 +580,7 @@ def throw_activity(ctx: task.ActivityContext, _):
580580
assert state is not None
581581
assert state.runtime_status == client.OrchestrationStatus.FAILED
582582
assert state.failure_details is not None
583-
assert state.failure_details.error_type == "TaskFailedError"
583+
assert state.failure_details.error_type == "durabletask.task.TaskFailedError"
584584
assert state.failure_details.message.endswith("Activity task #1 failed: Kah-BOOOOM!!!")
585585
assert state.failure_details.stack_trace is not None
586586
assert throw_activity_counter == 4
@@ -667,7 +667,7 @@ def test_orchestrator(ctx: task.OrchestrationContext, _):
667667
assert state.name == task.get_name(test_orchestrator)
668668
assert state.instance_id == id
669669
assert state.failure_details is not None
670-
assert state.failure_details.error_type == "JsonEncodeOutputException"
670+
assert state.failure_details.error_type == "durabletask.internal.json_encode_output_exception.JsonEncodeOutputException"
671671
assert state.failure_details.message.startswith("The orchestration result could not be encoded. Object details:")
672672
assert state.failure_details.message.find("This is not JSON serializable") != -1
673673
assert state.runtime_status == client.OrchestrationStatus.FAILED

‎tests/durabletask/entities/test_entity_failure_handling.py‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -50,7 +50,7 @@ def test_orchestrator(ctx: task.OrchestrationContext, _):
5050
assert state.name == task.get_name(test_orchestrator)
5151
assert state.instance_id == id
5252
assert state.failure_details is not None
53-
assert state.failure_details.error_type == "TaskFailedError"
53+
assert state.failure_details.error_type == "durabletask.task.TaskFailedError"
5454
assert "Something went wrong!" in state.failure_details.message
5555
assert state.runtime_status == client.OrchestrationStatus.FAILED
5656

@@ -77,7 +77,7 @@ def test_orchestrator(ctx: task.OrchestrationContext, _):
7777
assert state.name == task.get_name(test_orchestrator)
7878
assert state.instance_id == id
7979
assert state.failure_details is not None
80-
assert state.failure_details.error_type == "TaskFailedError"
80+
assert state.failure_details.error_type == "durabletask.task.TaskFailedError"
8181
assert "Something went wrong!" in state.failure_details.message
8282
assert state.runtime_status == client.OrchestrationStatus.FAILED
8383

0 commit comments

Comments
 (0)