Help us understand the problem. What is going on with this article?

GraphQLエラーハンドリング

お題

Type-safe GraphQL for Goを謳うGolang製GraphQLライブラリであるgqlgenを使って、GraphQL Server側のエラーハンドリングについて検討。

想定する読者

  • Golangについてある程度書ける。
  • 「GraphQL is 何?」ではない。
  • gqlgenの getting-started で初期セットアップくらいはやったことがある。

関連記事索引

開発環境

# OS - Linux(Ubuntu)

$ cat /etc/os-release 
NAME="Ubuntu"
VERSION="18.04.5 LTS (Bionic Beaver)"

# バックエンド

# 言語 - Golang

$ go version
go version go1.15.2 linux/amd64

# gqlgen

v0.13.0

IDE - Goland

GoLand 2020.2.3
Build #GO-202.7319.61, built on September 16, 2020

今回の全ソース

https://github.com/sky0621/study-gqlgen/tree/v0.2

実践

gqlgenを使ってサーバーサイド側でどうGraphQLのエラーハンドリングをするべきか、いくつかの方法を試行してみる。

1.ベーシックな方法でハンドリング

いくつかのパターンを列挙する。

server.go

package main

import (
    "log"
    "net/http"

    "github.com/99designs/gqlgen/graphql/handler"
    "github.com/99designs/gqlgen/graphql/playground"
    "github.com/sky0621/study-gqlgen/errorhandling/graph"
    "github.com/sky0621/study-gqlgen/errorhandling/graph/generated"
)

func main() {
    srv := handler.NewDefaultServer(generated.NewExecutableSchema(generated.Config{Resolvers: &graph.Resolver{}}))
    http.Handle("/", playground.Handler("GraphQL playground", "/query"))
    http.Handle("/query", srv)
    log.Fatal(http.ListenAndServe(":8080", nil))
}

schema.graphqls

type Query {
  normalReturn: [Todo!]!
  errorReturn: [Todo!]!
  customErrorReturn: [Todo!]!
  customErrorReturn2: [Todo!]!
  customErrorReturn3: [Todo!]!
  customErrorReturn4: [Todo!]!
  panicReturn: [Todo!]!
}

type Todo {
  id: ID!
  text: String!
}

リゾルバー

schema.resolvers.go
package graph

// This file will be automatically regenerated based on the schema, any resolver implementations
// will be copied through when generating and any unknown code will be moved to the end.

import (
    "context"
    "errors"
    "fmt"
    "time"

    "github.com/99designs/gqlgen/graphql"
    "github.com/sky0621/study-gqlgen/errorhandling/graph/generated"
    "github.com/sky0621/study-gqlgen/errorhandling/graph/model"
    "github.com/vektah/gqlparser/v2/gqlerror"
)

func (r *queryResolver) NormalReturn(ctx context.Context) ([]*model.Todo, error) {
    return []*model.Todo{
        {ID: "001", Text: "something1"},
        {ID: "002", Text: "something2"},
    }, nil
}

func (r *queryResolver) ErrorReturn(ctx context.Context) ([]*model.Todo, error) {
    return nil, errors.New("error occurred")
}

func (r *queryResolver) CustomErrorReturn(ctx context.Context) ([]*model.Todo, error) {
    return nil, gqlerror.Errorf("custom error")
}

func (r *queryResolver) CustomErrorReturn2(ctx context.Context) ([]*model.Todo, error) {
    graphql.AddError(ctx, gqlerror.Errorf("add error"))
    graphql.AddErrorf(ctx, "add error2: %s", time.Now().String())
    return nil, nil
}

func (r *queryResolver) CustomErrorReturn3(ctx context.Context) ([]*model.Todo, error) {
    return nil, &gqlerror.Error{
        Extensions: map[string]interface{}{
            "code":  "A00001",
            "field": "text",
            "value": "トイレ掃除",
        },
    }
}

