Skip to content

Fix: GraphQL Error Handling Not Working — Errors Not Returned or Always 200 OK

FixDevs · (Updated: )

Part of:  JavaScript & TypeScript Errors

Quick Answer

How to fix GraphQL error handling — error extensions, partial data with errors, Apollo formatError, custom error classes, client-side error detection, and network vs GraphQL errors.

The Problem

GraphQL always returns HTTP 200 even when an error occurs:

HTTP/1.1 200 OK
{
  "data": null,
  "errors": [
    {
      "message": "User not found",
      "locations": [{ "line": 2, "column": 3 }],
      "path": ["user"]
    }
  ]
}

Or errors include stack traces in production:

{
  "errors": [
    {
      "message": "Cannot read properties of undefined (reading 'id')",
      "extensions": {
        "stacktrace": [
          "TypeError: Cannot read properties of undefined",
          "    at UserResolver.getUser (/app/resolvers/user.ts:42:18)",
          "    at ..."
        ]
      }
    }
  ]
}

Or the client receives a 200 response but data is null with no errors array — the error is silently lost.

Or field-level errors (partial failures) aren’t distinguishable from full query failures.

Why This Happens

GraphQL has a unique error model that differs from REST APIs, and each server implementation interprets the spec differently.

The GraphQL specification intentionally uses HTTP 200 for all responses that successfully execute, even when execution encounters errors. This is because GraphQL supports partial success: if a query requests ten fields and one resolver throws, the response is HTTP 200 with nine populated fields in data and one entry in errors. HTTP status codes like 4xx and 5xx are reserved for transport-level failures — a malformed request body, a server crash, or a CORS rejection. This “errors inside a 200” model confuses developers coming from REST, where the HTTP status code is the primary error signal.

The second layer of complexity comes from error masking. Apollo Server 4 masks unexpected errors by default: any error that isn’t a GraphQLError subclass gets replaced with “Internal server error” in the response. This is a security feature to prevent leaking stack traces and internal details, but it means that throwing a plain Error('User not found') in a resolver produces a generic message on the client. Other servers like graphql-yoga and Mercurius have their own masking behavior. And on the client side, Apollo Client’s default errorPolicy: 'none' throws away partial data entirely when any error exists — you get either data or an error, never both, unless you explicitly opt in with errorPolicy: 'all'.

Fix 1: Create Typed GraphQL Errors

Use specific error classes that communicate the error type and control what’s exposed to clients:

// errors.ts — custom error classes
import { GraphQLError } from 'graphql';

// Base class for expected errors (user-facing messages safe to show)
export class AppError extends GraphQLError {
  constructor(message: string, extensions?: Record<string, unknown>) {
    super(message, {
      extensions: {
        ...extensions,
        // code helps clients distinguish error types
      },
    });
  }
}

export class NotFoundError extends AppError {
  constructor(resource: string, id?: string) {
    super(`${resource}${id ? ` (${id})` : ''} not found`, {
      code: 'NOT_FOUND',
      http: { status: 404 },
    });
  }
}

export class ValidationError extends AppError {
  constructor(message: string, fields?: Record<string, string>) {
    super(message, {
      code: 'VALIDATION_ERROR',
      fields,
      http: { status: 400 },
    });
  }
}

export class AuthenticationError extends AppError {
  constructor(message = 'Not authenticated') {
    super(message, {
      code: 'UNAUTHENTICATED',
      http: { status: 401 },
    });
  }
}

export class ForbiddenError extends AppError {
  constructor(message = 'Permission denied') {
    super(message, {
      code: 'FORBIDDEN',
      http: { status: 403 },
    });
  }
}

Use in resolvers:

// resolvers/user.ts
import { NotFoundError, ValidationError, AuthenticationError } from '../errors';

