Tasuke Hubのロゴ

ITを中心に困っている人を助けるメディア

分かりやすく解決策を提供することで、あなたの困ったをサポート。 全ての人々がスムーズに生活できる世界を目指します。

実践的GraphQLエラーハンドリング:効率的なAPIデバッグと堅牢なクライアント実装のガイド

記事のサムネイル

GraphQLエラーハンドリングの重要性と基本概念

GraphQLは柔軟で効率的なAPIを構築するための強力なクエリ言語ですが、適切なエラーハンドリングなしでは、その力を最大限に活かすことはできません。従来のRESTfulAPIとは異なり、GraphQLはどんなクエリでも基本的にHTTPステータス200を返します。これは、GraphQLがエラー情報もレスポンスの一部として扱うためです。

このアプローチは柔軟性を提供する一方で、エラー処理の方法を再考する必要があります。GraphQLのエラーハンドリングが重要である理由は主に以下の通りです:

TH

Tasuke Hub管理人

東証プライム市場上場企業エンジニア

情報系修士卒業後、大手IT企業にてフルスタックエンジニアとして活躍。 Webアプリケーション開発からクラウドインフラ構築まで幅広い技術に精通し、 複数のプロジェクトでリードエンジニアを担当。 技術ブログやオープンソースへの貢献を通じて、日本のIT技術コミュニティに積極的に関わっている。

🎓情報系修士🏢東証プライム上場企業💻フルスタックエンジニア📝技術ブログ執筆者
  1. 部分的な成功への対応: GraphQLでは1つのリクエストで複数のフィールドを取得できるため、一部は成功し一部は失敗するケースが一般的です。
  2. 詳細なエラー情報の提供: クライアントが効果的に対応できるよう、エラーの性質と原因について具体的な情報が必要です。
  3. セキュリティとデバッグのバランス: 開発者が問題を診断できる十分な情報を提供しつつも、センシティブな情報を漏らさないようにする必要があります。
// GraphQLレスポンスの基本構造
{
  "data": {
    "userProfile": { /* 成功したデータ */ },
    "userPosts": null  // エラーが発生したフィールド
  },
  "errors": [
    {
      "message": "記事の取得中にエラーが発生しました",
      "locations": [{ "line": 6, "column": 3 }],
      "path": ["userPosts"],
      "extensions": {
        "code": "INTERNAL_SERVER_ERROR",
        "exception": {
          "stacktrace": [
            // スタックトレース情報
          ]
        }
      }
    }
  ]
}

この例では、userProfileの取得は成功しましたが、userPostsの取得は失敗しています。GraphQLレスポンスには常にdataフィールドが含まれ、エラーが発生した場合はerrors配列も含まれます。各エラーオブジェクトには、エラーメッセージ、発生場所、パス、そして追加情報を含むextensionsフィールドが含まれています。

GraphQLエラーの種類を理解する

GraphQLエラーは大きく3つのカテゴリに分類できます:

  1. 構文エラー: クエリの構文が不正な場合(括弧の不一致など)
  2. 検証エラー: クエリがスキーマに対して有効でない場合(存在しないフィールドの指定など)
  3. 実行時エラー: クエリの実行中に発生するエラー(データベース接続の問題など)

これらのエラーを適切に処理することで、APIの使いやすさと堅牢性を大幅に向上させることができます。

サーバーサイドでの効果的なエラー設計

GraphQLサーバーでの適切なエラー設計は、クライアントに有用な情報を提供し、デバッグを容易にするために不可欠です。ここでは、実装言語としてNode.js(Apollo Server)を例に説明します。

カスタムエラークラスの作成

エラー情報を構造化し、一貫性のあるエラーレスポンスを提供するために、カスタムエラークラスを作成することをおすすめします。

// サーバーサイドのカスタムエラークラス(Apollo Server使用例)
class GraphQLError extends Error {
  constructor(message, code, additionalInfo = {}) {
    super(message);
    this.name = this.constructor.name;
    this.code = code;
    this.additionalInfo = additionalInfo;
  }
}

