Skip to content

GraphQL Error Handling — Error Patterns and Best Practices

DodaTech Updated 2026-06-28 7 min read

In this tutorial, you will learn about Graphql Error Handling. We cover key concepts, practical examples, and best practices to help you master this topic.

GraphQL error handling differs from REST — errors are returned alongside data in a partial success model, requiring both client and server to handle errors gracefully.

What You'll Learn

You will learn Apollo Server error types, the partial success model, custom error codes, error masking in production, client-side error handling, and structured error responses.

Why Error Handling Matters

REST APIs return errors as HTTP status codes. GraphQL always returns 200 — errors are embedded in the response body. This enables partial success: one resolver might fail while others succeed, and the client gets both data and errors. DodaTech's Durga Antivirus Pro dashboard handles partial success gracefully — if the threat feed is down but device data is available, the dashboard shows devices with a "threat feed unavailable" banner instead of a blank screen.

flowchart TB
    A["GraphQL Response"] --> B["data: { threats: [...], user: null }"]
    A --> C["errors: [{ message: 'User not found', path: ['user'] }]"]
    D["Client checks"] --> E["errors present?"]
    E -->|"Yes"| F["Show warning\nbut render data"]
    E -->|"No"| G["Render normally"]
    style A fill:#dbeafe,stroke:#2563eb
    style C fill:#fca5a5,stroke:#dc2626
â„šī¸ Info

Prerequisites: GraphQL mutations and resolvers.

Apollo Server Error Types

const { 
  ApolloError,
  AuthenticationError,    // 401 — Not authenticated
  ForbiddenError,         // 403 — Not authorized
  UserInputError,         // 400 — Invalid input
  ValidationError,        // 422 — Validation failed
} = require('apollo-server');

const resolvers = {
  Mutation: {
    createDevice: (_, { input }, context) => {
      // Authentication check
      if (!context.user) {
        throw new AuthenticationError('You must be logged in');
      }
      
      // Authorization check
      if (context.user.role !== 'ADMIN') {
        throw new ForbiddenError('Only admins can create devices');
      }
      
      // Input validation
      if (!input.name || input.name.length < 2) {
        throw new UserInputError('Device name must be at least 2 characters', {
          invalidArgs: ['name'],
        });
      }
      
      // Business validation
      if (input.name.includes('<script>')) {
        throw new ValidationError('Device name contains invalid characters');
      }
      
      return createDevice(input);
    },
  },
};
// Response for UserInputError
{
  "data": null,
  "errors": [
    {
      "message": "Device name must be at least 2 characters",
      "extensions": {
        "code": "BAD_USER_INPUT",
        "invalidArgs": ["name"]
      }
    }
  ]
}

Custom Error Codes

const { ApolloError } = require('apollo-server');

// Define custom error class
class DeviceOfflineError extends ApolloError {
  constructor(message, deviceId) {
    super(message, 'DEVICE_OFFLINE', { deviceId });
    Object.defineProperty(this, 'name', { value: 'DeviceOfflineError' });
  }
}

class ThreatNotFoundError extends ApolloError {
  constructor(threatId) {
    super(`Threat ${threatId} not found`, 'THREAT_NOT_FOUND', { threatId });
  }
}

class RateLimitError extends ApolloError {
  constructor(retryAfter) {
    super('Too many requests', 'RATE_LIMITED', { retryAfter });
  }
}

// Usage
const resolvers = {
  Mutation: {
    scanDevice: async (_, { id }, context) => {
      const device = await context.db.devices.findById(id);
      if (!device) {
        throw new UserInputError('Device not found', { invalidArgs: ['id'] });
      }
      if (device.status === 'OFFLINE') {
        throw new DeviceOfflineError('Device is offline and cannot be scanned', id);
      }
      if (context.rateLimiter.exceeded()) {
        throw new RateLimitError(60);
      }
      return startScan(device);
    },
  },
};

Error Masking in Production

const server = new ApolloServer({
  typeDefs,
  resolvers,
  formatError: (formattedError, error) => {
    // In production, mask internal error details
    if (process.env.NODE_ENV === 'production') {
      // Don't expose stack traces or internal messages
      if (formattedError.extensions?.code === 'INTERNAL_SERVER_ERROR' 
          || !formattedError.extensions?.code) {
        return {
          message: 'An unexpected error occurred',
          extensions: { code: 'INTERNAL_SERVER_ERROR' },
        };
      }
    }
    
    // Always include error code if present
    return {
      message: formattedError.message,
      extensions: {
        code: formattedError.extensions?.code || 'UNKNOWN',
        ...(formattedError.extensions?.invalidArgs && {
          invalidArgs: formattedError.extensions.invalidArgs,
        }),
      },
    };
  },
});

Partial Success — Client Handling

