Skip to content

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

FixDevs ·

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:

  • HTTP 200 is by design — GraphQL specs allow partial success. If some fields resolve successfully and others fail, the response is HTTP 200 with both data (partial) and errors. HTTP 4xx/5xx are reserved for transport errors.
  • Unhandled resolver exceptions become GraphQL errors — any uncaught error in a resolver is caught by the GraphQL engine and added to the errors array. The field value becomes null.
  • Stack traces exposed in development — most GraphQL servers (Apollo, Yoga) include stacktrace in extensions during development. Without proper NODE_ENV configuration, this leaks to production.
  • Silent null fields — if a resolver returns null (not throws), there’s no error in the response. The client sees null data without knowing why.
  • Error masking — Apollo Server 4 masks unexpected errors by default. Non-ApolloError / non-GraphQLError subclasses become “Internal server error” — intentional, but confusing if you expect the original message.

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: Configure formatError for Production

Strip internal details from production error responses:

// 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') {
      // Check if it's an expected, safe error
      const isSafeError =
        error instanceof GraphQLError &&
        error.extensions?.code !== undefined;

      if (!isSafeError) {
        // Log the real error server-side
        console.error('Unexpected GraphQL error:', error);

        // Return a generic message to the client
        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,
    };
  },
});

Apollo Server 4 error masking:

// Apollo Server 4 masks unexpected errors by default
// Error masking behavior:
// - GraphQLError subclasses → message shown as-is
// - Unexpected errors → replaced with "Internal server error"

// To see original messages in development:
const server = new ApolloServer({
  typeDefs,
  resolvers,
  includeStacktraceInErrorResponses: process.env.NODE_ENV !== 'production',
});

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: 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 5: 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'
    ) {
      // Log to your error tracking service
      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 6: 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);
}

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.

For related API issues, see Fix: GraphQL N+1 Problem and Fix: GraphQL Subscription Not Updating.

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