🤯

GraphQLのエラーハンドリングが分かりづらすぎるので整理する

2024/06/29に公開

GraphQLのエラーが分かりづらい

GraphQLのエラーって初見殺しですよね。エラーが起きても4xxや5xxでなく200ステータスが返る仕様なのは置いておいて、GraphQL Specでは以下のようなエラーが例示されています。

{
  "errors": [
    {
      "message": "Name for character with ID 1002 could not be fetched.",
      "locations": [{ "line": 6, "column": 7 }],
      "path": ["hero", "heroFriends", 1, "name"],
      "extensions": {
        "code": "CAN_NOT_FETCH_BY_ID",
        "timestamp": "Fri Feb 9 14:33:09 UTC 2018"
      }
    }
  ]
}

messagelocationsをもつオブジェクトの配列になっており、extensionsフィールドは独自にカスタマイズすることができます。しかし、素直にextensionsを使用するように設計すると、以下のような辛さが発生します(主にクライアントサイド)。

  • 特定のレスポンスで返る可能性があるエラーを常にサーバーサイドと同期する必要性
  • extensions.codeで処理を分ける実装
  • extensionsには型がつかない

これでは、利用可能なスキーマを定義することでクライアントとサーバーを分離できるというGraphQLの利点がなくなってしまいます。

エラー仕様が分かりづらい、というかデバッグ用のスタックトレースに見えるといった指摘もあり、混乱ぶりが伺えますね(私もそのひとりなわけですが)。

GraphQLにおけるエラーとは

この混乱を整理するには、GraphQLにおける『エラー』の解像度を上げる必要があるようです。結論からいうと、サーバーサイドで発生するエラーは大きく2つに分けることができます。

  1. 予期せず発生するエラー、以後『例外エラー』と表記(ex. Internal Server Error, Bad Gateway)
  2. 仕様として定めた失敗、以後『ドメインエラー』と表記(ex. Unauthorized, Not Found, Bad Request)

前者は開発者がコントロールできない『エラー』であり、後者はドメインや仕様に基づく『結果』の1つの状態と考えることができます。ざっくり、ステータスコード5xx系と4xx系の違いだと考えてよいかもしれません。

GraphQLのerrorsは『例外エラー』を扱う

以下にGraphQL Specの作者のコメントを示します。

GraphQL errors encode exceptional scenarios - like a service being down or some other internal failure. Errors which are part of the API domain should be captured within that domain.

冒頭で示したGraphQLのエラーレスポンスは『例外エラー』を扱う想定であり、APIドメインにおいて発生するエラー(ドメインエラー)はそのドメインの中で処理してくれ、と。

なるほど(正直、それならGraphQL Specの例はミスリーディングだよなぁ、とは思いました)。

「ドメインの中で処理する」とは、レスポンスのerrorsフィールドではなくdataフィールドで管理することを意味します。

あとから気づきましたが、Shopify GraphQL Design Tutorialでもドメインエラーについての解説がありました(ユーザーエラーという名称で整理されています)。

レスポンスにドメインエラーを含める

整理ができたので、実装してみます(NestJS)。コードファーストでカスタムエラーを定義するサンプルです。

error.ts
import { Field, ObjectType } from "@nestjs/graphql";

@ObjectType()
export class BaseError {
  @Field()
  message: string;

  @Field()
  code: string;
}

@ObjectType()
export class ApplicationError extends BaseError {
  constructor(message: string, code: string) {
    super();
    this.message = message;
    this.code = code;
  }
}

@ObjectType()
export class ValidationError extends BaseError {
  constructor(message: string) {
    super();
    this.message = message;
    this.code = "VALIDATION_ERROR";
  }
}

次に、GraphQLレスポンスです。私の場合はMutationへの戻りなのでxxxPayloadという命名を使っています。成功の戻りであるcategoryとドメインエラーerrorsをもつオブジェクトで定義しています。Unionでもいいと思います。

category.payload.ts
import { ObjectType, Field, createUnionType } from "@nestjs/graphql";
import { ApplicationError, NotFoundError, ValidationError } from "src/common/errors";
import { Category } from "src/models/category.model";

export const CategoryError = createUnionType({
  name: "CategoryError",
  types: () => [ApplicationError, NotFoundError, ValidationError] as const,
});

@ObjectType()
export class CategoryPayload {
  @Field(() => Category, { nullable: true })
  category?: Category;

  @Field(() => [CategoryError], { nullable: true })
  errors?: (typeof CategoryError)[];
}

最後に、Serviceクラスでドメインエラーをハンドルします。エラーを投げるのではなく、オブジェクトにラップして返すようにします。

category.service.ts
  async createCategory(input: CreateCategoryInput): Promise<CategoryPayload> {
    const errors: (typeof CategoryError)[] = [];
    if (validationFailed) {
        errors.push(
          new ValidationError("You can't have more than 20 categories")
        );
        return { errors };
    }
    const category = await prisma.category.create({
        data: input,
    });
    return { category };
  }

実行してみると、以下のようにドメインエラーが取得できました。

mutation {
  createCategory(input: {
    groupId: "xxxxx"
    name: "category"
    color: "color"
  }) {
    category {
      id
      name
      order
    }
    errors {
      __typename
      ...on ValidationError {
        message
        code
      }
      ...on NotFoundError {
        message
        code
      }
    }
  }
}
{
  "data": {
    "createCategory": {
      "category": null,
      "errors": [
        {
          "__typename": "ValidationError",
          "message": "You can't have more than 20 categories",
          "code": "VALIDATION_ERROR"
        }
      ]
    }
  }
}

これで、安心してフロントエンドの実装もできそうです。めでたし。

追記: こんな記事も書いてみました。
https://zenn.dev/minamiso/articles/080eba9aaa0606

参考

大変参考になりました...ありがとうございます!!
https://techblog.gaudiy.com/entry/2022/02/17/215331
https://techblog.enechain.com/entry/graphql-backend-review
https://sachee.medium.com/200-ok-error-handling-in-graphql-7ec869aec9bc

Discussion