Re: GraphQL Error、下から見るか?横から見るか?

上記のトピックについてもう少し突っ込んだ議論をしたい。

背景: GraphQL におけるエラーの表現の手法

Web API において、正常系以外の、例外(エラー)的な状況をレスポンスの情報に埋め込みたい場合、REST API では HTTP ステータスコードがよく用いられる。
一方、GraphQL API では、複数のリソースを同時に取得することが前提にあるので、「リソースXはエラーであるがリソースYは正常である」のように Partial Error の状況を表現したいことがままある。そのためステータスコードは不適であることが多い。

また、GraphQL の仕様として、返すデータのトップレベルに data と並列に errors を返すことが出来る。(手法aとする)

{
  "errors": [
    {
      "message": "forbidden",
      "path": ["x"] 
    }
  ],
  "data": {
    "x": null,
    "y": 1
  }
}

しかしこの手法は、クライアントから見てエラーハンドリングがしづらいという欠点がある。どう使いにくいのかは元記事に丁寧に説明されているので割愛。

で、その代わりの手法として、個々のフィールドで Union Type を使い、その中に正常系と例外を内包して表しましょう、というのが提案されている。(手法bとする)

# GraphQL Schema

type Query {
  # x: X
  x: XResult!
  y: Int!
}

union XResult = X | Forbidden
 
type X {
  ...
}
type Forbidden {
  message: String!
}


// レスポンスの例
{
  "data": {
    "x": {
      "__typename": "Forbidden",
      "message": "hogehoge"
    },
    "y": 1
  }
}

ここまでの話は、元記事のほうが非常に詳しいので、詳しくはそちらを参照されたい。

課題: いつ、どちらの手法を採るのか?

この手法を使えばめでたしめでたしとなるかというと、現場においては微妙にそうでもない。すごく雑にいうと、どのフィールドを Union Type として表すのかや、何から何までをエラーの型として表すのか、というところで、設計力が問われてくる。

まず思考実験として、すべてのフィールドを正常系と例外系の Union Type として表すことを考えてみよう。すると何が起きるか?
今度は、クライアントがすべての例外パターンをハンドリング「しなければ」ならなくなる。つまり、今までだったら x.y.z のようにアクセス出来ていたフィールドで、いちいち「xがエラーだったら? yがエラーだったら?」というチェックをしなければならなくなる。

既存のプログラミング手法のアナロジーとして考えると、
a) トップレベルの errors で返す手法は、関数が例外を投げる (throw) ようなもので、
b) Union Type として返す手法は、関数が「正常系または例外」のような値 (Result type) を返すのに近い。

手法aは、別に気にしたくければ気にしなくてもいいというところに価値がある。どうせ HTTP リクエストなど、サーバーの都合で 504 のようなエラーが返ってくることはあり得るのだから、そういう大域レベルでのエラーハンドリングの機構は存在するはずで、それに丸投げすればよい。一方でデメリットは、いざハンドリングしたいときに不便なことだ。TypeScript ユーザーは例外に型がつかない不便さを知っていると思うが、あれに近い。

手法bは、ハンドリングしたいときには便利な一方、そうでないときにスマートに無視することが難しい。
関数型プログラミングのユーザーであれば、いやいや何でも Result<T,E> 型になっていれば便利で良いじゃないかと思うかもしれない。
しかし現状はただ単に GraphQL の仕様にある Union Type を利用しているだけで、Union Type の中のどれが正常(T)でどれが例外(E)なのかという情報はスキーマには含まれてないので、自動的にそういうふうなクライアントを生成することはできない。
(ここまで書いて、何らかのメタデータを含めることでいい感じのクライアントを生成することは技術的には可能な気はしてきたが、現状世の中にそういうソリューションは僕の知る限りは存在していない。)

長々と書いたが、すべてのフィールドを最初から、例外を内包する Union Type として表すのは、現実的ではない。
クライアントとしては、ハンドリングしたいときは手法bになっていて、そうでないときは手法aになっているほうが基本的には嬉しい。

じゃあそうすればいいじゃん、と思うかもしれないが、ここからが次の課題になる。
どの例外をハンドリングしたいか、というのは、GraphQL Schema を作る時点ではあまり自明ではないのだ。

例外のパターン自体は、サーバーサイドを実装していればある程度自然に見つかってくるが、そのすべてをクライアントでハンドリングしたいかというとそうではない。

例として、マルチテナントのサービスを挙げる。このサービスでは、テナント間で情報が交わることはほとんどないものとする。
このようなサービスでは、テナント外の情報にアクセスしようとしたら基本的にエラーになるべきだ。
それを Union Type (手法b)で表現したら、多くのフィールドが Union Type になってしまう。
しかしそれは本当に必要だろうか? 普通にユーザーが情報をたどっていったときに、テナント外の情報にたどり着くことはないのだから、フロントエンドとしてはそんなケースは本来ハンドリングする必要がないはずである。

(2022年末時点での) 自分の見解