func (r *queryResolver) CustomErrorReturn4(ctx context.Context) ([]*model.Todo, error) {
    return nil, &gqlerror.Error{
        Extensions: map[string]interface{}{
            "errors": []map[string]interface{}{
                {
                    "code":  "A00001",
                    "field": "text",
                    "value": "トイレ掃除",
                },
                {
                    "code":  "A00002",
                    "field": "text",
                    "value": "トイレ掃除",
                },
            },
        },
    }
}

func (r *queryResolver) PanicReturn(ctx context.Context) ([]*model.Todo, error) {
    panic(fmt.Errorf("panic occurred"))
}

// Query returns generated.QueryResolver implementation.
func (r *Resolver) Query() generated.QueryResolver { return &queryResolver{r} }

type queryResolver struct{ *Resolver }

パターン別の解説

参考

GraphQLにおけるレスポンスに関して。
正常系の場合、以下のような構造になる。

{
  "data": {
      〜〜〜〜
  }
}

リゾルバーで何かしらエラーを返すと、以下のような構造になる。

{
  "errors": [
    {
      "message": 〜〜〜〜,
      "path": [〜〜〜〜]
    }
  ],
  "data": null
}

その他、以下、参考にされたし。
https://gqlgen.com/reference/errors/

■正常系

func (r *queryResolver) NormalReturn(ctx context.Context) ([]*model.Todo, error) {
    return []*model.Todo{
        {ID: "001", Text: "something1"},
        {ID: "002", Text: "something2"},
    }, nil
}

正常系。設定したデータが返される。
screenshot-localhost_8080-2020.10.17-23_48_21.png

■go標準のエラーを返すパターン

func (r *queryResolver) ErrorReturn(ctx context.Context) ([]*model.Todo, error) {
    return nil, errors.New("error occurred")
}

指定したエラーメッセージがmessageに積まれている。
pathは勝手に付与される。
screenshot-localhost_8080-2020.10.17-23_51_16.png

■gqlgenが用意したメソッドを介してエラーを返すパターン

func (r *queryResolver) CustomErrorReturn(ctx context.Context) ([]*model.Todo, error) {
    return nil, gqlerror.Errorf("custom error")
}

やはり、指定したエラーメッセージがmessageに積まれている。
構造はgo標準のエラーを返すパターンと同じ。
screenshot-localhost_8080-2020.10.18-00_08_41.png

■複数のエラーを返すパターン

func (r *queryResolver) CustomErrorReturn2(ctx context.Context) ([]*model.Todo, error) {
    graphql.AddError(ctx, gqlerror.Errorf("add error"))
    graphql.AddErrorf(ctx, "add error2: %s", time.Now().String())
    return nil, nil
}

指定した2種類のエラーがそれぞれのmessageに積まれている。
これまでのエラー発生時と違い、datanullではなく空スライスが返されているのが、やや気になる。
(おそらくだけど、returnでerrorを返さなかったためかな。)
screenshot-localhost_8080-2020.10.18-00_19_32.png

■個別拡張領域を使うパターン

func (r *queryResolver) CustomErrorReturn3(ctx context.Context) ([]*model.Todo, error) {
    return nil, &gqlerror.Error{
        Extensions: map[string]interface{}{
            "code":  "A00001",
            "field": "text",
            "value": "トイレ掃除",
        },
    }
}

messageには何も積まず、用意されたextensionsにサービス固有の表現でエラー内容を定義する。
map[string]interface{}なので任意の構造が使える。
これにより、レスポンスを受けたフロントエンド側でcodeに応じたエラーメッセージの生成とエンドユーザーへの表示などが可能になる。
screenshot-localhost_8080-2020.10.18-00_20_47.png

■個別拡張領域を使うパターン2

func (r *queryResolver) CustomErrorReturn4(ctx context.Context) ([]*model.Todo, error) {
    return nil, &gqlerror.Error{
        Extensions: map[string]interface{}{
            "errors": []map[string]interface{}{
                {
                    "code":  "A00001",
                    "field": "text",
                    "value": "トイレ掃除",
                },
                {
                    "code":  "A00002",
                    "field": "text",
                    "value": "トイレ掃除",
                },
            },
        },
    }
}

