diff --git a/package-lock.json b/package-lock.json index d67bef9..4757e91 100644 --- a/package-lock.json +++ b/package-lock.json @@ -69,7 +69,6 @@ "resolved": "https://registry.npmjs.org/@azure/core-client/-/core-client-1.10.1.tgz", "integrity": "sha512-Nh5PhEOeY6PrnxNPsEHRr9eimxLwgLlpmguQaHKBinFYA/RU9+kOYVOQqOrTsCL+KSxrLLl1gD8Dk5BFW/7l/w==", "license": "MIT", - "peer": true, "dependencies": { "@azure/abort-controller": "^2.1.2", "@azure/core-auth": "^1.10.0", @@ -131,7 +130,6 @@ "resolved": "https://registry.npmjs.org/@azure/core-rest-pipeline/-/core-rest-pipeline-1.22.2.tgz", "integrity": "sha512-MzHym+wOi8CLUlKCQu12de0nwcq9k9Kuv43j4Wa++CsCpJwps2eeBQwD2Bu8snkxTtDKDx4GwjuR9E8yC8LNrg==", "license": "MIT", - "peer": true, "dependencies": { "@azure/abort-controller": "^2.1.2", "@azure/core-auth": "^1.10.0", @@ -330,7 +328,6 @@ "integrity": "sha512-H3mcG6ZDLTlYfaSNi0iOKkigqMFvkTKlGUYlD8GW7nNOYRrevuA46iTypPyv+06V3fEmvvazfntkBU34L0azAw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@babel/code-frame": "^7.28.6", "@babel/generator": "^7.28.6", @@ -1002,7 +999,6 @@ "resolved": "https://registry.npmjs.org/@grpc/grpc-js/-/grpc-js-1.14.3.tgz", "integrity": "sha512-Iq8QQQ/7X3Sac15oB6p0FmUg/klxQvXLeileoqrTRGJYLV+/9tubbr9ipz0GKHjmXVsgFPo/+W+2cA8eNcR+XA==", "license": "Apache-2.0", - "peer": true, "dependencies": { "@grpc/proto-loader": "^0.8.0", "@js-sdsl/ordered-map": "^4.4.2" @@ -1603,7 +1599,6 @@ "integrity": "sha512-3giAOQvZiH5F9bMlMiv8+GSPMeqg0dbaeo58/0SlA9sxSqZhnUtxzX9/2FzyhS9sWQf5S0GJE0AKBrFqjpeYcg==", "dev": true, "license": "Apache-2.0", - "peer": true, "engines": { "node": ">=8.0.0" } @@ -1780,7 +1775,6 @@ "dev": true, "hasInstallScript": true, "license": "Apache-2.0", - "peer": true, "dependencies": { "@swc/counter": "^0.1.3", "@swc/types": "^0.1.25" @@ -1996,7 +1990,6 @@ "integrity": "sha512-TXTnIcNJQEKwThMMqBXsZ4VGAza6bvN4pa41Rkqoio6QBKMvo+5lexeTMScGCIxtzgQJzElcvIltani+adC5PQ==", "dev": true, "license": "Apache-2.0", - "peer": true, "dependencies": { "tslib": "^2.8.0" } @@ -2158,7 +2151,6 @@ "resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.9.tgz", "integrity": "sha512-PD03/U8g1F9T9MI+1OBisaIARhSzeidsUjQaf51fOxrfjeiKN9bLVO06lHuHYjxdnqLWJijJHfqXPSJri2EM2A==", "license": "MIT", - "peer": true, "dependencies": { "undici-types": "~6.21.0" } @@ -2232,7 +2224,6 @@ "integrity": "sha512-BtE0k6cjwjLZoZixN0t5AKP0kSzlGu7FctRXYuPAm//aaiZhmfq1JwdYpYr1brzEspYyFeF+8XF5j2VK6oalrA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "8.54.0", "@typescript-eslint/types": "8.54.0", @@ -2474,7 +2465,6 @@ "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", "dev": true, "license": "MIT", - "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -2799,7 +2789,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "baseline-browser-mapping": "^2.9.0", "caniuse-lite": "^1.0.30001759", @@ -3447,7 +3436,6 @@ "integrity": "sha512-LEyamqS7W5HB3ujJyvi0HQK/dtVINZvd5mAAp9eT5S/ujByGjiZLCzPcHVzuXbpJDJF/cxwHlfceVUDZ2lnSTw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.1", @@ -4407,7 +4395,6 @@ "integrity": "sha512-NIy3oAFp9shda19hy4HK0HRTWKtPJmGdnvywu01nOqNC2vZg+Z+fvJDxpMQA88eb2I9EcafcdjYgsDthnYTvGw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@jest/core": "^29.7.0", "@jest/types": "^29.6.3", @@ -6169,7 +6156,6 @@ "integrity": "sha512-UOnG6LftzbdaHZcKoPFtOcCKztrQ57WkHDeRD9t/PTQtmT0NHSeWWepj6pS0z/N7+08BHFDQVUrfmfMRcZwbMg==", "dev": true, "license": "MIT", - "peer": true, "bin": { "prettier": "bin/prettier.cjs" }, @@ -6906,7 +6892,6 @@ "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=12" }, @@ -7058,7 +7043,6 @@ "integrity": "sha512-f0FFpIdcHgn8zcPSbf1dRevwt047YMnaiJM3u2w2RewrB+fob/zePZcrOyQoLMMO7aBIddLcQIEK5dYjkLnGrQ==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@cspotcode/source-map-support": "^0.8.0", "@tsconfig/node10": "^1.0.7", @@ -7145,7 +7129,6 @@ "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "dev": true, "license": "Apache-2.0", - "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" diff --git a/packages/durabletask-js/src/client/client.ts b/packages/durabletask-js/src/client/client.ts index 4e05876..a8633a1 100644 --- a/packages/durabletask-js/src/client/client.ts +++ b/packages/durabletask-js/src/client/client.ts @@ -569,19 +569,19 @@ export class TaskHubGrpcClient { ); } catch (e) { // Handle gRPC errors and convert them to appropriate errors - if (e && typeof e === "object" && "code" in e) { - const grpcError = e as { code: number; details?: string }; + if (e instanceof Error && "code" in e) { + const grpcError = e as grpc.ServiceError; if (grpcError.code === grpc.status.NOT_FOUND) { - throw new Error(`An orchestration with the instanceId '${instanceId}' was not found.`); + throw new Error(`An orchestration with the instanceId '${instanceId}' was not found.`, { cause: e }); } if (grpcError.code === grpc.status.FAILED_PRECONDITION) { - throw new Error(grpcError.details || `Cannot rewind orchestration '${instanceId}': it is in a state that does not allow rewinding.`); + throw new Error(grpcError.details || `Cannot rewind orchestration '${instanceId}': it is in a state that does not allow rewinding.`, { cause: e }); } if (grpcError.code === grpc.status.UNIMPLEMENTED) { - throw new Error(grpcError.details || `The rewind operation is not supported by the backend.`); + throw new Error(grpcError.details || `The rewind operation is not supported by the backend.`, { cause: e }); } if (grpcError.code === grpc.status.CANCELLED) { - throw new Error(`The rewind operation for '${instanceId}' was cancelled.`); + throw new Error(`The rewind operation for '${instanceId}' was cancelled.`, { cause: e }); } } throw e; @@ -629,13 +629,13 @@ export class TaskHubGrpcClient { if (e instanceof Error && "code" in e) { const grpcError = e as grpc.ServiceError; if (grpcError.code === grpc.status.NOT_FOUND) { - throw new Error(`An orchestration with the instanceId '${instanceId}' was not found.`); + throw new Error(`An orchestration with the instanceId '${instanceId}' was not found.`, { cause: e }); } if (grpcError.code === grpc.status.FAILED_PRECONDITION) { - throw new Error(`An orchestration with the instanceId '${instanceId}' cannot be restarted.`); + throw new Error(`An orchestration with the instanceId '${instanceId}' cannot be restarted.`, { cause: e }); } if (grpcError.code === grpc.status.CANCELLED) { - throw new Error(`The restartOrchestration operation was canceled.`); + throw new Error(`The restartOrchestration operation was canceled.`, { cause: e }); } } throw e; diff --git a/packages/durabletask-js/test/client-error-cause.spec.ts b/packages/durabletask-js/test/client-error-cause.spec.ts new file mode 100644 index 0000000..cab13bd --- /dev/null +++ b/packages/durabletask-js/test/client-error-cause.spec.ts @@ -0,0 +1,215 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +import * as grpc from "@grpc/grpc-js"; +import { TaskHubGrpcClient } from "../src"; + +/** + * Creates a mock gRPC ServiceError with the specified status code and details. + */ +function createGrpcError(code: grpc.status, details: string = ""): grpc.ServiceError { + const error = new Error(details) as grpc.ServiceError; + error.code = code; + error.details = details; + error.metadata = new grpc.Metadata(); + return error; +} + +/** + * Accesses the internal _stub on a TaskHubGrpcClient for test mocking. + */ +function getStub(client: TaskHubGrpcClient): any { + return (client as any)._stub; +} + +describe("TaskHubGrpcClient error cause preservation", () => { + let client: TaskHubGrpcClient; + + beforeEach(() => { + client = new TaskHubGrpcClient({ hostAddress: "localhost:4001" }); + }); + + describe("rewindInstance", () => { + it("should preserve error cause for NOT_FOUND gRPC errors", async () => { + const grpcError = createGrpcError(grpc.status.NOT_FOUND, "Instance not found"); + + getStub(client).rewindInstance = (_req: any, _metadata: any, callback: any) => { + callback(grpcError, null); + return {} as grpc.ClientUnaryCall; + }; + + try { + await client.rewindInstance("test-instance", "test reason"); + fail("Expected an error to be thrown"); + } catch (e: unknown) { + expect(e).toBeInstanceOf(Error); + const error = e as Error; + expect(error.message).toContain("test-instance"); + expect(error.message).toContain("was not found"); + expect(error.cause).toBe(grpcError); + } + }); + + it("should preserve error cause for FAILED_PRECONDITION gRPC errors", async () => { + const grpcError = createGrpcError( + grpc.status.FAILED_PRECONDITION, + "Orchestration is running", + ); + + getStub(client).rewindInstance = (_req: any, _metadata: any, callback: any) => { + callback(grpcError, null); + return {} as grpc.ClientUnaryCall; + }; + + try { + await client.rewindInstance("test-instance", "test reason"); + fail("Expected an error to be thrown"); + } catch (e: unknown) { + expect(e).toBeInstanceOf(Error); + const error = e as Error; + expect(error.message).toBe("Orchestration is running"); + expect(error.cause).toBe(grpcError); + } + }); + + it("should preserve error cause for UNIMPLEMENTED gRPC errors", async () => { + const grpcError = createGrpcError(grpc.status.UNIMPLEMENTED, ""); + + getStub(client).rewindInstance = (_req: any, _metadata: any, callback: any) => { + callback(grpcError, null); + return {} as grpc.ClientUnaryCall; + }; + + try { + await client.rewindInstance("test-instance", "test reason"); + fail("Expected an error to be thrown"); + } catch (e: unknown) { + expect(e).toBeInstanceOf(Error); + const error = e as Error; + expect(error.message).toContain("not supported by the backend"); + expect(error.cause).toBe(grpcError); + } + }); + + it("should preserve error cause for CANCELLED gRPC errors", async () => { + const grpcError = createGrpcError(grpc.status.CANCELLED, "Cancelled"); + + getStub(client).rewindInstance = (_req: any, _metadata: any, callback: any) => { + callback(grpcError, null); + return {} as grpc.ClientUnaryCall; + }; + + try { + await client.rewindInstance("test-instance", "test reason"); + fail("Expected an error to be thrown"); + } catch (e: unknown) { + expect(e).toBeInstanceOf(Error); + const error = e as Error; + expect(error.message).toContain("was cancelled"); + expect(error.cause).toBe(grpcError); + } + }); + + it("should rethrow unrecognized gRPC errors without wrapping", async () => { + const grpcError = createGrpcError(grpc.status.INTERNAL, "Internal server error"); + + getStub(client).rewindInstance = (_req: any, _metadata: any, callback: any) => { + callback(grpcError, null); + return {} as grpc.ClientUnaryCall; + }; + + try { + await client.rewindInstance("test-instance", "test reason"); + fail("Expected an error to be thrown"); + } catch (e: unknown) { + // Unrecognized gRPC status codes should be rethrown as-is + expect(e).toBe(grpcError); + } + }); + }); + + describe("restartOrchestration", () => { + it("should preserve error cause for NOT_FOUND gRPC errors", async () => { + const grpcError = createGrpcError(grpc.status.NOT_FOUND, "Instance not found"); + + getStub(client).restartInstance = (_req: any, _metadata: any, callback: any) => { + callback(grpcError, null); + return {} as grpc.ClientUnaryCall; + }; + + try { + await client.restartOrchestration("test-instance"); + fail("Expected an error to be thrown"); + } catch (e: unknown) { + expect(e).toBeInstanceOf(Error); + const error = e as Error; + expect(error.message).toContain("test-instance"); + expect(error.message).toContain("was not found"); + expect(error.cause).toBe(grpcError); + } + }); + + it("should preserve error cause for FAILED_PRECONDITION gRPC errors", async () => { + const grpcError = createGrpcError( + grpc.status.FAILED_PRECONDITION, + "Orchestration is still running", + ); + + getStub(client).restartInstance = (_req: any, _metadata: any, callback: any) => { + callback(grpcError, null); + return {} as grpc.ClientUnaryCall; + }; + + try { + await client.restartOrchestration("test-instance"); + fail("Expected an error to be thrown"); + } catch (e: unknown) { + expect(e).toBeInstanceOf(Error); + const error = e as Error; + expect(error.message).toContain("test-instance"); + expect(error.message).toContain("cannot be restarted"); + expect(error.cause).toBe(grpcError); + } + }); + + it("should preserve error cause for CANCELLED gRPC errors", async () => { + const grpcError = createGrpcError(grpc.status.CANCELLED, "Cancelled"); + + getStub(client).restartInstance = (_req: any, _metadata: any, callback: any) => { + callback(grpcError, null); + return {} as grpc.ClientUnaryCall; + }; + + try { + await client.restartOrchestration("test-instance"); + fail("Expected an error to be thrown"); + } catch (e: unknown) { + expect(e).toBeInstanceOf(Error); + const error = e as Error; + expect(error.message).toContain("was canceled"); + expect(error.cause).toBe(grpcError); + } + }); + + it("should rethrow unrecognized gRPC errors without wrapping", async () => { + const grpcError = createGrpcError(grpc.status.INTERNAL, "Internal server error"); + + getStub(client).restartInstance = (_req: any, _metadata: any, callback: any) => { + callback(grpcError, null); + return {} as grpc.ClientUnaryCall; + }; + + try { + await client.restartOrchestration("test-instance"); + fail("Expected an error to be thrown"); + } catch (e: unknown) { + // Unrecognized gRPC status codes should be rethrown as-is + expect(e).toBe(grpcError); + } + }); + + it("should validate instanceId parameter", async () => { + await expect(client.restartOrchestration("")).rejects.toThrow("instanceId cannot be null or empty"); + }); + }); +});