💪

ぼくのかんがえたさいきょうの API エラーレスポンスのフォーマット

2022/03/11に公開

エラーハンドリングについてじっくり考えてみたシリーズ
Web サービスを開発するときのエラーハンドリングについて

API から返ってきたエラーレスポンスのフォーマット(型)はどういうのが良いのかについて考えてみました。

デファクトスタンダードはどれ?

WebAPIでエラーをどう表現すべき?15のサービスを調査してみた - Qiita
などで取り上げられているように、現状各社のエラーレスポンスのフォーマットはかなりばらばらです。

ちなみに JSON:API という仕様があり、API がこの仕様に則って作られている場合はエラーレスポンスのフォーマットが定義されているため、これに沿ったエラーレスポンスを作れば良さそうです。
しかしそうでない API の場合は、エラーレスポンスだけ JSON:API に沿うのは違う気がします。レスポンスのフォーマットとして参考にはなるかもしれませんが。

というわけで一見デファクトはなさそうに思えますが、なんと RFC7807「Problem Details for HTTP APIs」 という仕様があるため、これが今後のスタンダードになっていくと思われます。なんせ RFC なので。

https://datatracker.ietf.org/doc/html/rfc7807

RFC7807 is どんなの

HTTP RFC7230 ステータスコードは、役に立つエラーについて十分な情報を伝えるためには、時として十分ではありません。後ろに人間がいる際には、ウェブブラウザは HTML W3C.REC-html5-20141028 レスポンスボディを使って自然に問題を伝えることができますが、特に”HTTP APIs”と呼ばれる人間ではないコンシューマの場合は、通常そうではありません。

従ってAPIクライアントは、(ステータスコードを使った)ハイレベルなエラークラスと、(それらの書式の一つを使った)問題の細かな詳細の両方を伝えることが可能です。

https://www.eisbahn.jp/yoichiro/2017/01/rfc_7807.html
(日本語訳から引用)

要はステータスコードだけじゃエラーをハンドリングするのに不十分な場合があり、エラーの細かい詳細を伝えることで明確に識別できるよね、ということらしいです。

というわけでエラーレスポンスのフォーマットを定義するのに最適な RFC だということが分かりました。

RFC7807 Way でいこう

この RFC7807 なのですが、JSON 形式で出ている例が2つあります。

クライアントの口座が十分な残高を持っていなかったことを示すレスポンス

HTTP/1.1 403 Forbidden
Content-Type: application/problem+json
Content-Language: en

   {
    "type": "https://example.com/probs/out-of-credit",
    "title": "You do not have enough credit.",
    "detail": "Your current balance is 30, but that costs 50.",
    "instance": "/account/12345/msgs/abc",
    "balance": 30,
    "accounts": ["/account/12345",
                 "/account/67890"]
   }

バリデーションエラーのレスポンス

HTTP/1.1 400 Bad Request
Content-Type: application/problem+json
Content-Language: en

   {
   "type": "https://example.net/validation-error",
   "title": "Your request parameters didn't validate.",
   "invalid-params": [ {
                         "name": "age",
                         "reason": "must be a positive integer"
                       },
                       {
                         "name": "color",
                         "reason": "must be 'green', 'red' or 'blue'"}
                     ]
   }

それぞれ細かくみていきましょう。

Problem Details オブジェクトのメンバー

  • title
    • 人間が読める形式の問題タイプの概要
      • エラーのタイトル
  • detail
    • この問題の発生に固有の人間が読める説明
      • エラーの説明
  • status
    • HTTP ステータスコード
      • 一応メンバーとして存在してはいるが、レスポンスボディの status ではなく、レスポンスステータスコードで判断したい気持ちがある
      • サーバーをまたぐなどしてレスポンスコードが変わってしまいこちらの status を参照せざるを得ない場合以外は基本的になくて良さそう(例にも載っていない)
  • type
    • 問題のタイプを識別する URL。ない場合は about:blank が入る
      • エラーの詳細ドキュメントへの URL
  • instance
    • 問題の特定の発生を識別する URL
      • 問題の発生箇所の参照 URL

これらが基本となる情報です。これらをベースに、他に必要な情報があれば拡張していくという考え方が RFC7807 のようです。

拡張メンバー

