Skip to content

Error Handling

This guide covers comprehensive error handling strategies for GQLB applications, from GraphQL errors to network failures and validation issues.

GraphQL Error Types

Understanding GraphQL Errors

GraphQL errors can occur at different levels:

typescript
import { b } from './generated'

// Query that might produce different error types
const USER_QUERY = b.query('GetUser', { id: 'ID!' }, (b, v) => [
  b.user({ id: v.id }, b => [
    b.id(),
    b.name(),
    b.email(),
    b.posts(b => [
      b.id(),
      b.title(),
    ])
  ])
])

// Different error scenarios:
// 1. Validation errors (query structure)
// 2. Execution errors (resolver failures)
// 3. Network errors (connection issues)
// 4. Permission errors (authorization)

Handling GraphQL Response Errors

typescript
import { ExecutionResult } from 'graphql'

interface GraphQLError {
  message: string
  locations?: Array<{ line: number; column: number }>
  path?: Array<string | number>
  extensions?: {
    code?: string
    exception?: any
    [key: string]: any
  }
}

async function executeWithErrorHandling<T>(
  query: Operation<T>,
  variables: VariablesOf<typeof query>
): Promise<T> {
  try {
    const result: ExecutionResult = await client.query({
      query: query.document(),
      variables
    })

    // Handle GraphQL errors
    if (result.errors && result.errors.length > 0) {
      throw new GraphQLExecutionError(result.errors, result.data)
    }

    // Handle partial data
    if (!result.data) {
      throw new Error('No data returned from GraphQL query')
    }

    return result.data as T
  } catch (error) {
    if (error instanceof ApolloError) {
      throw new GraphQLNetworkError(error)
    }
    throw error
  }
}

Custom Error Classes

Structured Error Handling

typescript
// Base GraphQL error class
export class GraphQLError extends Error {
  constructor(
    message: string,
    public readonly code: string,
    public readonly path?: Array<string | number>
  ) {
    super(message)
    this.name = 'GraphQLError'
  }
}

// Network-related errors
export class GraphQLNetworkError extends GraphQLError {
  constructor(public readonly originalError: Error) {
    super(
      `Network error: ${originalError.message}`,
      'NETWORK_ERROR'
    )
    this.name = 'GraphQLNetworkError'
  }
}

// Execution errors from resolvers
export class GraphQLExecutionError extends GraphQLError {
  constructor(
    public readonly errors: readonly GraphQLFormattedError[],
    public readonly partialData?: any
  ) {
    super(
      `Execution error: ${errors.map(e => e.message).join(', ')}`,
      'EXECUTION_ERROR'
    )
    this.name = 'GraphQLExecutionError'
  }

  hasPartialData(): boolean {
    return this.partialData !== undefined && this.partialData !== null
  }

  getErrorByPath(path: string[]): GraphQLFormattedError | undefined {
    return this.errors.find(error => 
      error.path && 
      JSON.stringify(error.path) === JSON.stringify(path)
    )
  }
}

// Authentication/Authorization errors
export class GraphQLAuthError extends GraphQLError {
  constructor(message: string = 'Authentication required') {
    super(message, 'AUTH_ERROR')
    this.name = 'GraphQLAuthError'
  }
}

// Validation errors
export class GraphQLValidationError extends GraphQLError {
  constructor(
    message: string,
    public readonly field: string
  ) {
    super(message, 'VALIDATION_ERROR')
    this.name = 'GraphQLValidationError'
  }
}

Error Recovery Strategies

Retry Logic

typescript
class QueryRetryHandler {
  private readonly maxRetries: number = 3
  private readonly baseDelay: number = 1000

  async executeWithRetry<T>(
    query: Operation<T>,
    variables: VariablesOf<typeof query>,
    options: {
      maxRetries?: number
      retryDelay?: number
      retryableErrors?: string[]
    } = {}
  ): Promise<T> {
    const maxRetries = options.maxRetries ?? this.maxRetries
    const baseDelay = options.retryDelay ?? this.baseDelay
    const retryableErrors = options.retryableErrors ?? [
      'NETWORK_ERROR',
      'TIMEOUT_ERROR',
      'RATE_LIMITED'
    ]

    let attempt = 0
    let lastError: Error

    while (attempt <= maxRetries) {
      try {
        return await executeWithErrorHandling(query, variables)
      } catch (error) {
        lastError = error
        attempt++

        // Don't retry non-retryable errors
        if (error instanceof GraphQLError && 
            !retryableErrors.includes(error.code)) {
          throw error
        }

        // Don't retry on last attempt
        if (attempt > maxRetries) {
          break
        }

        // Exponential backoff
        const delay = baseDelay * Math.pow(2, attempt - 1)
        await new Promise(resolve => setTimeout(resolve, delay))

        console.log(`Retrying query ${query.operationName} (attempt ${attempt}/${maxRetries})`)
      }
    }

    throw lastError!
  }
}

