diff --git a/packages/durabletask-js/src/utils/pb-helper.util.ts b/packages/durabletask-js/src/utils/pb-helper.util.ts index dd03077..7551054 100644 --- a/packages/durabletask-js/src/utils/pb-helper.util.ts +++ b/packages/durabletask-js/src/utils/pb-helper.util.ts @@ -186,6 +186,16 @@ export function newSubOrchestrationFailedEvent(eventId: number, ex: Error): pb.H } export function newFailureDetails(e: unknown): pb.TaskFailureDetails { + return buildFailureDetails(e, 0); +} + +/** + * Recursively builds TaskFailureDetails, populating innerFailure from error.cause. + * The depth parameter is internal to bound the cause-chain recursion and is not + * exposed on the public newFailureDetails() signature. + */ +function buildFailureDetails(e: unknown, depth: number): pb.TaskFailureDetails { + const MAX_CAUSE_DEPTH = 10; const failure = new pb.TaskFailureDetails(); // Use e.name (which can be customized) over constructor.name (which is always the class name) // This allows users to set error.name = "CustomError" and have it preserved in failure details @@ -202,6 +212,14 @@ export function newFailureDetails(e: unknown): pb.TaskFailureDetails { failure.setStacktrace(sv); } + // Populate innerFailure from error.cause to preserve the full error chain. + // A depth limit guards against pathological circular cause chains. + // error.cause can be any value, so guard against null/undefined explicitly rather + // than truthiness — a falsy-but-present cause (e.g. "" or 0) is still a valid cause. + if (e instanceof Error && e.cause != null && depth < MAX_CAUSE_DEPTH) { + failure.setInnerfailure(buildFailureDetails(e.cause, depth + 1)); + } + return failure; } diff --git a/packages/durabletask-js/test/pb-helper-versioning.spec.ts b/packages/durabletask-js/test/pb-helper-versioning.spec.ts index 9331c4c..2fa544b 100644 --- a/packages/durabletask-js/test/pb-helper-versioning.spec.ts +++ b/packages/durabletask-js/test/pb-helper-versioning.spec.ts @@ -66,5 +66,85 @@ describe("pb-helper.util - Version Mismatch Failure Details", () => { expect(failure.getErrortype()).toBe("CustomError"); expect(failure.getErrormessage()).toBe("Custom error message"); }); + + it("should populate innerFailure from error.cause", () => { + const innerError = new TypeError("Cannot read property 'x' of undefined"); + const outerError = new Error("Failed to process input", { cause: innerError }); + const failure = newFailureDetails(outerError); + + expect(failure.getErrortype()).toBe("Error"); + expect(failure.getErrormessage()).toBe("Failed to process input"); + + const inner = failure.getInnerfailure(); + expect(inner).toBeDefined(); + expect(inner!.getErrortype()).toBe("TypeError"); + expect(inner!.getErrormessage()).toBe("Cannot read property 'x' of undefined"); + expect(inner!.getStacktrace()).toBeDefined(); + }); + + it("should handle multi-level error cause chains", () => { + const rootCause = new RangeError("Index out of bounds"); + const midError = new Error("Data access failed", { cause: rootCause }); + const topError = new Error("Operation failed", { cause: midError }); + const failure = newFailureDetails(topError); + + expect(failure.getErrortype()).toBe("Error"); + expect(failure.getErrormessage()).toBe("Operation failed"); + + const mid = failure.getInnerfailure(); + expect(mid).toBeDefined(); + expect(mid!.getErrortype()).toBe("Error"); + expect(mid!.getErrormessage()).toBe("Data access failed"); + + const root = mid!.getInnerfailure(); + expect(root).toBeDefined(); + expect(root!.getErrortype()).toBe("RangeError"); + expect(root!.getErrormessage()).toBe("Index out of bounds"); + expect(root!.getInnerfailure()).toBeUndefined(); + }); + + it("should handle non-Error cause values", () => { + const outerError = new Error("Wrapper", { cause: "string cause" }); + const failure = newFailureDetails(outerError); + + const inner = failure.getInnerfailure(); + expect(inner).toBeDefined(); + expect(inner!.getErrortype()).toBe("UnknownError"); + expect(inner!.getErrormessage()).toBe("string cause"); + }); + + it("should not set innerFailure when there is no cause", () => { + const error = new Error("No cause"); + const failure = newFailureDetails(error); + + expect(failure.getInnerfailure()).toBeUndefined(); + }); + + it("should not set innerFailure for non-Error values", () => { + const failure = newFailureDetails("plain string error"); + + expect(failure.getErrortype()).toBe("UnknownError"); + expect(failure.getErrormessage()).toBe("plain string error"); + expect(failure.getInnerfailure()).toBeUndefined(); + }); + + it("should cap recursion depth for deep cause chains", () => { + // Build a linear cause chain deeper than the limit (10) + let error: Error = new Error("root"); + for (let i = 0; i < 15; i++) { + error = new Error(`level-${i}`, { cause: error }); + } + + const failure = newFailureDetails(error); + + // Walk down the chain — should stop at depth 10 + let current = failure; + let depth = 0; + while (current.getInnerfailure()) { + current = current.getInnerfailure()!; + depth++; + } + expect(depth).toBe(10); + }); }); });