この問題に対して、現段階での自分がどう考えているのか、を述べる。
なお、ここでは GraphQL API を使うクライアント (チーム) が数種類であり、会話等ができる状態を想定している。GitHub API のような全世界に公開される API や、社内でも全然知らない部署が使うことを想定するような API のことは一旦考えていない。

  1. いわゆる 5xx 系のエラーは、基本的に全て手法a (errors) を利用する。

  2. mutation のトップレベルの型は、全て手法b (Union Type) を利用する。現段階で必要がなくてもそうする。

  3. query 側 (= mutationのトップレベル以外) は、必要になるまでは手法aとし、どうしても必要になったら手法bを検討する。

以下説明する。基本的には「クライアントが必要なものだけをハンドリングしやすくする」という考えに沿っている。

1. いわゆる 5xx 系のエラーは、基本的に全て手法a (errors) を利用する

まずこれはあまり議論の余地がないと思うのだが、少し展開してみる。

5xx 系のエラーのうち、多くあるのは、例えばデータベースがアクセス不能のようなサービス全般に起こっている障害だ。そういう状況を考慮して Partial Error (手法b) にするのは、一言で言えばあまり旨味がない。なぜなら、そういう状況では他のリソースも同様に取得できないことがほとんどだからである。

そうでなくて、そのリソースに特有な状況としての5xx的エラーが存在するなら、そこを Partial Error とするのは考えられる。例えば、あるリソースは外部APIを利用していて、その外部APIのサービスレベルが低い、という場合がある。
上の状況に限って言えば、そもそもクエリを2つに分けることを僕はおすすめする。外部APIに依存している場合起こりうることとして、エラーになるだけでなく、レスポンスが遅くなる、ということもある。このとき、1つのクエリにしていると、遅いリソースに引きずられてレスポンス全体が遅くなってしまうからだ。(@stream, @defer ディレクティブで解決できるという話はあるが、十分普及していないので今回は割愛する)

というわけで、基本的には、よほど特別なケース以外 errors に回すでよいだろう。

2. mutation のトップレベルの型は、全て手法b (Union Type) を利用する。現段階で必要がなくてもそうする

3つの中ではここだけ一番自信ないが、現段階の考えとして書く。

mutation のトップレベルだけは特別なケースと考える。更新系はドメインロジックの宝庫であり、4xx 的な例外のパターンがあることが多いし、それをクライアントでハンドリングしたいことも多い。仮にいまそうでなくとも、少し先の未来でそうなることがある。

例えば mutation createHoge があるとすると、その戻り値の型を union HogePayload = HogeSuccess | Forbidden のように表す。 

「現段階で必要がなくてもそうする」というのは、こうする理由は、戻り値の型をあとから Union Type に変更するのは破壊的変更になってしまうという制約が関係している。
たとえば foo: Foo! だったものを foo: FooResult (union type) にしてしまうと、既存のクエリが通らなくなってしまうので、これは明確に破壊的変更である。
一方で一度 Union Type にしてしまえば、あとから Union の中の種類を追加するのは破壊的変更ではない。既存のクエリはそのまま通るし、クライアントで壊れないようにプログラミングできるからだ ( __typename で未知のものが来るケースには常に備えよう)。

例えば MutationError という汎用的な型を作っておき union HogePayload = HogeSuccess | MutationError と最初からしてしまう。これによって、あとから Forbidden を付け足したくなったときに union HogePayload = HogeSuccess | Forbidden | MutationError としても破壊的変更ではなくなる。

なお、クライアントは以下のように、未知の __typename に備えておくべきである。これは Union Type や Interface Type, Enum Type を扱うときには基本的に必ずすべきことである。

switch (hogePayload.__typename) {
  case "HogeSuccess":
    // 成功時の処理
    break;
  default:
    // 不明なエラーとしての処理
}

3. query 側 (= mutationのトップレベル以外) は、必要になるまでは手法aとし、どうしても必要になったら手法bを検討する

いわゆる YAGNI の精神である。僕は現時点ではだいたいこれが一番良いと思っている。

「必要になるまで」は手法aでよいとするが、この「必要となる」かどうかというのは基本的にクライアントの視点であるから、 Schema を作るときにクライアントの視点を盛り込む(会話する)べきである。これは何もエラーのパターンに限らず GraphQL API 設計においては絶対に必要な考えなので全員実践してほしい。
クライアントの視点が強すぎても良い API 設計はできないのでそこは "要はバランス" ではあるのだが、現在と少し先の未来を考えて、ハンドリングする必要がないようなエラー種別であれば、それは手法aでよいと考える。

「必要になったらそうする」というのは、2で書いている破壊的変更になってしまう問題をはらむ。こればっかりは仕方ないので、あとから foo フィールドと並列に fooWithErrors とか fooResult というフィールドを生やして対応する。
2との違いは、それが起きる確率である。個人的な経験として mutation はそうなる可能性が高い上に、トップレベルだけ考えれば良いのでクライアントの対応コストも低いから、前もってそうしておくとよく、query 側はそうなる確率が低い割にすべてのフィールドでそれを想定するのはコストが高すぎるので YAGNI で対応する。


noteの通貨流通量を増やしていきたい!!