Fallback Queries

typescript
// Primary query with full data
const FULL_USER_QUERY = b.query('GetFullUser', { id: 'ID!' }, (b, v) => [
  b.user({ id: v.id }, b => [
    b.id(),
    b.name(),
    b.email(),
    b.avatar(),
    b.bio(),
    b.stats(b => [
      b.postsCount(),
      b.followersCount(),
    ]),
    b.posts({ first: 10 }, b => [
      b.id(),
      b.title(),
      b.publishedAt(),
    ])
  ])
])

// Fallback query with minimal data
const MINIMAL_USER_QUERY = b.query('GetMinimalUser', { id: 'ID!' }, (b, v) => [
  b.user({ id: v.id }, b => [
    b.id(),
    b.name(),
    b.avatar(),
  ])
])

async function getUserWithFallback(userId: string) {
  try {
    // Try full query first
    return await executeQuery(FULL_USER_QUERY, { id: userId })
  } catch (error) {
    console.warn('Full user query failed, falling back to minimal query:', error)
    
    try {
      // Fallback to minimal query
      const minimalData = await executeQuery(MINIMAL_USER_QUERY, { id: userId })
      
      // Add default values for missing fields
      return {
        ...minimalData,
        user: minimalData.user ? {
          ...minimalData.user,
          email: null,
          bio: null,
          stats: { postsCount: 0, followersCount: 0 },
          posts: []
        } : null
      }
    } catch (fallbackError) {
      console.error('Both queries failed:', fallbackError)
      throw new Error(`Failed to load user data: ${fallbackError.message}`)
    }
  }
}

Partial Data Handling

Working with Partial Results

typescript
interface PartialDataResult<T> {
  data: Partial<T> | null
  errors: GraphQLFormattedError[]
  hasData: boolean
  hasErrors: boolean
}

function handlePartialData<T>(
  result: ExecutionResult<T>
): PartialDataResult<T> {
  return {
    data: result.data || null,
    errors: result.errors || [],
    hasData: !!result.data,
    hasErrors: !!(result.errors && result.errors.length > 0)
  }
}

// Example usage with dashboard query
const DASHBOARD_QUERY = b.query('GetDashboard', (b) => [
  b.currentUser(b => [
    b.id(),
    b.name(),
  ]),
  b.recentPosts(b => [
    b.id(),
    b.title(),
  ]),
  b.notifications(b => [
    b.id(),
    b.message(),
  ])
])

async function loadDashboard() {
  try {
    const result = await client.query({
      query: DASHBOARD_QUERY.document(),
      errorPolicy: 'all' // Return partial data even with errors
    })

    const partialResult = handlePartialData(result)

    if (partialResult.hasErrors) {
      console.warn('Dashboard loaded with errors:', partialResult.errors)
    }

    // Handle each section independently
    if (partialResult.data?.currentUser) {
      displayUser(partialResult.data.currentUser)
    } else {
      showUserPlaceholder()
    }

    if (partialResult.data?.recentPosts) {
      displayPosts(partialResult.data.recentPosts)
    } else {
      showPostsPlaceholder()
    }

    if (partialResult.data?.notifications) {
      displayNotifications(partialResult.data.notifications)
    } else {
      showNotificationsPlaceholder()
    }

  } catch (error) {
    showDashboardError(error)
  }
}

Error-Aware UI Components

React Error Boundaries

typescript
import React, { Component, ErrorInfo, ReactNode } from 'react'

interface Props {
  children: ReactNode
  fallback?: ReactNode
  onError?: (error: Error, errorInfo: ErrorInfo) => void
}

interface State {
  hasError: boolean
  error?: Error
}

export class GraphQLErrorBoundary extends Component<Props, State> {
  constructor(props: Props) {
    super(props)
    this.state = { hasError: false }
  }