const resolvers = {
  Query: {
    user: async (_, { id }, { currentUser }) => {
      if (!currentUser) throw new AuthenticationError();

      const user = await UserService.findById(id);
      if (!user) throw new NotFoundError('User', id);

      return user;
    },
  },

  Mutation: {
    createUser: async (_, { input }) => {
      if (!input.email.includes('@')) {
        throw new ValidationError('Invalid email format', {
          email: 'Must be a valid email address',
        });
      }

      const existing = await UserService.findByEmail(input.email);
      if (existing) {
        throw new ValidationError('Email already registered', {
          email: 'This email is already in use',
        });
      }

      return UserService.create(input);
    },
  },
};

Fix 2: Server-Specific Error Formatting

Each GraphQL server handles error formatting differently. Configure yours correctly for production.

Apollo Server 4:

import { ApolloServer } from '@apollo/server';
import { GraphQLError } from 'graphql';

const server = new ApolloServer({
  typeDefs,
  resolvers,

  formatError: (formattedError, error) => {
    // In production — hide unexpected errors
    if (process.env.NODE_ENV === 'production') {
      const isSafeError =
        error instanceof GraphQLError &&
        error.extensions?.code !== undefined;

      if (!isSafeError) {
        console.error('Unexpected GraphQL error:', error);
        return {
          message: 'An unexpected error occurred',
          extensions: { code: 'INTERNAL_SERVER_ERROR' },
        };
      }
    }

    // Remove stack trace from extensions in all environments
    const { stacktrace, ...safeExtensions } = formattedError.extensions ?? {};

    return {
      ...formattedError,
      extensions: safeExtensions,
    };
  },

  // Control stack trace inclusion per environment
  includeStacktraceInErrorResponses: process.env.NODE_ENV !== 'production',
});

graphql-yoga:

graphql-yoga masks errors differently from Apollo. By default, it replaces unexpected error messages with “Unexpected error.” and preserves GraphQLError messages:

import { createYoga, maskError } from 'graphql-yoga';

const yoga = createYoga({
  schema,
  maskedErrors: {
    // Custom mask function — control which errors are exposed
    maskError: (error, message, isDev) => {
      if (error instanceof GraphQLError) {
        // Expected errors — return as-is
        return error;
      }
      // Unexpected errors — mask in production
      if (isDev) return error;
      return maskError(error, message, isDev);
    },
  },
});

Mercurius (Fastify):

Mercurius includes error formatting in its Fastify plugin options:

import Fastify from 'fastify';
import mercurius from 'mercurius';

const app = Fastify();

app.register(mercurius, {
  schema,
  resolvers,
  errorFormatter: (result, context) => {
    const errors = result.errors?.map(err => {
      // Strip stack traces
      const { stacktrace, ...extensions } = err.extensions ?? {};
      return { ...err, extensions };
    });

    return {
      statusCode: result.statusCode || 200,
      response: { data: result.data, errors },
    };
  },
});

Fix 3: Handle Partial Errors on the Client

GraphQL responses can have BOTH data and errors. Check both:

// Apollo Client — check for errors even with 200 response
const { data, errors, loading } = useQuery(GET_USER, {
  variables: { id: userId },
  errorPolicy: 'all',   // Return partial data + errors (default is 'none')
});

// errorPolicy options:
// 'none' (default) — if any error, data is undefined
// 'ignore'         — ignore errors, return whatever data resolved
// 'all'            — return partial data + errors array

if (errors?.length) {
  errors.forEach(err => {
    console.error('GraphQL error:', err.message, err.extensions?.code);
  });
}

if (data?.user) {
  renderUser(data.user);
}

Distinguish network errors from GraphQL errors:

// Apollo Client
import { useQuery } from '@apollo/client';

function UserProfile({ userId }) {
  const { data, loading, error } = useQuery(GET_USER, {
    variables: { id: userId },
  });

  if (loading) return <Spinner />;

  // error.networkError — HTTP/transport error (500, CORS, network down)
  // error.graphQLErrors — GraphQL execution errors in the response
  if (error) {
    if (error.networkError) {
      return <ErrorPage message="Network error — check your connection" />;
    }

    const errorCode = error.graphQLErrors[0]?.extensions?.code;

    switch (errorCode) {
      case 'UNAUTHENTICATED':
        return <Redirect to="/login" />;
      case 'NOT_FOUND':
        return <NotFound message="User not found" />;
      case 'FORBIDDEN':
        return <Forbidden />;
      default:
        return <ErrorPage message={error.message} />;
    }
  }

  return <User user={data.user} />;
}