// 特定タイプのエラー
class ValidationError extends GraphQLError {
  constructor(message, additionalInfo) {
    super(message, 'VALIDATION_ERROR', additionalInfo);
  }
}

class AuthenticationError extends GraphQLError {
  constructor(message, additionalInfo) {
    super(message, 'AUTHENTICATION_ERROR', additionalInfo);
  }
}

class AuthorizationError extends GraphQLError {
  constructor(message, additionalInfo) {
    super(message, 'AUTHORIZATION_ERROR', additionalInfo);
  }
}

class ResourceNotFoundError extends GraphQLError {
  constructor(message, additionalInfo) {
    super(message, 'RESOURCE_NOT_FOUND', additionalInfo);
  }
}

エラーフォーマッターを使ったエラー変換

Apollo Serverでは、formatError関数を使ってエラーレスポンスをカスタマイズできます。これにより、内部エラーをクライアントに適切な形式で提供できます。

const server = new ApolloServer({
  typeDefs,
  resolvers,
  formatError: (err) => {
    // 元のエラーオブジェクトを取得
    const originalError = err.originalError;
    
    // デフォルトのエラー情報
    const formattedError = {
      message: err.message,
      code: 'INTERNAL_SERVER_ERROR'
    };
    
    // カスタムGraphQLErrorからの情報を追加
    if (originalError instanceof GraphQLError) {
      formattedError.code = originalError.code;
      formattedError.additionalInfo = originalError.additionalInfo;
    }
    
    // 開発環境の場合のみスタックトレースを含める
    if (process.env.NODE_ENV === 'development') {
      formattedError.stacktrace = originalError ? originalError.stack : err.stack;
    }
    
    return formattedError;
  }
});

リゾルバーでのエラーハンドリング実装例

リゾルバー関数内でエラーを適切に処理し、カスタムエラークラスを使って情報を提供する例を示します。

const resolvers = {
  Query: {
    user: async (_, { id }, context) => {
      try {
        // 認証チェック
        if (!context.user) {
          throw new AuthenticationError('ログインが必要です');
        }
        
        // 権限チェック
        if (!hasUserAccess(context.user, id)) {
          throw new AuthorizationError('このユーザー情報へのアクセス権がありません');
        }
        
        // データ取得
        const user = await UserModel.findById(id);
        
        // 存在チェック
        if (!user) {
          throw new ResourceNotFoundError('ユーザーが見つかりません', { userId: id });
        }
        
        return user;
      } catch (error) {
        // カスタムエラーはそのまま再スロー
        if (error instanceof GraphQLError) {
          throw error;
        }
        
        // データベースエラーなど未処理のエラーをログに記録し、一般的なエラーに変換
        console.error('ユーザー取得エラー:', error);
        throw new GraphQLError(
          'ユーザー情報の取得中にエラーが発生しました',
          'DATABASE_ERROR'
        );
      }
    }
  }
};

Nullability(Null許容性)の適切な設計

GraphQLスキーマでの非Null制約(!記号で示される)の使い方は、エラーハンドリングに大きな影響を与えます。フィールドが非Nullとして定義されている場合、そのフィールドでエラーが発生すると、親オブジェクト全体がNullになります。

type Query {
  user(id: ID!): User
}

type User {
  id: ID!
  name: String!          # 非Null - エラー時は親のUserがNullになる
  email: String!         # 非Null - エラー時は親のUserがNullになる
  posts: [Post]          # Null許容 - エラー時はこのフィールドのみNullになる
  premiumStatus: Premium # Null許容 - エラー時はこのフィールドのみNullになる
}

type Premium {
  tier: String!
  expiresAt: String!
}

type Post {
  id: ID!
  title: String!
  content: String!
}

このスキーマ設計では、nameemailフィールドの取得で問題が発生した場合、userオブジェクト全体がNullになります。一方、postspremiumStatusでエラーが発生した場合は、それらのフィールドのみがNullになり、他のユーザー情報は正常に返されます。

