Mesrai
Back to blog
// essayTechnical Deep Dive

Lost Error Context in a Try/Catch Re-Throw: The Stack-Trace Killer

Real PR catch: re-throw as new Error(message) — original stack lost. Error cause option fix.

Mesrai TeamAugust 27, 20268 min read

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

tslib/with-retry.ts
// 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:

textexploit
# 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 location

Cause chain saves debugging time, especially in cloud/serverless contexts where re-throws are common.

Mesrai's review comment

textreview-comment.txt
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

tslib/with-retry.ts (fixed)
// 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.

// try

See it on your next PR.

Free for individuals. Install in two minutes. Mesrai reviews every commit.