返したいエラーは1つとは限らない。このようにマップのスライスという形で保持すれば複数のエラーを返すことも当然可能。
screenshot-localhost_8080-2020.10.18-00_24_29.png

■panicが発生した時のパターン

func (r *queryResolver) PanicReturn(ctx context.Context) ([]*model.Todo, error) {
    panic(fmt.Errorf("panic occurred"))
}

panic発生時に積まれたメッセージは無視してinternal system errormessageには積まれる。
screenshot-localhost_8080-2020.10.18-00_28_03.png

2.カスタマイズしたエラーハンドリング

よっぽど小規模なサービスでない限り、サービス固有のエラーハンドリング表現が必要になってくると思う。
gqlgenではhandler生成時に「エラー発生時」と「panic発生時」にフックして処理を追加する仕掛けがある。
この仕掛けを利用して、
リゾルバーからは(エラー発生時)サービス固有に定義したエラー構造体を返し、handlerでフックして、エラー構造体を加工してレスポンスとする実装をしてみる。

schema.graphqls

type Query {
  errorPresenter: [Todo!]!
  panicHandler: [Todo!]!
}

type Todo {
  id: ID!
  text: String!
}

schema.resolvers.go

サービス固有のエラー構造体としてAppErrorを作成。リゾルバーからはその構造体を返却。

package graph

import (
    "context"
    "fmt"

    "github.com/sky0621/study-gqlgen/errorhandling2/graph/generated"
    "github.com/sky0621/study-gqlgen/errorhandling2/graph/model"
)

type ErrorCode string

const (
    ErrorCodeRequired            ErrorCode = "1001"
    ErrorCodeUnexpectedSituation ErrorCode = "9999"
)

type AppError struct {
    Code ErrorCode
    Msg  string
}

func (e AppError) Error() string {
    return fmt.Sprintf("[%s]%s", e.Code, e.Msg)
}

func (r *queryResolver) ErrorPresenter(ctx context.Context) ([]*model.Todo, error) {
    return nil, AppError{
        Code: ErrorCodeRequired,
        Msg:  "text is none",
    }
}

func (r *queryResolver) PanicHandler(ctx context.Context) ([]*model.Todo, error) {
    panic("unexpected situation")
}

// Query returns generated.QueryResolver implementation.
func (r *Resolver) Query() generated.QueryResolver { return &queryResolver{r} }

type queryResolver struct{ *Resolver }

server.go

SetErrorPresenter()でセットする関数の中で、リゾルバーで投げたエラーを受け取り、AppErrorだったら*gqlerror.Error{}の構造に編集し直している。
ちなみに、SetRecoverFunc()も用意してpanic発生時もサービス固有の想定したエラー表現になるよう編集している。

package main

import (
    "context"
    "errors"
    "log"
    "net/http"

    "github.com/99designs/gqlgen/graphql"
    "github.com/vektah/gqlparser/v2/gqlerror"

    "github.com/99designs/gqlgen/graphql/handler"
    "github.com/99designs/gqlgen/graphql/playground"
    "github.com/sky0621/study-gqlgen/errorhandling2/graph"
    "github.com/sky0621/study-gqlgen/errorhandling2/graph/generated"
)

func main() {
    srv := handler.NewDefaultServer(generated.NewExecutableSchema(generated.Config{Resolvers: &graph.Resolver{}}))

    srv.SetErrorPresenter(func(ctx context.Context, e error) *gqlerror.Error {
        err := graphql.DefaultErrorPresenter(ctx, e)

        var appErr graph.AppError
        if errors.As(err, &appErr) {
            return &gqlerror.Error{
                Message: appErr.Msg,
                Extensions: map[string]interface{}{
                    "code": appErr.Code,
                },
            }
        }
        return err
    })

    srv.SetRecoverFunc(func(ctx context.Context, err interface{}) error {
        return &gqlerror.Error{
            Extensions: map[string]interface{}{
                "code":  graph.ErrorCodeUnexpectedSituation,
                "cause": err,
            },
        }
    })

    http.Handle("/", playground.Handler("GraphQL playground", "/query"))
    http.Handle("/query", srv)

    log.Fatal(http.ListenAndServe(":8080", nil))
}