非Nullの使用は、クライアントが常に期待できるデータを明確にする点で有用ですが、エラーの影響範囲が広がるというトレードオフがあります。一般的には、以下のガイドラインが役立ちます:

  • エンティティの識別子(ID)は通常非Nullとして定義する
  • オプショナルな情報や外部サービスに依存するデータはNull許容にする
  • エラー時に部分的なデータでも価値がある場合は、関連フィールドをNull許容にする

クライアントサイドでの堅牢なエラーハンドリング

GraphQLのクライアントサイド実装では、サーバーから返されるエラー情報を適切に処理し、ユーザーに意味のあるフィードバックを提供することが重要です。ここでは、Apollo Clientを使った実装例を紹介します。

Apollo Clientでのエラーハンドリング基本

Apollo Clientでは、クエリやミューテーションの結果にloadingerrordataプロパティが含まれています。これらを使って基本的なエラーハンドリングを実装できます。

// React + Apollo Client使用例
import { useQuery } from '@apollo/client';
import { GET_USER_PROFILE } from './queries';

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

  if (loading) return <p>読み込み中...</p>;
  
  if (error) {
    console.error('プロフィール取得エラー:', error);
    return <p>エラーが発生しました: {error.message}</p>;
  }
  
  return (
    <div>
      <h1>{data.user.name}</h1>
      <p>Email: {data.user.email}</p>
      {/* 他のユーザー情報を表示 */}
    </div>
  );
}

部分的なエラーを持つデータの処理

GraphQLでは、レスポンスにdataerrorsの両方が含まれる場合があります。以下の例では、部分的なデータとエラーの両方を処理する方法を示します。

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

  if (loading) return <p>読み込み中...</p>;
  
  // 完全なエラー(データなし)の場合
  if (error && !data) {
    return <p>プロフィールの読み込みに失敗しました: {error.message}</p>;
  }
  
  // ユーザーデータが取得できなかった場合
  if (!data || !data.user) {
    return <p>ユーザー情報が見つかりません</p>;
  }

  return (
    <div>
      <h1>{data.user.name}</h1>
      <p>Email: {data.user.email}</p>
      
      {/* 投稿一覧 - エラーがあるかもしれないのでnullチェック */}
      <h2>投稿一覧</h2>
      {data.user.posts ? (
        <PostList posts={data.user.posts} />
      ) : (
        <p>投稿の読み込みに失敗しました</p>
      )}
      
      {/* プレミアム情報 - エラーがあるかもしれないのでnullチェック */}
      {data.user.premiumStatus ? (
        <PremiumBadge tier={data.user.premiumStatus.tier} />
      ) : (
        <p>プレミアム情報は利用できません</p>
      )}
    </div>
  );
}

エラーポリシーの設定

Apollo Clientでは、errorPolicyオプションを使ってエラー発生時の挙動をカスタマイズできます。デフォルトではnoneが設定されており、エラーがあればdataはnullとなります。

const { data, error } = useQuery(GET_USER_PROFILE, {
  variables: { userId },
  errorPolicy: 'all' // 'none' | 'all' | 'ignore'
});
  • none: エラーがあればdatanullになる(デフォルト)
  • all: エラーがあっても部分的なdataが返される
  • ignore: エラーを無視し、errorは返さない(dataのみ返す)

グローバルエラーハンドリング

共通のエラーハンドリングロジックを実装するために、Apollo Clientのリンクを使用できます。

import { ApolloClient, InMemoryCache, ApolloLink, HttpLink, from } from '@apollo/client';

// エラーハンドリングリンク
const errorLink = new ApolloLink((operation, forward) => {
  return forward(operation).map(response => {
    // GraphQLエラーを処理
    if (response.errors) {
      response.errors.forEach(err => {
        // エラーコードに基づく処理
        switch (err.extensions?.code) {
          case 'AUTHENTICATION_ERROR':
            // 認証エラー処理(例: ログアウトやログイン画面へのリダイレクト)
            console.error('認証エラー:', err);
            // logoutUser();
            break;
          
          case 'VALIDATION_ERROR':
            // バリデーションエラー処理
            console.error('入力エラー:', err);
            // displayValidationError(err);
            break;
            
          case 'RESOURCE_NOT_FOUND':
            // リソース不在エラー処理
            console.error('リソースが見つかりません:', err);
            // navigateToNotFound();
            break;
            
          default:
            // 一般エラー処理
            console.error('GraphQLエラー:', err);
            // displayGeneralError(err);
        }
      });
    }
    return response;
  });
});

