Skip to content

Commit 672a7ef

Browse files
authored
fix: preserve EntityOperationFailedException details (#277)
1 parent ba53c1c commit 672a7ef

7 files changed

Lines changed: 118 additions & 23 deletions

File tree

packages/durabletask-js/src/entities/entity-operation-failed-exception.ts

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -82,8 +82,13 @@ export class EntityOperationFailedException extends Error {
8282
* @param operationName - The operation name.
8383
* @param failureDetails - The failure details.
8484
*/
85-
constructor(entityId: EntityInstanceId, operationName: string, failureDetails: TaskFailureDetails) {
86-
super(EntityOperationFailedException.getExceptionMessage(operationName, entityId, failureDetails));
85+
constructor(
86+
entityId: EntityInstanceId,
87+
operationName: string,
88+
failureDetails: TaskFailureDetails,
89+
) {
90+
const message = EntityOperationFailedException.getExceptionMessage(operationName, entityId, failureDetails);
91+
super(message);
8792
this.name = "EntityOperationFailedException";
8893
this.entityId = entityId;
8994
this.operationName = operationName;

packages/durabletask-js/src/task/completable-task.ts

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,4 +37,22 @@ export class CompletableTask<T> extends Task<T> {
3737
this._parent.onChildCompleted(this);
3838
}
3939
}
40+
41+
/**
42+
* Fails the task with a pre-constructed error.
43+
* Use this when a more specific error type (e.g., EntityOperationFailedException)
44+
* should be preserved as the task's exception rather than wrapping in a generic TaskFailedError.
45+
*/
46+
failWithError(error: Error): void {
47+
if (this._isComplete) {
48+
throw new Error("Task is already completed");
49+
}
50+
51+
this._exception = error;
52+
this._isComplete = true;
53+
54+
if (this._parent) {
55+
this._parent.onChildCompleted(this);
56+
}
57+
}
4058
}

packages/durabletask-js/src/task/task.ts

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,14 @@
11
// Copyright (c) Microsoft Corporation. All rights reserved.
22
// Licensed under the MIT License.
33

4-
import { TaskFailedError } from "./exception/task-failed-error";
54
import { CompositeTask } from "./composite-task";
65

76
/**
87
* Abstract base class for asynchronous tasks in a durable orchestration.
98
*/
109
export class Task<T> {
1110
_result: T | undefined;
12-
_exception: TaskFailedError | undefined;
11+
_exception: Error | undefined;
1312
_parent: CompositeTask<T> | undefined;
1413
_isComplete: boolean = false;
1514

@@ -51,7 +50,7 @@ export class Task<T> {
5150
/**
5251
* Get the exception that caused the task to fail
5352
*/
54-
getException(): TaskFailedError {
53+
getException(): Error {
5554
if (!this._exception) {
5655
throw new Error("Task did not fail");
5756
}

packages/durabletask-js/src/worker/orchestration-executor.ts

Lines changed: 12 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -641,20 +641,18 @@ export class OrchestrationExecutor {
641641
// If in a critical section, recover the lock for this entity
642642
ctx._entityFeature.recoverLockAfterCall(pendingCall.entityId);
643643

644-
// Convert failure details and throw EntityOperationFailedException
645-
const failureDetails = createTaskFailureDetails(failedEvent?.getFailuredetails());
646-
if (!failureDetails) {
647-
pendingCall.task.fail(
648-
`Entity operation '${pendingCall.operationName}' failed with unknown error`,
649-
);
650-
} else {
651-
const exception = new EntityOperationFailedException(
652-
pendingCall.entityId,
653-
pendingCall.operationName,
654-
failureDetails,
655-
);
656-
pendingCall.task.fail(exception.message, failedEvent?.getFailuredetails());
657-
}
644+
const failureDetails =
645+
createTaskFailureDetails(failedEvent?.getFailuredetails()) ??
646+
{
647+
errorType: "UnknownError",
648+
errorMessage: `Entity operation '${pendingCall.operationName}' failed with unknown error`,
649+
};
650+
const exception = new EntityOperationFailedException(
651+
pendingCall.entityId,
652+
pendingCall.operationName,
653+
failureDetails,
654+
);
655+
pendingCall.task.failWithError(exception);
658656

659657
await ctx.resume();
660658
}

packages/durabletask-js/test/entity-operation-events.spec.ts

Lines changed: 55 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import { OrchestrationExecutor } from "../src/worker/orchestration-executor";
55
import { Registry } from "../src/worker/registry";
66
import { OrchestrationContext } from "../src/task/context/orchestration-context";
77
import { EntityInstanceId } from "../src/entities/entity-instance-id";
8+
import { EntityOperationFailedException } from "../src/entities/entity-operation-failed-exception";
89
import * as pb from "../src/proto/orchestrator_service_pb";
910
import * as ph from "../src/utils/pb-helper.util";
1011
import { StringValue } from "google-protobuf/google/protobuf/wrappers_pb";
@@ -190,7 +191,7 @@ describe("OrchestrationExecutor Entity Operation Events", () => {
190191
});
191192

192193
describe("ENTITYOPERATIONFAILED", () => {
193-
it("should fail entity call task with error details", async () => {
194+
it("should fail entity call task with EntityOperationFailedException", async () => {
194195
// Arrange
195196
let caughtError: Error | undefined;
196197
const orchestrator = async function* (ctx: OrchestrationContext): AsyncGenerator<Task<number>, string, number> {
@@ -225,10 +226,19 @@ describe("OrchestrationExecutor Entity Operation Events", () => {
225226

226227
await executor.execute("test-instance", oldEvents2, newEvents2);
227228

228-
// Assert
229+
// Assert - error should be EntityOperationFailedException
229230
expect(caughtError).toBeDefined();
231+
expect(caughtError).toBeInstanceOf(EntityOperationFailedException);
230232
expect(caughtError!.message).toContain("badOperation");
231233
expect(caughtError!.message).toContain("Operation not supported");
234+
235+
// Verify entity-specific context is preserved
236+
const entityError = caughtError as EntityOperationFailedException;
237+
expect(entityError.entityId.name).toBe("counter");
238+
expect(entityError.entityId.key).toBe("my-counter");
239+
expect(entityError.operationName).toBe("badOperation");
240+
expect(entityError.failureDetails.errorType).toBe("InvalidOperationError");
241+
expect(entityError.failureDetails.errorMessage).toBe("Operation not supported");
232242
});
233243

234244
it("should propagate failure to orchestration if not caught", async () => {
@@ -268,6 +278,49 @@ describe("OrchestrationExecutor Entity Operation Events", () => {
268278
const completeAction = completeActionWrapper!.getCompleteorchestration()!;
269279
expect(completeAction.getOrchestrationstatus()).toBe(pb.OrchestrationStatus.ORCHESTRATION_STATUS_FAILED);
270280
});
281+
282+
it("should throw EntityOperationFailedException details when uncaught", async () => {
283+
// Arrange — verify the uncaught path produces an EntityOperationFailedException in the
284+
// orchestration failure details message, matching the documented API contract
285+
const orchestrator = async function* (ctx: OrchestrationContext): AsyncGenerator<Task<number>, string, number> {
286+
const entityId = new EntityInstanceId("counter", "my-counter");
287+
yield ctx.entities.callEntity<number>(entityId, "badOperation");
288+
return "should not reach here";
289+
};
290+
291+
registry.addNamedOrchestrator("TestOrchestrator", orchestrator);
292+
293+
const executor = new OrchestrationExecutor(registry);
294+
295+
const oldEvents: pb.HistoryEvent[] = [];
296+
const newEvents: pb.HistoryEvent[] = [
297+
ph.newOrchestratorStartedEvent(new Date()),
298+
ph.newExecutionStartedEvent("TestOrchestrator", "test-instance", undefined),
299+
];
300+
301+
const result1 = await executor.execute("test-instance", oldEvents, newEvents);
302+
const requestId = result1.actions[0].getSendentitymessage()!.getEntityoperationcalled()!.getRequestid();
303+
304+
// Fail the operation
305+
const oldEvents2 = [...newEvents];
306+
const newEvents2 = [
307+
ph.newOrchestratorStartedEvent(new Date()),
308+
newEntityOperationFailedEvent(100, requestId, "ValidationError", "Invalid input"),
309+
];
310+
311+
const result2 = await executor.execute("test-instance", oldEvents2, newEvents2);
312+
313+
// Assert - orchestration should fail with the EntityOperationFailedException message
314+
const completeActionWrapper = result2.actions.find((a) => a.hasCompleteorchestration());
315+
expect(completeActionWrapper).toBeDefined();
316+
const completeAction = completeActionWrapper!.getCompleteorchestration()!;
317+
expect(completeAction.getOrchestrationstatus()).toBe(pb.OrchestrationStatus.ORCHESTRATION_STATUS_FAILED);
318+
const failureDetails = completeAction.getFailuredetails();
319+
expect(failureDetails).toBeDefined();
320+
expect(failureDetails!.getErrortype()).toBe("EntityOperationFailedException");
321+
expect(failureDetails!.getErrormessage()).toContain("badOperation");
322+
expect(failureDetails!.getErrormessage()).toContain("Invalid input");
323+
});
271324
});
272325

273326
describe("Multiple entity calls", () => {

packages/durabletask-js/test/entity-operation-failed-exception.spec.ts

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import {
77
TaskFailureDetails,
88
createTaskFailureDetails,
99
} from "../src/entities/entity-operation-failed-exception";
10+
import { TaskFailedError } from "../src/task/exception/task-failed-error";
1011
import * as pb from "../src/proto/orchestrator_service_pb";
1112
import { StringValue } from "google-protobuf/google/protobuf/wrappers_pb";
1213

@@ -65,6 +66,21 @@ describe("EntityOperationFailedException", () => {
6566
expect(exception instanceof EntityOperationFailedException).toBe(true);
6667
});
6768

69+
it("should not be instanceof TaskFailedError", () => {
70+
// Arrange
71+
const entityId = new EntityInstanceId("counter", "my-counter");
72+
const failureDetails: TaskFailureDetails = {
73+
errorType: "Error",
74+
errorMessage: "Something went wrong",
75+
};
76+
77+
// Act
78+
const exception = new EntityOperationFailedException(entityId, "op", failureDetails);
79+
80+
// Assert
81+
expect(exception instanceof TaskFailedError).toBe(false);
82+
});
83+
6884
it("should include stack trace", () => {
6985
// Arrange
7086
const entityId = new EntityInstanceId("counter", "my-counter");

packages/durabletask-js/test/task.spec.ts

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,12 @@ function makeFailureDetails(
2727
return details;
2828
}
2929

30+
function getTaskFailedError(task: Task<unknown>): TaskFailedError {
31+
const exception = task.getException();
32+
expect(exception).toBeInstanceOf(TaskFailedError);
33+
return exception as TaskFailedError;
34+
}
35+
3036
describe("Task (base class)", () => {
3137
// Task is not abstract, so we can instantiate it directly for testing
3238
// its base-class behavior.
@@ -197,7 +203,7 @@ describe("CompletableTask", () => {
197203
const details = makeFailureDetails("detailed error", "CustomError", "at line 42");
198204
task.fail("detailed error", details);
199205

200-
const exception = task.getException();
206+
const exception = getTaskFailedError(task);
201207
expect(exception.details.message).toBe("detailed error");
202208
expect(exception.details.errorType).toBe("CustomError");
203209
expect(exception.details.stackTrace).toBe("at line 42");
@@ -207,7 +213,7 @@ describe("CompletableTask", () => {
207213
const task = new CompletableTask<string>();
208214
task.fail("no details");
209215

210-
const exception = task.getException();
216+
const exception = getTaskFailedError(task);
211217
// Default TaskFailureDetails has empty strings for message and errorType
212218
expect(exception.details.message).toBe("");
213219
expect(exception.details.errorType).toBe("");

0 commit comments

Comments
 (0)