// Client-side error handling
const query = gql`
  query DashboardData {
    user(id: "user-001") { name email }
    threats { id name severity }
    devices { id name }
  }
`;

const { data, error } = await client.query({ query });

// Check for partial success
if (error?.graphQLErrors?.length) {
  // Some resolvers failed, but data is still available
  for (const err of error.graphQLErrors) {
    console.error(`Error at path: ${err.path?.join('.')}`);
    console.error(`Message: ${err.message}`);
  }
}

// Render what we have
if (data?.user) renderUser(data.user);
if (data?.threats) renderThreats(data.threats);
if (data?.devices) renderDevices(data.devices);

Structured Error Response Pattern

// Return errors in data for better client handling
type DeviceResult {
  success: Boolean!
  device: Device
  errors: [DeviceError!]
}

type DeviceError {
  field: String!
  message: String!
  code: String!
}

type Mutation {
  createDevice(input: CreateDeviceInput!): DeviceResult!
}
const resolvers = {
  Mutation: {
    createDevice: async (_, { input }, context) => {
      const errors = [];
      
      if (!input.name) errors.push({ field: 'name', message: 'Required', code: 'REQUIRED' });
      if (input.name && input.name.length < 2) errors.push({ field: 'name', message: 'Too short', code: 'MIN_LENGTH' });
      if (!input.os) errors.push({ field: 'os', message: 'Required', code: 'REQUIRED' });
      
      if (errors.length > 0) {
        return { success: false, device: null, errors };
      }
      
      const device = await context.db.devices.create(input);
      return { success: true, device, errors: [] };
    },
  },
};

Common Mistakes

1. Throwing Generic Errors

throw new Error('Something went wrong') returns an INTERNAL_SERVER_ERROR with no useful information. Use typed Apollo errors.

2. Exposing Stack Traces in Production

Default Apollo Server includes stack traces in error extensions. Use formatError to mask them in production.

3. Ignoring Partial Success

Clients should always check for both data and errors. Rendering only when data exists ignores partial success.

4. Not Setting Error Codes

Without extensions.code, clients cannot programmatically handle errors. Always set a specific error code.

5. Throwing Errors in Subscription Resolvers

Subscription errors close the Websocket connection. Handle errors gracefully in subscription resolvers and send error messages as events instead.

Practice Questions

  1. How does GraphQL error handling differ from REST?
  2. What Apollo Server error types are available?
  3. How do you create custom error codes?
  4. What does formatError do?
  5. How should clients handle GraphQL errors?

Answers:

  1. GraphQL always returns HTTP 200 with errors in the response body (errors array alongside data). REST uses HTTP status codes. GraphQL supports partial success.
  2. AuthenticationError (401), ForbiddenError (403), UserInputError (400), ValidationError (422), and generic ApolloError for custom errors.
  3. Create a class extending ApolloError with a unique error code string: class MyError extends ApolloError { constructor() { super(message, 'MY_CODE'); } }.
  4. formatError is an Apollo Server option that transforms errors before sending the response. Use it to mask internals, add consistent formatting, or log errors.
  5. Always check both data and errors. Render available data, show warnings for failed sections, and never assume full success or full failure.

Challenge: Design a comprehensive error handling system for DodaTech's GraphQL API. Include custom error classes for each domain (DeviceOfflineError, ScanInProgressError, ThreatAlreadyResolvedError), structured error responses for mutations, error masking in production, and client-side handling that shows per-section error states in the dashboard.

FAQ

What is a GraphQL error extension?

Extensions are additional metadata attached to errors, including error codes, invalid argument names, and exception details. They go in the extensions field of each error object.

Should I use errors array or return errors in data?

Use the errors array for system-level errors (auth, not found, server error). Return errors in data (via a result wrapper type) for business logic errors (validation, insufficient balance).

How do I log GraphQL errors?

Use Apollo Server plugins: plugins: [{ requestDidStart: () => ({ didEncounterErrors: (ctx) => logger.error(ctx.errors) }) }].

What happens if formatError throws?

Apollo Server catches the formatError exception and returns a generic internal server error. Never throw from formatError — wrap it in try/catch.

Can I throw errors from subscription resolvers?

Throwing from a subscribe function closes the WebSocket connection. Instead, return an error event or filter out invalid events in the async iterator.

Mini Project

Build a GraphQL error handling system for DodaTech. Include domain-specific error classes, a formatError function that masks internals in production, mutation result types with structured errors, and a React ErrorBoundary component that renders per-section error states for the dashboard.

What's Next

Topic Description
Authentication Auth in GraphQL APIs
Authorization Role-based access control
Security Depth limiting, cost analysis
âŦ… Batching Strategies
➡ Authentication

Built by the developers of DodaTech

Doda Browser, DodaZIP & Durga Antivirus Pro