// HTTPリンク
const httpLink = new HttpLink({
  uri: 'https://api.example.com/graphql'
});

// Apolloクライアント設定
const client = new ApolloClient({
  link: from([errorLink, httpLink]),
  cache: new InMemoryCache()
});

ユーザーフレンドリーなエラーメッセージ

技術的なエラーメッセージをユーザーフレンドリーなメッセージに変換するヘルパー関数を作成すると便利です。

// エラーメッセージ変換関数
function getUserFriendlyErrorMessage(error) {
  // ネットワークエラーの場合
  if (error.networkError) {
    return 'サーバーに接続できません。インターネット接続を確認してください。';
  }
  
  // GraphQLエラーがある場合
  if (error.graphQLErrors && error.graphQLErrors.length > 0) {
    const gqlError = error.graphQLErrors[0];
    const code = gqlError.extensions?.code;
    
    switch (code) {
      case 'AUTHENTICATION_ERROR':
        return 'セッションが切れています。再度ログインしてください。';
      
      case 'AUTHORIZATION_ERROR':
        return 'このアクションを実行する権限がありません。';
      
      case 'VALIDATION_ERROR':
        return `入力内容に問題があります: ${gqlError.message}`;
      
      case 'RESOURCE_NOT_FOUND':
        return '要求されたリソースが見つかりませんでした。';
      
      default:
        return 'エラーが発生しました。しばらく経ってからもう一度お試しください。';
    }
  }
  
  // その他のエラー
  return 'エラーが発生しました。しばらく経ってからもう一度お試しください。';
}

// 使用例
function ErrorDisplay({ error }) {
  return (
    <div className="error-container">
      <p>{getUserFriendlyErrorMessage(error)}</p>
      {process.env.NODE_ENV === 'development' && (
        <details>
          <summary>技術的な詳細</summary>
          <pre>{JSON.stringify(error, null, 2)}</pre>
        </details>
      )}
    </div>
  );
}

実践的なエラーパターンと解決策

ここでは、GraphQLアプリケーションで一般的に発生するエラーパターンと、その効果的な解決策を紹介します。

N+1クエリ問題とデータローダー

GraphQLでは、ネストされたリレーションをクエリする際に「N+1クエリ問題」が発生することがあります。例えば、ユーザーのリストとその投稿を取得する場合、1回のクエリでユーザーリストを取得し、その後各ユーザーの投稿を個別に取得してしまうことです。

# N+1問題を引き起こす可能性のあるクエリ
query {
  users {
    id
    name
    posts {
      id
      title
    }
  }
}

この問題はパフォーマンスの低下を招き、多くのリクエストを発生させるとデータベースへの負荷が高まり、タイムアウトエラーが発生する可能性があります。

解決策: DataLoader

DataLoaderを使用すると、個別のリクエストをバッチ処理することで、N+1問題を効果的に解決できます。

// DataLoaderの実装例(Apollo Server)
const DataLoader = require('dataloader');

// コンテキスト作成時にDataLoaderを初期化
const server = new ApolloServer({
  typeDefs,
  resolvers,
  context: () => {
    return {
      // ユーザーIDでポストを取得するローダー
      postsByUserLoader: new DataLoader(async (userIds) => {
        // 一度にすべてのユーザーのポストを取得
        const posts = await PostModel.find({ userId: { $in: userIds } });
        
        // ユーザーIDごとにポストをグループ化
        const postsByUserId = userIds.map(userId => 
          posts.filter(post => post.userId.toString() === userId.toString())
        );
        
        return postsByUserId;
      })
    };
  }
});

