GraphQLのエラーハンドリングが分かりづらすぎるので整理する
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"
}
}
]
}
message
やlocations
をもつオブジェクトの配列になっており、extensions
フィールドは独自にカスタマイズすることができます。しかし、素直にextensions
を使用するように設計すると、以下のような辛さが発生します(主にクライアントサイド)。
- 特定のレスポンスで返る可能性があるエラーを常にサーバーサイドと同期する必要性
-
extensions.code
で処理を分ける実装 -
extensions
には型がつかない
これでは、利用可能なスキーマを定義することでクライアントとサーバーを分離できるというGraphQLの利点がなくなってしまいます。
エラー仕様が分かりづらい、というかデバッグ用のスタックトレースに見えるといった指摘もあり、混乱ぶりが伺えますね(私もそのひとりなわけですが)。
GraphQLにおけるエラーとは
この混乱を整理するには、GraphQLにおける『エラー』の解像度を上げる必要があるようです。結論からいうと、サーバーサイドで発生するエラーは大きく2つに分けることができます。
- 予期せず発生するエラー、以後『例外エラー』と表記(ex. Internal Server Error, Bad Gateway)
- 仕様として定めた失敗、以後『ドメインエラー』と表記(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)。コードファーストでカスタムエラーを定義するサンプルです。
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でもいいと思います。
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クラスでドメインエラーをハンドルします。エラーを投げるのではなく、オブジェクトにラップして返すようにします。
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"
}
]
}
}
}
これで、安心してフロントエンドの実装もできそうです。めでたし。
追記: こんな記事も書いてみました。
参考
大変参考になりました...ありがとうございます!!
Discussion