Part 2 では成功時のレスポンスを扱った。今回はエラーを設計する。
私が担当した API では、エラーレスポンスを何度も設計し直した。最初は数字のコードで、エンドポイントごとにも形が違かった。最初から次の形で決めておけばよかった。
決めることは 3 つある。どのステータスコードを返すか、エラーコードにどんな名前を付けるか、その定義をどこに書くか。
成功時のステータスコード
操作の種別で決まる。
- コレクションに対する
POST(リソースの新規作成):201 Created - それ以外の成功(取得・更新・削除・アクション):
200 OK
204 No Content は使わない。削除も削除済みオブジェクトを返すので、成功レスポンスは必ずボディを持つ。Stripe のステータスコード一覧も成功は 200 だけで、201 と 204 を含まない。作成だけを 201 にするのは独自の判断である。
エラーは error オブジェクトで返す
{
"error": {
"code": "notification_category_not_found",
"message": "Notification category not found"
}
}
-
code: 機械可読なエラーコード。クライアントが分岐に使う唯一のフィールド -
message: 開発者向けの英語の説明。文言は予告なく変わりうるので、クライアントは分岐に使わない
画面に出す文言はクライアントが持つ。項目の日本語ラベルと画面上の位置を知っているのはクライアントだからである。
トップレベルに並べず、error オブジェクトに入れる。 成功レスポンスのフィールドと名前が衝突せず、クライアントは error の有無だけで成功と失敗を分岐できる。Stripe と Google も入れ子にしている。
バリデーションエラーは field_errors を足す。errors という名前にしないのは、何の複数形なのかが読めないためである。
{
"error": {
"code": "validation_failed",
"message": "Request parameter validation failed",
"field_errors": [
{ "field": "email", "message": "email must be a valid email address" },
{ "field": "address.city", "message": "city must be shorter than or equal to 50 characters" },
{ "field": "phone_numbers[1].number", "message": "number must contain only digits" }
]
}
}
field_errors は要素ごとに field と message を持つ配列にする。1 つのフィールドが複数の制約に違反した場合は、同じ field の要素が複数並ぶ。
field にはリクエストボディのフィールドへのパスを平坦に書く。 ネストは .、配列は [n] の添字で表し、ボディの構造をそのまま入れ子にしない。
- クライアントは
fieldの文字列を入力欄の名前とそのまま突き合わせられる。入れ子のオブジェクトをたどる必要が無い - 配列の何番目が不正かを表せる
-
Google AIP-193 の
fieldViolationsもemail_addresses[3].type[2]のパス、Stripe のparamもline_items[0][currency]と、いずれも平坦なパス文字列である
フィールド名をキーにしたオブジェクト({ "email": ["..."] })にする設計もある。入力欄への対応付けはキー引きで済むが、要素に情報を足す余地が無い。配列なら、違反の種類でクライアントが UI を変えたくなったときに、要素へ code を足すだけで済む。
付加情報は context に入れる
原因は 1 つだが、クライアントが対応するには追加の情報が要るエラーがある。在庫が足りないなら、どの商品が足りないか。上限を超えたなら、上限はいくつか。これを message に埋め込まず、context に機械可読な値として入れる。
{
"error": {
"code": "order_items_out_of_stock",
"message": "Some items in the order are out of stock",
"context": { "out_of_stock_product_ids": [101, 205] }
}
}
context のキーはエラーコードごとに定義し、一度定義したキーは変えない。 クライアントは code を見て、そのコードに定義された context を読む。
-
field_errors: 入力項目ごとに独立して失敗する場合。バリデーションエラーにだけ付く -
context: 原因が 1 つで、影響を受けた対象の一覧や識別子を伝えたい場合
ステータスコードの使い分け
-
404 Not Found: パスパラメーターが指すリソースが存在しない -
409 Conflict: リソースの現在の状態と矛盾して、操作できない -
422 Unprocessable Entity: 入力は構造的に有効だが、業務ルールやバリデーションに違反している
404 はパスパラメーターに使う
GET /v1/notifications/not-a-uuid → 404
GET /v1/notifications/<存在しない ID> → 404
GET /v1/notifications/<削除済みの ID> → 404
パスパラメーターはリソースの識別子である。 フォーマットが不正でも、存在しなくても、削除済みでも、その識別子でリソースを取り出せないという結果は同じなので、区別せず 404 を返す。
バリデーションエラーは 422 を返す
ボディとクエリは識別子ではなく操作データである。フォーマット不正も、存在しないリソースへの参照も、業務ルール違反も 422 にする。
POST /v1/notifications { "category_id": "not-a-uuid" } → 422
POST /v1/notifications { "category_id": "<存在しない ID>" } → 422
ボディで存在しないリソースを指定したときに 404 を返さないのは、URL が指しているコレクションは存在するからである。404 はパスパラメーターの結果だけを表す。
400 Bad Request は JSON としてパースできない場合に限る。パースできたリクエストの中身の問題は 422 に寄せる。
バリデーションエラーを 400 にする設計
入力起因のエラーをすべて 400 にする設計も正しい。400 は「リクエストを受け付けられない」の汎用ステータスで、クライアントの分岐が 1 つ減る。
代わりに「JSON が壊れている」と「値が業務ルールに反する」が同じステータスになり、切り分けはエラーコードに委ねられる。422 を選ぶのは、この 2 つをステータスだけで分けたいからである。
409 は状態の衝突に使う
配信が開始された通知を更新しようとした、確定済みの注文をもう一度確定しようとした。入力そのものは正しく、リソースの現在の状態が操作を許さない場合が 409 である。
エラーコードは状態を表す
notification_not_editable # 操作ベース
notification_delivery_started # 状態ベース
コードには操作を妨げている状態を書き、操作は書かない。
- HTTP メソッドが操作を表すので、エラーコードが操作を繰り返す必要はない
- 「なぜエラーになるのか」が名前から分かる
- 業務ルールが変わっても名前の意味が安定する。「配信開始後は編集も削除もできない」に変わっても
notification_delivery_startedはそのまま使える
操作ベースの名前も許容できる。クライアントは受け取ったコードをそのまま画面の分岐に使える。ただしルールが増えると not_editable と not_deletable が並び、同じ状態に複数のコードができる。
コードは小文字の snake_case で、モジュールのプレフィックスを付ける。notification_*、order_*、payment_*。名前の衝突を避け、コードを見ただけでどのモジュールのエラーか分かる。
カタログを 1 か所に持つ
- コード・ステータス・説明・
contextのキーはエラーコード一覧に一元管理する - 「いつ返るか」のトリガー条件は各 API 仕様書に業務ルールとして書く
- 仕様書の「エラーレスポンス」セクションはカタログへのリンクだけにする
読み手は業務ルールからエラーの意味を理解し、コードを見たときにカタログを引く。 この 2 段構えにすると、同じ説明を 2 か所に書かずに済む。
各仕様書にコードを列挙する方式も、1 ファイルを読めば完結する点で読みやすい。コードが増えると同じ説明が複数の仕様書に散り、片方だけが更新される。
次回は、ここまでの設計を仕様書としてどう書くかを扱う。