  static getDerivedStateFromError(error: Error): State {
    return { hasError: true, error }
  }

  componentDidCatch(error: Error, errorInfo: ErrorInfo) {
    console.error('GraphQL Error Boundary caught an error:', error, errorInfo)
    
    if (this.props.onError) {
      this.props.onError(error, errorInfo)
    }

    // Report to error tracking service
    reportError(error, {
      component: 'GraphQLErrorBoundary',
      errorInfo
    })
  }

  render() {
    if (this.state.hasError) {
      if (this.props.fallback) {
        return this.props.fallback
      }

      return (
        <div className="error-boundary">
          <h2>Something went wrong</h2>
          <details>
            <summary>Error details</summary>
            <pre>{this.state.error?.message}</pre>
          </details>
          <button onClick={() => this.setState({ hasError: false })}>
            Try again
          </button>
        </div>
      )
    }

    return this.props.children
  }
}

Error-Aware Query Hook

typescript
import { useState, useEffect } from 'react'

interface QueryState<T> {
  data: T | null
  loading: boolean
  error: Error | null
  refetch: () => Promise<void>
}

export function useQueryWithErrorHandling<T>(
  query: Operation<T>,
  variables: VariablesOf<typeof query>,
  options: {
    retry?: boolean
    fallbackQuery?: Operation<any>
    onError?: (error: Error) => void
  } = {}
): QueryState<T> {
  const [state, setState] = useState<QueryState<T>>({
    data: null,
    loading: true,
    error: null,
    refetch: async () => {}
  })

  const executeQuery = async () => {
    setState(prev => ({ ...prev, loading: true, error: null }))

    try {
      const data = await executeWithErrorHandling(query, variables)
      setState(prev => ({ 
        ...prev, 
        data, 
        loading: false 
      }))
    } catch (error) {
      console.error(`Query ${query.operationName} failed:`, error)

      if (options.onError) {
        options.onError(error as Error)
      }

      // Try fallback query if available
      if (options.fallbackQuery) {
        try {
          const fallbackData = await executeWithErrorHandling(
            options.fallbackQuery, 
            variables
          )
          setState(prev => ({ 
            ...prev, 
            data: fallbackData, 
            loading: false,
            error: null
          }))
          return
        } catch (fallbackError) {
          console.error('Fallback query also failed:', fallbackError)
        }
      }

      setState(prev => ({ 
        ...prev, 
        error: error as Error, 
        loading: false 
      }))
    }
  }

  useEffect(() => {
    executeQuery()
  }, [JSON.stringify(variables)])

  return {
    ...state,
    refetch: executeQuery
  }
}

Mutation Error Handling

Structured Mutation Responses

typescript
// Mutation with comprehensive error handling
const CREATE_POST_MUTATION = b.mutation('CreatePost', {
  input: 'CreatePostInput!'
}, (b, v) => [
  b.createPost({ input: v.input }, b => [
    // Success data
    b.post(b => [
      b.id(),
      b.title(),
      b.slug(),
      b.status(),
    ]),
    
    // Operation status
    b.success(),
    
    // Field-level validation errors
    b.validationErrors(b => [
      b.field(),
      b.message(),
      b.code(),
    ]),
    
    // Business logic errors
    b.businessErrors(b => [
      b.type(),
      b.message(),
      b.retryable(),
    ]),
    
    // System errors
    b.systemErrors(b => [
      b.message(),
      b.code(),
      b.details(),
    ])
  ])
])

type CreatePostResult = OutputOf<typeof CREATE_POST_MUTATION>

async function createPostWithErrorHandling(
  input: CreatePostInput
): Promise<CreatePostResult['createPost']> {
  try {
    const result = await executeQuery(CREATE_POST_MUTATION, { input })
    const mutation = result.createPost

    // Handle different error types
    if (!mutation.success) {
      if (mutation.validationErrors.length > 0) {
        throw new ValidationError(mutation.validationErrors)
      }
      
      if (mutation.businessErrors.length > 0) {
        throw new BusinessLogicError(mutation.businessErrors)
      }
      
      if (mutation.systemErrors.length > 0) {
        throw new SystemError(mutation.systemErrors)
      }
    }

    return mutation
  } catch (error) {
    console.error('Create post mutation failed:', error)
    throw error
  }
}

