A retry helper that catches errors and re-throws new ones with descriptive messages. The original stack trace is lost; debugging the underlying cause requires reading the code path again.
The vulnerable diff
// lib/with-retry.ts
async function withRetry<T>(fn: () => Promise<T>, max = 3): Promise<T> {
let err: unknown;
for (let i = 0; i < max; i++) {
try { return await fn(); }
catch (e) { err = e; await sleep(2 ** i * 100); }
}
// BUG: discards original stack
throw new Error("retry failed: " + (err as Error).message);
}What is wrong
JavaScript's Error has a `cause` option (since ES2022, Node 16+) that preserves the original error and its stack while letting you add context. Logs and tools like Sentry display the cause chain so you see both the wrapper message and the underlying error. Constructing a new Error with `err.message` as a string discards the original stack and the original error object — debugging the root cause becomes harder.
The attack
Logs comparison:
# Without cause:
Error: retry failed: ECONNRESET
at withRetry (with-retry.ts:11:9)
at handler (handler.ts:25:5) ← only this stack
# With cause:
Error: retry failed
at withRetry (with-retry.ts:11:9)
[cause]: Error: ECONNRESET
at TCP.onStreamRead (...)
at fetchClient (fetch.ts:84:14) ← actual failure locationCause chain saves debugging time, especially in cloud/serverless contexts where re-throws are common.
Mesrai's review comment
mesraipilot · Bot · reviewed 30 sec ago
[mesrai] [code-review] [Logic] [Stack-Loss] [low]
Re-throwing as `new Error(message)` loses the original stack and
the underlying error. Use Error's `cause`:
throw new Error("retry failed", { cause: err });
Sentry, pino, and modern stack-trace tools render the cause chain.
For multiple errors aggregated (e.g., Promise.allSettled rejections),
use AggregateError:
throw new AggregateError(errors, "all retries failed");The fix
// lib/with-retry.ts — fixed
async function withRetry<T>(fn: () => Promise<T>, max = 3): Promise<T> {
let lastErr: unknown;
for (let i = 0; i < max; i++) {
try { return await fn(); }
catch (e) { lastErr = e; await sleep(2 ** i * 100); }
}
throw new Error("retry failed", { cause: lastErr });
}Cause chain preserved. Wrapper adds context; original error remains debuggable.
Why human review missed it
Error wrapping that loses context is common in retry helpers, middleware, and async pipelines. Mesrai catches every `throw new Error(...err.message)` pattern.
Related rules + further reading
Mesrai rule pack: logic/preserve-error-cause — flags Error construction with err.message instead of cause.
ECMA-262: Error.prototype.cause.
Sentry / pino docs on cause chain rendering.
Takeaway
Use `cause` when re-throwing. Stack chain preserved. Debugging stays sane. Mesrai catches every lossy wrap.