// リゾルバーでDataLoaderを使用
const resolvers = {
  User: {
    posts: async (user, _, context) => {
      try {
        return await context.postsByUserLoader.load(user.id);
      } catch (error) {
        console.error('ポスト取得エラー:', error);
        throw new GraphQLError(
          'ユーザーの投稿を取得できませんでした',
          'DATABASE_ERROR'
        );
      }
    }
  }
};

エラーリトライとバックオフ戦略

一時的なネットワークエラーや過負荷状態のサーバーへのリクエストでは、リトライ戦略が有効です。特にミューテーション操作では、自動リトライが重要になります。

// Apollo Clientでのリトライ戦略
import { ApolloClient, InMemoryCache, HttpLink } from '@apollo/client';
import { RetryLink } from '@apollo/client/link/retry';

// リトライリンクの設定
const retryLink = new RetryLink({
  delay: {
    initial: 300, // 最初のリトライまでの遅延(ミリ秒)
    max: 10000,   // 最大遅延時間
    jitter: true  // ランダムな遅延を追加してスパイクを防ぐ
  },
  attempts: {
    max: 5,       // 最大リトライ回数
    retryIf: (error, operation) => {
      // ネットワークエラーやサーバーの一時的なエラーの場合のみリトライ
      return !!error && (error.networkError || 
             (error.graphQLErrors && 
              error.graphQLErrors.some(e => 
                e.extensions?.code === 'SERVICE_UNAVAILABLE' || 
                e.extensions?.code === 'TIMEOUT' ||
                e.message.includes('timeout')
              ))
            );
    }
  }
});

const httpLink = new HttpLink({
  uri: 'https://api.example.com/graphql'
});

const client = new ApolloClient({
  link: retryLink.concat(httpLink),
  cache: new InMemoryCache()
});

エラー追跡と分析

本番環境でのエラー監視は、問題の早期発見と解決に不可欠です。Sentryなどのエラー追跡サービスとGraphQLを統合する例を示します。

// サーバーサイドでのSentry統合(Apollo Server)
const Sentry = require('@sentry/node');
const { ApolloServer } = require('apollo-server-express');

// Sentryの初期化
Sentry.init({
  dsn: 'YOUR_SENTRY_DSN',
  environment: process.env.NODE_ENV
});

// Apolloサーバー設定
const server = new ApolloServer({
  typeDefs,
  resolvers,
  formatError: (err) => {
    // エラーをSentryに送信
    Sentry.captureException(err.originalError || err);
    
    // クライアントに返すエラー形式を整える
    return {
      message: err.message,
      code: err.extensions?.code || 'INTERNAL_SERVER_ERROR',
      // 開発環境のみスタックトレースを含める
      ...(process.env.NODE_ENV === 'development' && {
        stacktrace: err.originalError?.stack || err.stack
      })
    };
  },
  context: ({ req }) => {
    // リクエスト情報をSentryに追加
    Sentry.configureScope(scope => {
      if (req.user) {
        scope.setUser({ id: req.user.id });
      }
      scope.setTag('graphql', 'true');
    });
    
    return { req };
  }
});

クライアントサイドでも同様に、エラー追跡を実装できます:

// クライアントサイドでのSentry統合(Apollo Client + React)
import * as Sentry from '@sentry/react';
import { ApolloClient, InMemoryCache, HttpLink, from } from '@apollo/client';
import { onError } from '@apollo/client/link/error';

// Sentryの初期化
Sentry.init({
  dsn: 'YOUR_SENTRY_DSN',
  environment: process.env.NODE_ENV
});

