Skip to content

Commit 864f1dd

Browse files
committed
Extract legacy entity failure message from result, not exceptionType
In the WebJobs old-protocol ResponseMessage, exceptionType is only a presence marker / exception type name; the human-readable message is serialized into result. Detect failures by exceptionType presence (or a structured failureDetails object) and take the message from the deserialized result, matching azure-functions-durable-python / -js. Updates the exceptionType test to the real wire shape (message in result, type name in exceptionType) and guards against surfacing the type name.
1 parent a13812e commit 864f1dd

3 files changed

Lines changed: 68 additions & 31 deletions

File tree

durabletask/internal/helpers.py

Lines changed: 34 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -150,22 +150,44 @@ def _failure_details_from_core_dict(fd: dict[str, Any]) -> pb.TaskFailureDetails
150150
)
151151

152152

153-
def entity_response_failure_details(response: dict[str, Any]) -> pb.TaskFailureDetails | None:
154-
"""Extract failure details from a legacy-protocol entity ``ResponseMessage``.
155-
156-
The legacy entity protocol (used when the Durable WebJobs extension
157-
translates entity operations) delivers operation results as a
158-
``ResponseMessage`` whose ``exceptionType`` (error message) or
159-
``failureDetails`` field is populated when the operation failed. Returns
160-
``None`` when the response represents a successful operation.
153+
def entity_response_failure_details(
154+
response: dict[str, Any],
155+
error_content: Any = None) -> pb.TaskFailureDetails:
156+
"""Build failure details from a failed legacy-protocol entity ``ResponseMessage``.
157+
158+
Call this only for responses that :func:`is_entity_error_response` reports as
159+
failures. In the WebJobs "old protocol" ``ResponseMessage`` (see
160+
``EntityScheduler/ResponseMessage.cs``), a failed operation serializes the
161+
human-readable content into ``result`` while ``exceptionType`` carries only
162+
the exception's type name (a presence marker) -- it is *not* the message.
163+
This mirrors ``azure-functions-durable-python`` / ``-js``, which read the
164+
message from ``result`` and ignore ``exceptionType``'s value.
165+
166+
Parameters
167+
----------
168+
response:
169+
The deserialized ``ResponseMessage`` dict.
170+
error_content:
171+
The already-deserialized ``result`` payload, used as the failure
172+
message. A structured ``failureDetails`` object, if present, takes
173+
precedence (current-protocol shape).
161174
"""
162175
failure_details = response.get("failureDetails")
163176
if isinstance(failure_details, dict):
164177
return _failure_details_from_core_dict(cast(dict[str, Any], failure_details))
165-
error_message = response.get("exceptionType")
166-
if error_message is not None:
167-
return pb.TaskFailureDetails(errorType="", errorMessage=str(error_message))
168-
return None
178+
error_type = str(response.get("exceptionType") or "")
179+
error_message = "" if error_content is None else str(error_content)
180+
return pb.TaskFailureDetails(errorType=error_type, errorMessage=error_message)
181+
182+
183+
def is_entity_error_response(response: dict[str, Any]) -> bool:
184+
"""Return ``True`` if a legacy-protocol entity ``ResponseMessage`` is a failure.
185+
186+
In the WebJobs "old protocol" a failed operation is marked by the presence of
187+
an ``exceptionType`` field (successful responses omit it). Current-protocol
188+
payloads may instead carry a structured ``failureDetails`` object.
189+
"""
190+
return "exceptionType" in response or isinstance(response.get("failureDetails"), dict)
169191

170192

171193
def new_event_sent_event(event_id: int, instance_id: str, input: str):

durabletask/worker.py

Lines changed: 18 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -2818,18 +2818,24 @@ def _handle_entity_event_raised(self,
28182818
if not ph.is_empty(event.eventRaised.input):
28192819
response = self._data_converter.deserialize(event.eventRaised.input.value)
28202820

2821-
# For entity operation calls (lock acquisitions never fail this way), the legacy-protocol
2822-
# ResponseMessage signals a failed operation via its "exceptionType" (error message) or
2823-
# "failureDetails" field. Propagate that as a task failure so an awaiting call_entity raises,
2824-
# matching the new entity protocol and the .NET SDK.
2825-
if not is_lock_event and isinstance(response, dict):
2826-
failure_details = ph.entity_response_failure_details(response)
2827-
if failure_details is not None:
2828-
failure = EntityOperationFailedException(entity_id, operation or "", failure_details)
2829-
ctx._entity_context.recover_lock_after_call(entity_id) # pyright: ignore[reportPrivateUsage]
2830-
entity_task.fail(str(failure), failure)
2831-
ctx.resume()
2832-
return
2821+
# For entity operation calls (lock acquisitions never fail this way), the legacy WebJobs
2822+
# "old protocol" ResponseMessage signals a failed operation via the presence of an
2823+
# "exceptionType" marker (or a structured "failureDetails" object). The human-readable
2824+
# message lives in the *serialized* "result" field -- "exceptionType" is only the exception
2825+
# type name, not the message -- so deserialize "result" to recover it, matching
2826+
# azure-functions-durable-python / -js. Propagate as a task failure so an awaiting
2827+
# call_entity raises, like the current entity protocol and the .NET SDK.
2828+
if not is_lock_event and isinstance(response, dict) and ph.is_entity_error_response(response):
2829+
raw_result = response.get("result")
2830+
error_content = (
2831+
self._data_converter.deserialize(raw_result) if isinstance(raw_result, str) else raw_result
2832+
)
2833+
failure_details = ph.entity_response_failure_details(response, error_content)
2834+
failure = EntityOperationFailedException(entity_id, operation or "", failure_details)
2835+
ctx._entity_context.recover_lock_after_call(entity_id) # pyright: ignore[reportPrivateUsage]
2836+
entity_task.fail(str(failure), failure)
2837+
ctx.resume()
2838+
return
28332839

28342840
result = None
28352841
if response is not None:

tests/durabletask/test_orchestration_executor.py

Lines changed: 16 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -2096,8 +2096,9 @@ def orchestrator(ctx: task.OrchestrationContext, _):
20962096

20972097

20982098
def test_entity_call_failure_propagated_over_old_protocol():
2099-
"""A failed entity operation delivered via the legacy protocol (an eventRaised
2100-
ResponseMessage with failureDetails) must surface as a TaskFailedError to the caller."""
2099+
"""A failed entity response carrying a structured ``failureDetails`` object
2100+
(defensively supported, current-protocol shape) must surface as a
2101+
TaskFailedError with the structured message."""
21012102
test_entity_id = entities.EntityInstanceId("Counter", "myCounter")
21022103

21032104
def orchestrator(ctx: task.OrchestrationContext, _):
@@ -2122,8 +2123,8 @@ def orchestrator(ctx: task.OrchestrationContext, _):
21222123
assert actions[0].sendEntityMessage.HasField("entityOperationCalled")
21232124
request_id = actions[0].sendEntityMessage.entityOperationCalled.requestId
21242125

2125-
# The Durable WebJobs extension translates a failed entity operation into a
2126-
# legacy-protocol ResponseMessage carrying a serialized FailureDetails.
2126+
# A structured FailureDetails object, when present, takes precedence over the
2127+
# WebJobs result/exceptionType fields.
21272128
response_message = {
21282129
"result": None,
21292130
"failureDetails": {
@@ -2148,8 +2149,9 @@ def orchestrator(ctx: task.OrchestrationContext, _):
21482149

21492150

21502151
def test_entity_call_failure_propagated_over_old_protocol_exception_type():
2151-
"""A failed entity operation whose legacy ResponseMessage only carries the
2152-
``exceptionType`` (error message) field must still surface as a TaskFailedError."""
2152+
"""A WebJobs "old protocol" failure carries the message in the serialized
2153+
``result`` field, while ``exceptionType`` is only the exception type name.
2154+
The surfaced message must come from ``result``, not ``exceptionType``."""
21532155
test_entity_id = entities.EntityInstanceId("Counter", "myCounter")
21542156

21552157
def orchestrator(ctx: task.OrchestrationContext, _):
@@ -2173,7 +2175,12 @@ def orchestrator(ctx: task.OrchestrationContext, _):
21732175
assert len(actions) == 1
21742176
request_id = actions[0].sendEntityMessage.entityOperationCalled.requestId
21752177

2176-
response_message = {"result": None, "exceptionType": "Something went wrong!"}
2178+
# Real WebJobs shape: message serialized into "result"; "exceptionType" is the
2179+
# (assembly-qualified) exception type name and must NOT be used as the message.
2180+
response_message = {
2181+
"result": json.dumps("Something went wrong!"),
2182+
"exceptionType": "System.InvalidOperationException, mscorlib",
2183+
}
21772184
new_events = [
21782185
helpers.new_event_sent_event(1, str(test_entity_id), json.dumps({"id": request_id})),
21792186
helpers.new_event_raised_event(request_id, json.dumps(response_message)),
@@ -2185,6 +2192,8 @@ def orchestrator(ctx: task.OrchestrationContext, _):
21852192
assert output == (
21862193
"Operation 'set' on entity '@counter@myCounter' failed with error: Something went wrong!"
21872194
)
2195+
# Guard against the regression of surfacing the type name instead of the message.
2196+
assert "InvalidOperationException" not in output
21882197

21892198

21902199
def test_entity_call_success_over_old_protocol():

0 commit comments

Comments
 (0)