With fetch directly (no Apollo):

async function graphqlRequest(query, variables) {
  const response = await fetch('/graphql', {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({ query, variables }),
  });

  // GraphQL always returns 200 for execution errors
  // Non-200 means transport/server error
  if (!response.ok) {
    throw new Error(`HTTP error: ${response.status}`);
  }

  const result = await response.json();

  // Check for GraphQL-level errors even on 200
  if (result.errors?.length) {
    const error = result.errors[0];
    throw new GraphQLError(error.message, error.extensions?.code);
  }

  return result.data;
}

Fix 4: Error Handling in GraphQL Federation

In a federated (supergraph) architecture, errors from subgraphs propagate through the gateway. Each subgraph may format errors differently, and the gateway must merge them.

Apollo Federation error propagation:

// Subgraph — throw a GraphQLError with extensions
// The gateway preserves the extensions.code and path
throw new GraphQLError('Product not found', {
  extensions: {
    code: 'NOT_FOUND',
    serviceName: 'products',   // Helps identify which subgraph failed
  },
});

Gateway-level error handling:

// Apollo Gateway / Apollo Router
// Errors from subgraphs appear in the supergraph response's errors array
// Each error includes the path through the federated schema

// Client sees:
{
  "data": {
    "order": {
      "id": "123",
      "product": null    // This subgraph field failed
    }
  },
  "errors": [{
    "message": "Product not found",
    "path": ["order", "product"],
    "extensions": { "code": "NOT_FOUND", "serviceName": "products" }
  }]
}

Common federation mistake: A subgraph throwing a non-GraphQLError exception gets masked by the gateway. The client sees “Internal server error” with no useful details. Always use GraphQLError subclasses in subgraph resolvers.

Fix 5: Handle Errors in Subscriptions

GraphQL subscription error handling is different from queries and mutations:

// Server — error in subscription resolver
const resolvers = {
  Subscription: {
    messageAdded: {
      subscribe: async function* (_, __, { currentUser }) {
        if (!currentUser) throw new AuthenticationError();

        try {
          for await (const message of messageStream()) {
            yield { messageAdded: message };
          }
        } catch (err) {
          // Subscription errors end the subscription stream
          throw new GraphQLError('Subscription stream failed', {
            extensions: { code: 'SUBSCRIPTION_ERROR' },
          });
        }
      },
    },
  },
};

// Client — Apollo Client subscription error handling
const { data, error } = useSubscription(MESSAGE_ADDED_SUBSCRIPTION, {
  onError: (error) => {
    console.error('Subscription error:', error);
    // error.networkError or error.graphQLErrors
  },
});

Fix 6: Log Errors Server-Side

Ensure server-side errors are captured for debugging while hiding details from clients:

// Apollo Server with error logging
import { ApolloServer } from '@apollo/server';

const server = new ApolloServer({
  typeDefs,
  resolvers,

  formatError: (formattedError, originalError) => {
    // Log unexpected errors
    if (
      !(originalError instanceof GraphQLError) ||
      originalError.extensions?.code === 'INTERNAL_SERVER_ERROR'
    ) {
      logger.error({
        message: 'Unexpected GraphQL error',
        error: originalError,
        graphqlError: formattedError,
      });

      // Report to Sentry, Datadog, etc.
      Sentry.captureException(originalError);
    }

    // Return sanitized error to client
    return sanitizeError(formattedError, originalError);
  },

  plugins: [
    {
      async requestDidStart() {
        return {
          async didEncounterErrors({ errors, operation, variables }) {
            // Log all errors with operation context
            errors.forEach(error => {
              logger.warn({
                operation: operation?.name?.value,
                error: error.message,
                code: error.extensions?.code,
                path: error.path,
              });
            });
          },
        };
      },
    },
  ],
});