// エラー監視リンク
const errorLink = onError(({ graphQLErrors, networkError, operation }) => {
  if (graphQLErrors) {
    graphQLErrors.forEach(err => {
      const { message, path, extensions } = err;
      
      // 特定のエラーコードは無視する(例: 認証エラーなど)
      if (extensions?.code === 'AUTHENTICATION_ERROR') {
        return;
      }
      
      // エラー情報を構築
      const errorInfo = {
        message,
        path: path?.join('.'),
        operationName: operation.operationName,
        variables: operation.variables,
        code: extensions?.code
      };
      
      // Sentryにエラーを送信
      Sentry.captureException(new Error(message), {
        tags: { graphql: true },
        extra: errorInfo
      });
    });
  }
  
  if (networkError) {
    Sentry.captureException(networkError, {
      tags: { graphql: true, network: true },
      extra: {
        operationName: operation.operationName,
        variables: operation.variables
      }
    });
  }
});

// Apolloクライアント設定
const client = new ApolloClient({
  link: from([errorLink, new HttpLink({ uri: '/graphql' })]),
  cache: new InMemoryCache()
});

キャッシュとエラーの整合性

Apollo Clientのキャッシュは、エラー発生時にも適切に管理する必要があります。特にミューテーション後のエラーで、キャッシュのデータと実際のサーバーデータに不整合が生じることがあります。

// ミューテーション後のエラー処理とキャッシュ更新
const [updateUser, { loading, error }] = useMutation(UPDATE_USER_MUTATION, {
  // 楽観的UIの設定 - 即座にUI更新
  optimisticResponse: {
    updateUser: {
      __typename: 'User',
      id: userId,
      // 楽観的に更新する値
      name: newName,
      email: newEmail
    }
  },
  
  // エラー発生時のキャッシュ復元処理
  onError: (error) => {
    // キャッシュをリセットして不整合を解消
    client.cache.reset().then(() => {
      // 最新データを再取得
      client.query({
        query: GET_USER_QUERY,
        variables: { id: userId },
        fetchPolicy: 'network-only'
      });
    });
    
    // エラーを表示
    console.error('ユーザー更新エラー:', error);
    showErrorNotification(getUserFriendlyErrorMessage(error));
  }
});

より細かく制御したい場合は、キャッシュの更新ロジックをカスタマイズできます:

const [deletePost] = useMutation(DELETE_POST_MUTATION, {
  update: (cache, { data, errors }) => {
    // ミューテーションが成功し、エラーがなければキャッシュを更新
    if (data && data.deletePost && !errors) {
      // キャッシュから削除されたポストを除外
      const { posts } = cache.readQuery({
        query: GET_USER_POSTS,
        variables: { userId }
      });
      
      cache.writeQuery({
        query: GET_USER_POSTS,
        variables: { userId },
        data: {
          posts: posts.filter(post => post.id !== postId)
        }
      });
    }
  },
  // エラー処理
  onError: (error) => {
    showErrorNotification('投稿の削除に失敗しました');
  }
});

まとめ:実践的なGraphQLエラーハンドリングのポイント

GraphQLのエラーハンドリングは、APIの品質とユーザー体験に大きな影響を与えます。効果的なエラーハンドリングを実装するための重要なポイントは以下の通りです。

  • 構造化されたエラー設計: カスタムエラークラスを作成し、エラータイプ別に適切なコードと追加情報を提供することで、クライアントが効果的に対応できるようにします。
  • 適切なNull許容性の設計: スキーマ設計において、どのフィールドを非Nullにし、どのフィールドをNull許容にするかを慎重に検討することで、エラーの影響範囲を制御します。
  • 部分的なデータの処理: クライアント側では、一部のフィールドでエラーが発生している場合でも、利用可能なデータを最大限に活用するロジックを実装します。
  • ユーザーフレンドリーなメッセージ: 技術的なエラーメッセージを、エンドユーザーにとって理解しやすく有用な情報に変換します。
  • パフォーマンス最適化: DataLoaderのようなツールを使用してN+1クエリ問題を解決し、タイムアウトエラーを防止します。
  • エラー監視と分析: 本番環境では、SentryなどのサービスとGraphQLを統合し、エラーを追跡・分析することで、問題の早期発見と解決を促進します。

これらの実践を採用することで、より堅牢で使いやすいGraphQLアプリケーションを構築でき、開発者とエンドユーザーの両方に優れた体験を提供できるでしょう。

おすすめコンテンツ