Skip to content

Commit 826f90c

Browse files
authored
fix: Preserve original gRPC error cause in rewindInstance and restartOrchestration (#200)
1 parent 60e4eff commit 826f90c

File tree

2 files changed

+224
-9
lines changed

2 files changed

+224
-9
lines changed

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

Lines changed: 9 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -569,19 +569,19 @@ export class TaskHubGrpcClient {
569569
);
570570
} catch (e) {
571571
// Handle gRPC errors and convert them to appropriate errors
572-
if (e && typeof e === "object" && "code" in e) {
573-
const grpcError = e as { code: number; details?: string };
572+
if (e instanceof Error && "code" in e) {
573+
const grpcError = e as grpc.ServiceError;
574574
if (grpcError.code === grpc.status.NOT_FOUND) {
575-
throw new Error(`An orchestration with the instanceId '${instanceId}' was not found.`);
575+
throw new Error(`An orchestration with the instanceId '${instanceId}' was not found.`, { cause: e });
576576
}
577577
if (grpcError.code === grpc.status.FAILED_PRECONDITION) {
578-
throw new Error(grpcError.details || `Cannot rewind orchestration '${instanceId}': it is in a state that does not allow rewinding.`);
578+
throw new Error(grpcError.details || `Cannot rewind orchestration '${instanceId}': it is in a state that does not allow rewinding.`, { cause: e });
579579
}
580580
if (grpcError.code === grpc.status.UNIMPLEMENTED) {
581-
throw new Error(grpcError.details || `The rewind operation is not supported by the backend.`);
581+
throw new Error(grpcError.details || `The rewind operation is not supported by the backend.`, { cause: e });
582582
}
583583
if (grpcError.code === grpc.status.CANCELLED) {
584-
throw new Error(`The rewind operation for '${instanceId}' was cancelled.`);
584+
throw new Error(`The rewind operation for '${instanceId}' was canceled.`, { cause: e });
585585
}
586586
}
587587
throw e;
@@ -629,13 +629,13 @@ export class TaskHubGrpcClient {
629629
if (e instanceof Error && "code" in e) {
630630
const grpcError = e as grpc.ServiceError;
631631
if (grpcError.code === grpc.status.NOT_FOUND) {
632-
throw new Error(`An orchestration with the instanceId '${instanceId}' was not found.`);
632+
throw new Error(`An orchestration with the instanceId '${instanceId}' was not found.`, { cause: e });
633633
}
634634
if (grpcError.code === grpc.status.FAILED_PRECONDITION) {
635-
throw new Error(`An orchestration with the instanceId '${instanceId}' cannot be restarted.`);
635+
throw new Error(`An orchestration with the instanceId '${instanceId}' cannot be restarted.`, { cause: e });
636636
}
637637
if (grpcError.code === grpc.status.CANCELLED) {
638-
throw new Error(`The restartOrchestration operation was canceled.`);
638+
throw new Error(`The restartOrchestration operation was canceled.`, { cause: e });
639639
}
640640
}
641641
throw e;
Lines changed: 215 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,215 @@
1+
// Copyright (c) Microsoft Corporation. All rights reserved.
2+
// Licensed under the MIT License.
3+
4+
import * as grpc from "@grpc/grpc-js";
5+
import { TaskHubGrpcClient } from "../src";
6+
7+
/**
8+
* Creates a mock gRPC ServiceError with the specified status code and details.
9+
*/
10+
function createGrpcError(code: grpc.status, details: string = ""): grpc.ServiceError {
11+
const error = new Error(details) as grpc.ServiceError;
12+
error.code = code;
13+
error.details = details;
14+
error.metadata = new grpc.Metadata();
15+
return error;
16+
}
17+
18+
/**
19+
* Accesses the internal _stub on a TaskHubGrpcClient for test mocking.
20+
*/
21+
function getStub(client: TaskHubGrpcClient): any {
22+
return (client as any)._stub;
23+
}
24+
25+
describe("TaskHubGrpcClient error cause preservation", () => {
26+
let client: TaskHubGrpcClient;
27+
28+
beforeEach(() => {
29+
client = new TaskHubGrpcClient({ hostAddress: "localhost:4001" });
30+
});
31+
32+
describe("rewindInstance", () => {
33+
it("should preserve error cause for NOT_FOUND gRPC errors", async () => {
34+
const grpcError = createGrpcError(grpc.status.NOT_FOUND, "Instance not found");
35+
36+
getStub(client).rewindInstance = (_req: any, _metadata: any, callback: any) => {
37+
callback(grpcError, null);
38+
return {} as grpc.ClientUnaryCall;
39+
};
40+
41+
try {
42+
await client.rewindInstance("test-instance", "test reason");
43+
fail("Expected an error to be thrown");
44+
} catch (e: unknown) {
45+
expect(e).toBeInstanceOf(Error);
46+
const error = e as Error;
47+
expect(error.message).toContain("test-instance");
48+
expect(error.message).toContain("was not found");
49+
expect(error.cause).toBe(grpcError);
50+
}
51+
});
52+
53+
it("should preserve error cause for FAILED_PRECONDITION gRPC errors", async () => {
54+
const grpcError = createGrpcError(
55+
grpc.status.FAILED_PRECONDITION,
56+
"Orchestration is running",
57+
);
58+
59+
getStub(client).rewindInstance = (_req: any, _metadata: any, callback: any) => {
60+
callback(grpcError, null);
61+
return {} as grpc.ClientUnaryCall;
62+
};
63+
64+
try {
65+
await client.rewindInstance("test-instance", "test reason");
66+
fail("Expected an error to be thrown");
67+
} catch (e: unknown) {
68+
expect(e).toBeInstanceOf(Error);
69+
const error = e as Error;
70+
expect(error.message).toBe("Orchestration is running");
71+
expect(error.cause).toBe(grpcError);
72+
}
73+
});
74+
75+
it("should preserve error cause for UNIMPLEMENTED gRPC errors", async () => {
76+
const grpcError = createGrpcError(grpc.status.UNIMPLEMENTED, "");
77+
78+
getStub(client).rewindInstance = (_req: any, _metadata: any, callback: any) => {
79+
callback(grpcError, null);
80+
return {} as grpc.ClientUnaryCall;
81+
};
82+
83+
try {
84+
await client.rewindInstance("test-instance", "test reason");
85+
fail("Expected an error to be thrown");
86+
} catch (e: unknown) {
87+
expect(e).toBeInstanceOf(Error);
88+
const error = e as Error;
89+
expect(error.message).toContain("not supported by the backend");
90+
expect(error.cause).toBe(grpcError);
91+
}
92+
});
93+
94+
it("should preserve error cause for CANCELLED gRPC errors", async () => {
95+
const grpcError = createGrpcError(grpc.status.CANCELLED, "Cancelled");
96+
97+
getStub(client).rewindInstance = (_req: any, _metadata: any, callback: any) => {
98+
callback(grpcError, null);
99+
return {} as grpc.ClientUnaryCall;
100+
};
101+
102+
try {
103+
await client.rewindInstance("test-instance", "test reason");
104+
fail("Expected an error to be thrown");
105+
} catch (e: unknown) {
106+
expect(e).toBeInstanceOf(Error);
107+
const error = e as Error;
108+
expect(error.message).toContain("was canceled");
109+
expect(error.cause).toBe(grpcError);
110+
}
111+
});
112+
113+
it("should rethrow unrecognized gRPC errors without wrapping", async () => {
114+
const grpcError = createGrpcError(grpc.status.INTERNAL, "Internal server error");
115+
116+
getStub(client).rewindInstance = (_req: any, _metadata: any, callback: any) => {
117+
callback(grpcError, null);
118+
return {} as grpc.ClientUnaryCall;
119+
};
120+
121+
try {
122+
await client.rewindInstance("test-instance", "test reason");
123+
fail("Expected an error to be thrown");
124+
} catch (e: unknown) {
125+
// Unrecognized gRPC status codes should be rethrown as-is
126+
expect(e).toBe(grpcError);
127+
}
128+
});
129+
});
130+
131+
describe("restartOrchestration", () => {
132+
it("should preserve error cause for NOT_FOUND gRPC errors", async () => {
133+
const grpcError = createGrpcError(grpc.status.NOT_FOUND, "Instance not found");
134+
135+
getStub(client).restartInstance = (_req: any, _metadata: any, callback: any) => {
136+
callback(grpcError, null);
137+
return {} as grpc.ClientUnaryCall;
138+
};
139+
140+
try {
141+
await client.restartOrchestration("test-instance");
142+
fail("Expected an error to be thrown");
143+
} catch (e: unknown) {
144+
expect(e).toBeInstanceOf(Error);
145+
const error = e as Error;
146+
expect(error.message).toContain("test-instance");
147+
expect(error.message).toContain("was not found");
148+
expect(error.cause).toBe(grpcError);
149+
}
150+
});
151+
152+
it("should preserve error cause for FAILED_PRECONDITION gRPC errors", async () => {
153+
const grpcError = createGrpcError(
154+
grpc.status.FAILED_PRECONDITION,
155+
"Orchestration is still running",
156+
);
157+
158+
getStub(client).restartInstance = (_req: any, _metadata: any, callback: any) => {
159+
callback(grpcError, null);
160+
return {} as grpc.ClientUnaryCall;
161+
};
162+
163+
try {
164+
await client.restartOrchestration("test-instance");
165+
fail("Expected an error to be thrown");
166+
} catch (e: unknown) {
167+
expect(e).toBeInstanceOf(Error);
168+
const error = e as Error;
169+
expect(error.message).toContain("test-instance");
170+
expect(error.message).toContain("cannot be restarted");
171+
expect(error.cause).toBe(grpcError);
172+
}
173+
});
174+
175+
it("should preserve error cause for CANCELLED gRPC errors", async () => {
176+
const grpcError = createGrpcError(grpc.status.CANCELLED, "Cancelled");
177+
178+
getStub(client).restartInstance = (_req: any, _metadata: any, callback: any) => {
179+
callback(grpcError, null);
180+
return {} as grpc.ClientUnaryCall;
181+
};
182+
183+
try {
184+
await client.restartOrchestration("test-instance");
185+
fail("Expected an error to be thrown");
186+
} catch (e: unknown) {
187+
expect(e).toBeInstanceOf(Error);
188+
const error = e as Error;
189+
expect(error.message).toContain("was canceled");
190+
expect(error.cause).toBe(grpcError);
191+
}
192+
});
193+
194+
it("should rethrow unrecognized gRPC errors without wrapping", async () => {
195+
const grpcError = createGrpcError(grpc.status.INTERNAL, "Internal server error");
196+
197+
getStub(client).restartInstance = (_req: any, _metadata: any, callback: any) => {
198+
callback(grpcError, null);
199+
return {} as grpc.ClientUnaryCall;
200+
};
201+
202+
try {
203+
await client.restartOrchestration("test-instance");
204+
fail("Expected an error to be thrown");
205+
} catch (e: unknown) {
206+
// Unrecognized gRPC status codes should be rethrown as-is
207+
expect(e).toBe(grpcError);
208+
}
209+
});
210+
211+
it("should validate instanceId parameter", async () => {
212+
await expect(client.restartOrchestration("")).rejects.toThrow("instanceId cannot be null or empty");
213+
});
214+
});
215+
});

0 commit comments

Comments
 (0)