class ValidationError extends Error {
  constructor(public readonly errors: Array<{
    field: string
    message: string
    code: string
  }>) {
    super(`Validation failed: ${errors.map(e => e.message).join(', ')}`)
    this.name = 'ValidationError'
  }

  getFieldError(field: string) {
    return this.errors.find(error => error.field === field)
  }
}

class BusinessLogicError extends Error {
  constructor(public readonly errors: Array<{
    type: string
    message: string
    retryable: boolean
  }>) {
    super(`Business logic error: ${errors.map(e => e.message).join(', ')}`)
    this.name = 'BusinessLogicError'
  }

  isRetryable(): boolean {
    return this.errors.some(error => error.retryable)
  }
}

Error Monitoring and Reporting

Error Tracking Integration

typescript
interface ErrorReport {
  operation: string
  variables: any
  error: Error
  timestamp: Date
  userId?: string
  sessionId?: string
  userAgent?: string
}

class ErrorReporter {
  private reports: ErrorReport[] = []

  reportError(
    operation: string,
    variables: any,
    error: Error,
    context: {
      userId?: string
      sessionId?: string
    } = {}
  ) {
    const report: ErrorReport = {
      operation,
      variables,
      error,
      timestamp: new Date(),
      userAgent: navigator.userAgent,
      ...context
    }

    this.reports.push(report)
    
    // Send to error tracking service (e.g., Sentry, Bugsnag)
    this.sendToErrorService(report)
    
    // Log locally for debugging
    console.error('GraphQL Error Report:', report)
  }

  private async sendToErrorService(report: ErrorReport) {
    try {
      // Example with Sentry
      Sentry.captureException(report.error, {
        tags: {
          operation: report.operation,
          errorType: report.error.constructor.name
        },
        extra: {
          variables: report.variables,
          timestamp: report.timestamp
        },
        user: {
          id: report.userId
        }
      })
    } catch (err) {
      console.error('Failed to report error to tracking service:', err)
    }
  }

  getErrorStats() {
    const errorsByOperation = this.reports.reduce((acc, report) => {
      acc[report.operation] = (acc[report.operation] || 0) + 1
      return acc
    }, {} as Record<string, number>)

    const errorsByType = this.reports.reduce((acc, report) => {
      const type = report.error.constructor.name
      acc[type] = (acc[type] || 0) + 1
      return acc
    }, {} as Record<string, number>)

    return {
      total: this.reports.length,
      byOperation: errorsByOperation,
      byType: errorsByType,
      recent: this.reports.slice(-10)
    }
  }
}

const errorReporter = new ErrorReporter()

// Use in query execution
async function executeWithReporting<T>(
  query: Operation<T>,
  variables: VariablesOf<typeof query>
): Promise<T> {
  try {
    return await executeWithErrorHandling(query, variables)
  } catch (error) {
    errorReporter.reportError(
      query.operationName,
      variables,
      error as Error,
      {
        userId: getCurrentUserId(),
        sessionId: getSessionId()
      }
    )
    throw error
  }
}

Best Practices Summary

Error Handling Checklist

  • ✅ Use structured error types
  • ✅ Implement retry logic for transient errors
  • ✅ Provide fallback queries
  • ✅ Handle partial data gracefully
  • ✅ Use error boundaries in React
  • ✅ Report errors to monitoring services
  • ✅ Show user-friendly error messages
  • ✅ Test error scenarios thoroughly

Error Prevention

typescript
// Validate inputs before sending queries
function validateCreatePostInput(input: CreatePostInput): string[] {
  const errors: string[] = []

  if (!input.title || input.title.trim().length === 0) {
    errors.push('Title is required')
  }

  if (input.title && input.title.length > 200) {
    errors.push('Title must be less than 200 characters')
  }

  if (!input.content || input.content.trim().length === 0) {
    errors.push('Content is required')
  }

  return errors
}

// Use in mutation
async function createPost(input: CreatePostInput) {
  const validationErrors = validateCreatePostInput(input)
  
  if (validationErrors.length > 0) {
    throw new ValidationError(
      validationErrors.map(message => ({
        field: 'input',
        message,
        code: 'VALIDATION_ERROR'
      }))
    )
  }

  return await createPostWithErrorHandling(input)
}

Next Steps

Released under the MIT License.