動作確認

エラーハンドリング

うまいこと、エラーコードとエラーメッセージの振り分けが出来ている。
screenshot-localhost_8080-2020.10.18-00_43_38.png

panicハンドリング

causeとしてpanic発生時のエラーを積むようにしたため、ちゃんとレスポンスに発生元のエラー内容も乗るようになった。
screenshot-localhost_8080-2020.10.18-00_45_38.png

3.汎用性を考慮してカスタマイズしたエラーハンドリング

エラーと一口に言っても、バリデーションエラー、認証系のエラー、DB接続エラー、等々、いろんな種類があり、エラー構造として必要な要素も変わってくると思う。
単一のエラーで返せばいい場合もあれば、(バリデーションエラーのように)エラー要素1つ1つが必要で、結果として複数のエラーを返す必要がある場合もある。
こういった状況を踏まえ、なるべく汎用的にエラーハンドリングすることを試みる。

サービス固有のエラー構造

apperror.go
package graph

import (
    "context"
    "net/http"

    "github.com/vektah/gqlparser/v2/gqlerror"

    "github.com/99designs/gqlgen/graphql"
)

type AppError struct {
    httpStatusCode int          // http.StatusCodeXXXXXXX を入れる
    appErrorCode   AppErrorCode // サービス固有に定義したエラーコード

    /*
     * 以下、全てのエラー表現に必須ではない要素(オプションとして設定可能)
     */
    field string
    value string
}

func (e *AppError) AddGraphQLError(ctx context.Context) {
    extensions := map[string]interface{}{
        "status_code": e.httpStatusCode,
        "error_code":  e.appErrorCode,
    }
    if e.field != "" {
        extensions["field"] = e.field
    }
    if e.value != "" {
        extensions["value"] = e.value
    }
    graphql.AddError(ctx, &gqlerror.Error{
        Message:    "",
        Extensions: extensions,
    })
}

func NewAppError(httpStatusCode int, appErrorCode AppErrorCode, opts ...AppErrorOption) *AppError {
    a := &AppError{
        httpStatusCode: httpStatusCode,
        appErrorCode:   appErrorCode,
    }

    for _, o := range opts {
        o(a)
    }

    return a
}

// 認証エラー用
func NewAuthenticationError(opts ...AppErrorOption) *AppError {
    return NewAppError(http.StatusUnauthorized, AppErrorCodeAuthenticationFailure, opts...)
}

// 認可エラー用
func NewAuthorizationError(opts ...AppErrorOption) *AppError {
    return NewAppError(http.StatusForbidden, AppErrorCodeAuthorizationFailure, opts...)
}

// バリデーションエラー用
func NewValidationError(field, value string, opts ...AppErrorOption) *AppError {
    options := []AppErrorOption{WithField(field), WithValue(value)}
    for _, opt := range opts {
        options = append(options, opt)
    }
    return NewAppError(http.StatusBadRequest, AppErrorCodeValidationFailure, options...)
}

// その他エラー用
func NewInternalServerError(opts ...AppErrorOption) *AppError {
    return NewAppError(http.StatusInternalServerError, AppErrorCodeUnexpectedFailure, opts...)
}

type AppErrorCode string

