DEV Community

Art Levitt
Art Levitt

Posted on

Inngest's instanceof lies: why custom error classes vanish across step.run

A small bug that will silently break your retry logic, your error reporting, and your dashboards.

We run a long pipeline through Inngest - voice generation that calls OpenAI, then ElevenLabs, then writes to storage, with a consent gate at the top. Each vendor call lives in its own step.run so a transient failure on one vendor doesn't replay the rest of the pipeline.

We classify failures with custom error classes:

export class TTSUpstreamError extends Error {
  constructor(public status: number, message: string) {
    super(message);
    this.name = "TTSUpstreamError";
  }
}
And the failure handler did the obvious thing:

function ttsReasonFor(err: unknown): string {
  if (err instanceof TTSUpstreamError) return `elevenlabs_upstream:${err.status}`;
  if (err instanceof TTSNotConfiguredError) return "elevenlabs_not_configured";
  return "elevenlabs_unknown";
}
Enter fullscreen mode Exit fullscreen mode

Worked in dev. Worked in tests. In production, every failed run was logged as elevenlabs_unknown - including a real 402 from levenLabs that should have been classified as elevenlabs_upstream:402. We were debugging blind.

What's actually happening
Inngest's step.run wraps your function so the step result (success or failure) can be memoized. On a retry, instead of re-running the step, Inngest replays the memoized outcome. That requires serializing everything across the step boundary - including thrown errors.

When an error crosses that boundary, Inngest preserves error.name and error.message (and error.stack if you're lucky). What it does not preserve is class identity. By the time your catch block sees the error, it's a plain Error object with the right shape but a different prototype.

So err instanceof TTSUpstreamError returns false. Always.

The fix
Match on err.name, not on class identity. name survives serialization. Keep the instanceof check too, for the case where the throw happens outside a step.run - there, the original class identity is intact.

function ttsReasonFor(err: unknown): string {
  if (!(err instanceof Error)) return "elevenlabs_unknown";
  const name = err.name;
  const msg = err.message ?? "";
  if (name === "TTSNotConfiguredError") return "elevenlabs_not_configured";
  if (name === "TTSUpstreamError") {
    const status = msg.match(/status=(\d{3})/)?.[1] ?? "0";
    return `elevenlabs_upstream:${status}`;
  }
  // Direct throws (outside step.run) keep class identity intact.
  if (err instanceof TTSUpstreamError) return `elevenlabs_upstream:${err.status}`;
  if (err instanceof TTSNotConfiguredError) return "elevenlabs_not_configured";
  return "elevenlabs_unknown";
}

Enter fullscreen mode Exit fullscreen mode

Two things to notice:
We pull the status code back out of the message string. That's only possible because we put it there in the first place when constructing the error: new TTSUpstreamError(402, "ElevenLabs failed (status=402)"). If your error messages don't include structured data, add it now - your future self inside an Inngest step will thank you.
We keep the instanceof checks as a fallback. Because the rule is "class identity dies when crossing a step boundary" - but if the throw originates outside step.run, identity is intact and instanceof is the cleaner check.
Why this matters beyond Inngest
Any serialization boundary does this. Same problem appears in:

Web Workers (postMessage strips prototypes)
Any RPC framework
Anything that JSON-serializes errors for logging
If you're going to depend on error type in a system that crosses any of these boundaries, encode the type in name and structured data in message (or in a custom code / data field). Treat instanceof as a happy-path optimization, not a guarantee.

The rule of thumb: if your error has to leave the process - or even just leave the Promise it was thrown in - assume the prototype chain is gone when you catch it.

Top comments (0)