Fix 7: Return Errors as Data (Result Pattern)

For mutations, returning errors as part of the response type gives clients type-safe error handling:

# Schema — result union pattern
type CreateUserSuccess {
  user: User!
}

type CreateUserError {
  code: String!
  message: String!
  fields: [FieldError!]
}

type FieldError {
  field: String!
  message: String!
}

union CreateUserResult = CreateUserSuccess | CreateUserError

type Mutation {
  createUser(input: CreateUserInput!): CreateUserResult!
}
// Resolver
const resolvers = {
  Mutation: {
    createUser: async (_, { input }) => {
      const validation = validateUser(input);
      if (!validation.valid) {
        return {
          __typename: 'CreateUserError',
          code: 'VALIDATION_ERROR',
          message: 'Validation failed',
          fields: validation.errors,
        };
      }

      try {
        const user = await UserService.create(input);
        return { __typename: 'CreateUserSuccess', user };
      } catch (err) {
        return {
          __typename: 'CreateUserError',
          code: 'SERVER_ERROR',
          message: 'Failed to create user',
          fields: [],
        };
      }
    },
  },

  CreateUserResult: {
    __resolveType: (obj) => obj.__typename,
  },
};
// Apollo Client — type-safe handling
const CREATE_USER = gql`
  mutation CreateUser($input: CreateUserInput!) {
    createUser(input: $input) {
      ... on CreateUserSuccess {
        user { id name email }
      }
      ... on CreateUserError {
        code
        message
        fields { field message }
      }
    }
  }
`;

const [createUser] = useMutation(CREATE_USER);

const result = await createUser({ variables: { input } });
const { createUser: response } = result.data;

if (response.__typename === 'CreateUserSuccess') {
  router.push(`/users/${response.user.id}`);
} else {
  setErrors(response.fields);
}

Relay-style error handling: Relay encourages using result types rather than relying on the errors array. The Relay compiler generates TypeScript types from your schema, so a union return type gives you compile-time exhaustiveness checking. If you are using Relay, prefer the result union pattern over thrown GraphQLError instances for expected error paths.

Still Not Working?

errorPolicy: 'all' not returning partial data — partial data is only returned if the schema allows nullable fields. If a required field (field: Type!) fails, the null propagates upward until it reaches a nullable parent. Make strategic fields nullable if partial failure is acceptable.

Error boundaries in React with Apollo — Apollo’s default errorPolicy: 'none' throws errors as exceptions. Use an ErrorBoundary component to catch them:

<ApolloProvider client={client}>
  <ErrorBoundary fallback={<ErrorPage />}>
    <UserProfile />
  </ErrorBoundary>
</ApolloProvider>

CORS errors look like GraphQL errors — a CORS preflight failure returns a network error, not a GraphQL error. The client gets no response body. Check error.networkError.statusCode and error.networkError.result.

Apollo Client link chain swallows errors — if you have a custom ApolloLink in your link chain that catches errors (e.g., a retry link or logging link), errors may not reach your component. Verify each link in the chain passes errors through with forward(operation) and doesn’t silently return null.

extensions.code is undefined on the client — Apollo Server 4 sets the error code inside extensions.code, but some older server versions or custom formatError implementations strip extensions. Check that your formatError function preserves the code field. On graphql-yoga, the default extensions structure differs from Apollo, so client code that checks extensions.code may not find the expected field.

Batched queries mix errors — when using Apollo’s BatchHttpLink, multiple operations are sent in one HTTP request. If one operation fails, the entire batch response includes errors for that operation alongside successful results for others. Ensure your error handling checks error.path to identify which operation failed rather than treating the whole batch as an error.

For related API issues, see Fix: GraphQL N+1 Problem, Fix: GraphQL Subscription Not Updating, Fix: GraphQL 400 Bad Request, and Fix: CORS Access-Control-Allow-Origin Error.

F

FixDevs

Solo developer based in Japan. Every solution is cross-referenced with official documentation and tested before publishing.

Was this article helpful?

Related Articles