// MEMO: サービスの定義によっては意味のある文字列よりもコード体系を決めるのもあり。
const (
    // 認証エラー
    AppErrorCodeAuthenticationFailure AppErrorCode = "AUTHENTICATION_FAILURE"
    // 認可エラー
    AppErrorCodeAuthorizationFailure AppErrorCode = "AUTHORIZATION_FAILURE"
    // バリデーションエラー
    AppErrorCodeValidationFailure AppErrorCode = "VALIDATION_FAILURE"

    // その他の予期せぬエラー
    AppErrorCodeUnexpectedFailure AppErrorCode = "UNEXPECTED_FAILURE"
)

type AppErrorOption func(*AppError)

func WithField(v string) AppErrorOption {
    return func(a *AppError) {
        a.field = v
    }
}

func WithValue(v string) AppErrorOption {
    return func(a *AppError) {
        a.value = v
    }
}

解説

まず、サービス固有のエラー構造体としてAppErrorを作成。
エラー要素として何を持つかはサービスによりまちまちだとは思うけど、とりあえず以下2つはエラーの内容問わず必須として定義。

  • HTTPステータスコード
  • サービス固有のエラーコード
type AppError struct {
    httpStatusCode int          // http.StatusCodeXXXXXXX を入れる
    appErrorCode   AppErrorCode // サービス固有に定義したエラーコード
     〜〜
}

func NewAppError(httpStatusCode int, appErrorCode AppErrorCode, opts ...AppErrorOption) *AppError {
    a := &AppError{
        httpStatusCode: httpStatusCode,
        appErrorCode:   appErrorCode,
    }
     〜〜
}

続いて、例えばバリデーションエラーのように「どのフィールドのどの値が」という情報も欲しくなるようなケースのために構造体内には(冗長でも)パターン別に必要な要素を持たせるようにする。

type AppError struct {
     〜〜
    /*
     * 以下、全てのエラー表現に必須ではない要素(オプションとして設定可能)
     */
    field string
    value string
}

ただし、今後、こういった要素の追加が必要になるたびにNew関数を修正(つまり、呼び元も全て修正)なんてしたくないので、Functional Option Patternを用いることにする。

オプション適用のための関数を定義し、New関数では可変引数で渡す(つまり、ないならないでOK)ようにする。

type AppErrorOption func(*AppError)

func NewAppError(httpStatusCode int, appErrorCode AppErrorCode, opts ...AppErrorOption) *AppError {
    a := &AppError{
        httpStatusCode: httpStatusCode,
        appErrorCode:   appErrorCode,
    }

    for _, o := range opts {
        o(a)
    }

    return a
}

で、AppErrorOptionの適用事例として以下2つを用意。

func WithField(v string) AppErrorOption {
    return func(a *AppError) {
        a.field = v
    }
}

func WithValue(v string) AppErrorOption {
    return func(a *AppError) {
        a.value = v
    }
}

こうすることで、今後、エラー構造体に追加したい要素が増えても、既存の呼び出し元を修正する必要なく拡張できる。

(説明が雑なのが一番の理由だけど)初見でこの仕組みを理解するのは、けっこうキツいと思うので、「Functional Option Pattern」でググってもらって易しい解説記事を読んでもらいたい。。。

あとは、サービス固有のエラーコードを以下のように定義して、

type AppErrorCode string

// MEMO: サービスの定義によっては意味のある文字列よりもコード体系を決めるのもあり。
const (
    // 認証エラー
    AppErrorCodeAuthenticationFailure AppErrorCode = "AUTHENTICATION_FAILURE"
    // 認可エラー
    AppErrorCodeAuthorizationFailure AppErrorCode = "AUTHORIZATION_FAILURE"
    // バリデーションエラー
    AppErrorCodeValidationFailure AppErrorCode = "VALIDATION_FAILURE"

    // その他の予期せぬエラー
    AppErrorCodeUnexpectedFailure AppErrorCode = "UNEXPECTED_FAILURE"
)

エラーのタイプ別に専用のNew関数でも用意してあげればOK。

// 認証エラー用
func NewAuthenticationError(opts ...AppErrorOption) *AppError {
    return NewAppError(http.StatusUnauthorized, AppErrorCodeAuthenticationFailure, opts...)
}