Problem Details タイプ定義は、追加のメンバーで Problem Details オブジェクトを拡張する場合があります。

例で出ている balance accounts invalid-params などは拡張メンバーです。

RFC7807 に型をつけてみる

type URLString = string // URL の正規表現の型つけたいけど TypeScript では現状できないので諦め
type HTTPStatusCode = number // こっちは定義することできるけど長くなるので省略

type ProblemDetails = {
  title: string
  detail?: string
  status?: HTTPStatusCode
  type?: URLString | 'about:blank'
  instance?: URLString
}

// あらかじめ定義されたエラーレスポンスがあるときは ProblemDetails を拡張して使う

// クライアントの口座が十分な残高を持っていなかったことを示すレスポンス
type HogeProblemDetails = ProblemDetails & {
  balance: number
  accounts: string[]
}

// バリデーションエラーのレスポンス
type FugaProblemDetails = ProblemDetails & {
  invalidParams: { name: string; reason: string }[]
}

こういった型になりそうです。

RFC7807 を実務で使えるように良い感じにしたい

実際のプロダクトで使っていくときのためにもっと良い感じにしたいです。

type TypeString = string // API が定義しているエラータイプ e.g. 'out_of_credit'
type HTTPStatusCode = number

type ProblemDetails = {
  title: string // 必須
  detail?: string
  type?: TypeString
  status?: HTTPStatusCode
}

// あらかじめ定義されたエラーレスポンスがあるときは ProblemDetails を拡張して使う

// クライアントの口座が十分な残高を持っていなかったことを示すレスポンス
type HogeProblemDetails = ProblemDetails & {
  balance: number // セキュリティ的に API が返して問題のない情報でなおかつ、ユーザーに教えるべき情報のみを返す
}

// バリデーションエラーのレスポンス
type FugaProblemDetails = ProblemDetails & {
  errors: string[] // e.g. ['名前は必ず入力してください', 'メールアドレスは必ず入力してください']
}

変更点や特徴としては以下のとおりです。

  • type
    • type は URL ではなくエラーを識別できる一意の string とする
      • e.g. require_recreate_cartunavailable_line_item など
    • URL だと毎回ドキュメントを公開する必要があり、とても大変。また OpenAPI などでドキュメントを作る場合などには鶏卵になる。type はもっとプログラマブルに取り回しのしやすい一意の ASCII 文字列とかで良さそう
    • API 全体で統一されているのであれば、type はスネークケースでもキャメルケースでもケバブケースでも問題ない。API で扱っている言語によりそう
    • もし type がない場合は、 about:blank を返すのではなく、プロパティ自体を返さない(オプショナルなプロパティ)
    • また HTTP ステータスコードに対して想定されるエラーの種類が一つしかない場合にもわざわざ type を定義する必要がないため、プロパティは返す必要がない
  • status
    • 基本的に status は使わずレスポンスステータスコードを使う想定のため、このプロパティは基本的には返さなくてよいプロパティ(オプショナルなプロパティ)
    • サーバーをまたいでレスポンスステータスコードが変わってしまう場合があるため、そういった場合でも status を判別する必要がある場合にのみ status を返す
  • instance
    • 使われそうな機会を知らなかったので、なくても良いと判断して基本の型 ProblemDetails からは外しました 🤔
    • 使われそうなユースケースがあれば足しても良さそう
    • fetch API など使っていれば Response.url で事足りるのでは?と思ったりしている

Problem Details オブジェクトのプロパティだけでなく拡張プロパティに関しては、RFC7807 で定義されているわけではなく自由度が高いためある程度ケースバイケースになるかと思いますが、基本的に以下のようなパターンが考えられるのではと思っています。

// 422 Unprocessable Entity エラー(バリデーションエラー)
type ValidationProblemDetails = ProblemDetails & {
  errors: string[] // フロントエンドで即時バリデーションをしている場合は雑にエラーを表示するだけで良いので `string[]` で十分そう
}

// 401 Unauthorized エラー
type UnauthorizedProblemDetails = ProblemDetails & {
  loginUrl: string // loginUrl をつけてあげたほうがやさしい API になる
}

以上が API エラーレスポンスのフォーマット(型)について考えていることです。もし「もっとこうしたほうが良い API になる」などがあれば、コメントもらえるとうれしいです! 🙌

Discussion