DEV Community

Muhammad Adnan
Muhammad Adnan

Posted on

Every typed GraphQL error came back as "Internal server error" because graphql was loaded twice

I had six integration tests for error codes on a small GraphQL + Postgres backend: UNAUTHENTICATED, FORBIDDEN, NOT_FOUND, BAD_USER_INPUT and friends. The resolvers threw the right errors. CI still failed the tests, and every error came back the same:

{ "errors": [{ "message": "Unexpected error.", "extensions": { "code": "INTERNAL_SERVER_ERROR" } }] }
Enter fullscreen mode Exit fullscreen mode

Nothing was wrong with the resolvers. The server's error masking had decided our errors weren't GraphQL errors at all.

The setup

Expected failures are typed. Everything else gets masked so stack traces and SQL never reach the client:

export class AppError extends GraphQLError {
  constructor(code: ErrorCode, message: string, extra: Record<string, unknown> = {}) {
    super(message, { extensions: { code, ...extra } });
  }
}

// resolver
if (!canEdit) throw new AppError('FORBIDDEN', 'Not allowed');
Enter fullscreen mode Exit fullscreen mode

The masking rule is the usual one: if the thrown error is a GraphQLError (or wraps one), pass it through; otherwise replace it with INTERNAL_SERVER_ERROR. In GraphQL Yoga that's maskedErrors: true; in Apollo it's a formatError with an instanceof check.

The cause: two GraphQLError classes

instanceof compares against one specific class object. If the module that defines GraphQLError is loaded twice, there are two classes with the same name, and an error built from one is not an instanceof the other.

graphql v16 ships both a CommonJS build (index.js) and an ES module build (index.mjs). Your code can get one while your server library gets the other, depending on how each is imported and how your test runner or bundler resolves them. Vitest, Jest and some bundlers are common places for this to happen. A monorepo with two graphql versions in node_modules does the same thing more obviously.

So the chain was:

  1. The resolver throws AppError, which extends copy A of GraphQLError.
  2. The server checks it against copy B.
  3. instanceof is false, so the error counts as unexpected.
  4. The client gets INTERNAL_SERVER_ERROR, and the real code is only in the server log.

In production this looks like "the frontend can't tell a wrong password from an outage." It's easy to ship, because a manual test of the happy path never sees it.

How to check if you have it

Throw an AppError from any resolver and log this where your server handles errors:

import { GraphQLError } from 'graphql';

console.log(
  err.originalError?.constructor?.name,          // "AppError"
  err.originalError instanceof GraphQLError,     // false means two copies
);
Enter fullscreen mode Exit fullscreen mode

Then look for duplicates:

npm ls graphql
Enter fullscreen mode Exit fullscreen mode

More than one version listed is the obvious case. One version but false above means the CJS/ESM split.

The fix: recognize your errors by a brand, not by class identity

Give your error class a property that survives any number of module copies, and check that instead:

export class AppError extends GraphQLError {
  readonly isAppError = true;
  constructor(code: ErrorCode, message: string, extra: Record<string, unknown> = {}) {
    super(message, { extensions: { code, ...extra } });
  }
}

export function isAppError(err: unknown): err is AppError {
  return typeof err === 'object' && err !== null && (err as { isAppError?: unknown }).isAppError === true;
}
Enter fullscreen mode Exit fullscreen mode

Then do the masking yourself, so the decision uses the brand:

export function maskError(error: unknown, message: string): Error {
  const e = error as { originalError?: unknown; nodes?: any; path?: readonly (string | number)[] };
  const app = isAppError(e) ? e : isAppError(e?.originalError) ? e.originalError : null;
  if (app) {
    return new GraphQLError(app.message, { nodes: e.nodes, path: e.path, extensions: app.extensions });
  }
  console.error(error);
  return new GraphQLError(message, { nodes: e?.nodes, path: e?.path, extensions: { code: 'INTERNAL_SERVER_ERROR' } });
}

// GraphQL Yoga
createYoga({ schema, maskedErrors: { maskError } });
Enter fullscreen mode Exit fullscreen mode

For Apollo Server, use the same isAppError check inside formatError.

Deduplicating graphql (one version, one import style) is worth doing too. But the brand check keeps working when a future dependency upgrade brings the second copy back, and that's the failure you won't be watching for.

The test that caught it

One assertion per error code, against a real server and a real Postgres:

it('forbids viewers from creating issues', async () => {
  const res = await gql(
    'mutation($i: CreateIssueInput!) { createIssue(input: $i) { id } }',
    { i: { projectId: ids.alpha, title: 'Pour slab' } },
    ids.bo, // a member with the viewer role
  );
  expect(res.errors?.[0]?.extensions?.code).toBe('FORBIDDEN');
});
Enter fullscreen mode Exit fullscreen mode

If your error-code tests only call resolver functions directly, they skip the masking layer and pass while clients get 500s. Send the request through the server.

Checklist

  • Typed errors carry a brand property; masking checks the brand, not instanceof.
  • Masking runs in one function you own, with the same rule for every server.
  • npm ls graphql shows one version.
  • Each error code has a test that goes through the HTTP layer and asserts extensions.code.

Making Claude Code write it this way

This is one of the rules in the typed-errors skill of my Claude Code skills pack for TypeScript + GraphQL + Postgres backends. The starter backend in the pack is where CI caught this bug.

Top comments (0)