// 認可エラー用
func NewAuthorizationError(opts ...AppErrorOption) *AppError {
    return NewAppError(http.StatusForbidden, AppErrorCodeAuthorizationFailure, opts...)
}

// バリデーションエラー用
func NewValidationError(field, value string, opts ...AppErrorOption) *AppError {
    options := []AppErrorOption{WithField(field), WithValue(value)}
    for _, opt := range opts {
        options = append(options, opt)
    }
    return NewAppError(http.StatusBadRequest, AppErrorCodeValidationFailure, options...)
}

// その他エラー用
func NewInternalServerError(opts ...AppErrorOption) *AppError {
    return NewAppError(http.StatusInternalServerError, AppErrorCodeUnexpectedFailure, opts...)
}

リゾルバー

試しに、タイプ別にエラーを生成してGraphQLエラーとして追加してあげるとこんな感じ。
(認証エラーなんかは当然、ユーザーIDなどを積んだりするだろうけど、とりあえずはサンプルなので。)

schema.resolvers.go
package graph

import (
    "context"

    "github.com/sky0621/study-gqlgen/errorhandling3/graph/generated"
    "github.com/sky0621/study-gqlgen/errorhandling3/graph/model"
)

func (r *queryResolver) CustomErrorReturn(ctx context.Context) ([]*model.Todo, error) {
    // 認証エラーを追加
    NewAuthenticationError().AddGraphQLError(ctx)

    // 認可エラーを追加
    NewAuthorizationError().AddGraphQLError(ctx)

    // バリデーションエラーを追加
    NewValidationError("name", "taro").AddGraphQLError(ctx)

    // その他のエラーを追加
    NewInternalServerError().AddGraphQLError(ctx)

    return nil, nil
}

// Query returns generated.QueryResolver implementation.
func (r *Resolver) Query() generated.QueryResolver { return &queryResolver{r} }

type queryResolver struct{ *Resolver }

schema.graphqls

type Query {
  customErrorReturn: [Todo!]!
}

type Todo {
  id: ID!
  text: String!
}

server.go

今回はhandlerへの仕込みは無し。

package main

import (
    "log"
    "net/http"

    "github.com/99designs/gqlgen/graphql/handler"
    "github.com/99designs/gqlgen/graphql/playground"
    "github.com/sky0621/study-gqlgen/errorhandling3/graph"
    "github.com/sky0621/study-gqlgen/errorhandling3/graph/generated"
)

func main() {
    srv := handler.NewDefaultServer(generated.NewExecutableSchema(generated.Config{Resolvers: &graph.Resolver{}}))
    http.Handle("/", playground.Handler("GraphQL playground", "/query"))
    http.Handle("/query", srv)
    log.Fatal(http.ListenAndServe(":8080", nil))
}

動作確認

この通り、統一されたフォーマットになっているので、レスポンスを受け取る側でのハンドリングもしやすいはず。。。
screenshot-localhost_8080-2020.10.18-01_22_59.png

まとめ

とりあえずエラーが返せればいいといったシンプルなものから、一応、汎用性を考慮してサービス固有のエラー構造を定義したやり方まで複数のエラーハンドリング案を提示してみた。
もちろん、ここにあるパターン以外にもあるだろうし、ここにあげたものはプロダクションレベルとしては心もとない。
1サービスとして考えるなら、ここで返したエラー内容をフロントエンドではどのようにハンドリングするかも重要な要素だと思う。

sky0621
Go使い。最近はRustラブ。Webアプリケーション作ることが多い。フロントエンドもクラウド(GCP好き)もそれなりに触る。2019/10からGraphQLも嗜む。
https://github.com/sky0621/Curriculum-Vitae/blob/master/README.md
Why not register and get more from Qiita?
  1. We will deliver articles that match you
    By following users and tags, you can catch up information on technical fields that you are interested in as a whole
  2. you can read useful information later efficiently
    By "stocking" the articles you like, you can search right away