APIの良し悪しはエラーで決まる|実在する2つのAPIを叩いて読む

APIの良し悪しはエラーで決まる|実在する2つのAPIを叩いて読む

API設計の良し悪しは、正常系ではほとんど差が出ません。差が出るのはエラーのときです。「エラーが返ってきたが、何が悪いのか分からない」というAPIは、使う側の実装コストを一気に上げます。

この記事では、抽象論ではなく実際に動いている2つのAPIに壊れたリクエストを投げて、返ってきたものを読みます。題材は GitHub API と、このブログが動いている WordPress の REST API です。以下のレスポンスはすべて実際に取得したものです。

目次

壊れたリクエストを投げてみる

存在しないリソース

$ curl https://api.github.com/repos/no-such-owner/no-such-repo
HTTP 404
{
  "message": "Not Found",
  "documentation_url": "https://docs.github.com/rest/repos/repos#get-a-repository",
  "status": "404"
}
$ curl https://wadokon.com/wp-json/wp/v2/posts/999999
HTTP 404
{
  "code": "rest_post_invalid_id",
  "message": "無効な投稿 ID です。",
  "data": { "status": 404 }
}

ここで既に方針の違いが出ています。WordPress は code という機械可読な識別子を持っていて、GitHub は代わりにドキュメントのURLを返します。

rest_post_invalid_id のような文字列があると、クライアント側で if (err.code === 'rest_post_invalid_id') と分岐できます。メッセージ本文で分岐するのは絶対に避けたいので、この識別子には価値があります。日本語で返ってきていることからも分かるとおり、メッセージは言語設定で変わります。

バリデーションエラー

$ curl https://api.github.com/search/repositories
HTTP 422
{
  "message": "Validation Failed",
  "errors": [
    { "resource": "Search", "field": "q", "code": "missing" }
  ],
  "documentation_url": "https://docs.github.com/v3/search",
  "status": "422"
}
$ curl "https://wadokon.com/wp-json/wp/v2/posts?per_page=999"
HTTP 400
{
  "code": "rest_invalid_param",
  "message": "無効なパラメータ: per_page",
  "data": {
    "status": 400,
    "params": { "per_page": "per_page は1以上、100以下でなければなりません。" },
    "details": {
      "per_page": {
        "code": "rest_out_of_bounds",
        "message": "per_page は1以上、100以下でなければなりません。"
      }
    }
  }
}

どちらも「どのフィールドが、なぜ駄目なのか」を項目ごとに返しています。これがバリデーションエラーの必須要件です。

  • GitHub:errors 配列に field と code
    複数項目のエラーを同時に返せる
  • WordPress
    details にフィールド名をキーとして、項目ごとの code と message

フォームを実装する側からすると、この構造があるかどうかで実装量が変わります。項目ごとにエラーが返ってくれば、そのまま各入力欄の下に表示できます。「入力に誤りがあります」という文字列が1つ返ってくるだけのAPIだと、どの欄が悪いのかを推測するしかありません。

複数のエラーをまとめて返せるかも重要です。1つ直すたびに次のエラーが出るAPIは、5項目間違えていたら5往復かかります。

400 と 422 は結局どちらなのか

400・401・403・404・422をどの順で判断するかを示したフローチャート
迷ったら「クライアントは次に何をすべきか」で決める

今回まさに、同じ「バリデーション失敗」に対して GitHub は 422、WordPress は 400 を返しました。この使い分けは議論になりがちなところです。

状況本来の定義
400 Bad Request構文として解釈できない(壊れたJSONなど)
422 Unprocessable Content構文は正しいが、内容が処理できない

定義どおりなら 422 が正しい場面が多いのですが、実際には400で統一しているAPIも普通にあります。大事なのはどちらを選ぶかより、プロジェクト内で一貫していることです。同じAPIの中で400と422が混ざっているのがいちばん困ります。

401 と 403 も同じ問題がある

認証が必要なエンドポイントに認証なしでアクセスしてみます。

$ curl https://wadokon.com/wp-json/wp/v2/settings
HTTP 401
{
  "code": "rest_forbidden",
  "message": "その操作を実行する権限がありません。",
  "data": { "status": 401 }
}

ここは実例として面白いところです。ステータスコードは 401(未認証)なのに、コードの名前は rest_forbidden(403の意味)になっています。

  • 401 Unauthorized
    誰か分からない。ログインすれば通るかもしれない
  • 403 Forbidden
    誰かは分かっている。その人には権限がない

この区別は、クライアント側の挙動を変えます。401ならログイン画面へ誘導する、403なら「権限がありません」と表示して終わる。ここが曖昧だと、権限のないユーザーが延々とログイン画面に飛ばされる、といった実装になります。

ちなみに、存在するが権限のないリソースに対して、あえて404を返す設計もあります。403を返すと「そのIDのリソースは存在する」という情報が漏れるためです。これはセキュリティ上の判断で、意図的にやるなら妥当です。

古い駅のパタパタ式案内板を下から見上げた写真。羽根が回転中でぶれている
表示が変わる瞬間に、何が読み取れるか

ページネーションはヘッダーで返せる

2つとも、同じ方式を採用していました。

$ curl -D - "https://api.github.com/repos/torvalds/linux/commits?per_page=2"
Link: <https://api.github.com/repositories/2325298/commits?per_page=2&page=2>; rel="next",
      <https://api.github.com/repositories/2325298/commits?per_page=2&page=741057>; rel="last"

$ curl -D - "https://wadokon.com/wp-json/wp/v2/posts?per_page=2&page=2"
Link: <https://wadokon.com/wp-json/wp/v2/posts?per_page=2&page=1>; rel="prev",
      <https://wadokon.com/wp-json/wp/v2/posts?per_page=2&page=3>; rel="next"

Link ヘッダーに次ページのURLをそのまま入れる方式です。クライアントはページ番号を自分で計算する必要がなく、URLをそのまま叩けばいいのが利点です。

レスポンスボディに {"data": [...], "next": "..."} のように含める方式もあり、こちらのほうが「ヘッダーを見なくていい」ぶん扱いやすい場面もあります。どちらでも構いませんが、ページ番号だけ返して総件数を返さないのは避けたいところです。「次があるかどうか」が分からないと、クライアントは空が返るまで叩き続けることになります。

件数が多いならカーソル方式

ページ番号方式(?page=2)には構造的な弱点があります。

  • データが増減すると、ページの境界がずれる
    1ページ目を見ている間に新しい項目が追加されると、2ページ目で同じ項目をもう一度見ることになります
  • 後ろのページほど重くなる
    データベース的には OFFSET なので、10万件目を取るには10万件を読み飛ばす必要があります

「最後に見た項目のID」を渡すカーソル方式(?after=xxx)にすると、どちらも起きません。ただし「5ページ目に飛ぶ」ができなくなります。管理画面のようにページ番号が必要な用途か、無限スクロールのように順に読むだけの用途かで選びます。

レート制限は使う前に分かるように

X-RateLimit-Limit: 60
X-RateLimit-Remaining: 59
X-RateLimit-Reset: 1788884674

GitHub はすべての正常なレスポンスにこの3つを付けています。エラーになってから知らせるのではなく、常に残量が見えているのが良い設計です。

クライアント側は、残量を見て自分でペースを落とせます。制限に達したときだけ429を返す設計だと、クライアントは殴ってみるまで分からないので、必ず一度は制限に当たります。

429を返すときは Retry-After を付けます。「いつ再試行していいか」が分からないと、クライアントは適当な間隔でリトライし、結果的に負荷を増やします。

バージョンはパスに置くのが無難

2つとも、パスにバージョンが入っています。

https://wadokon.com/wp-json/wp/v2/posts        ← wp/v2
https://docs.github.com/v3/search              ← v3

ヘッダーでバージョンを指定する方式もありますが、パスに入っているほうが圧倒的に扱いやすいと思います。ブラウザのアドレスバーに貼れば動きますし、curlでも余計な指定が要りません。ログを見ればどのバージョンが叩かれているかも分かります。

WordPress の wp/v2 という形は、名前空間とバージョンをセットにしている点が参考になります。実際このサイトの API を見ると、wp/v2、oembed/1.0、wp-site-health/v1 のように、機能ごとに別々のバージョンが同居しています。プラグインが独自のAPIを足す前提の設計で、拡張される側のシステムでは有効な考え方です。

リソースの命名

ここは原則が固まっているので短く。

  • 名詞の複数形
    /posts、/users。動詞は使わず、操作はHTTPメソッドで表す
  • 階層は浅く
    /posts/123/comments までにして、それ以上深くしない
  • ケースを統一
    パスは小文字ケバブケース、JSONのキーはスネークかキャメルのどちらかに寄せる

迷うのはリソースに当てはまらない操作です。「パスワードをリセットする」「メールを再送する」のような処理は名詞にしづらいところですが、POST /password-reset-requests のように「その操作の記録」をリソースと考えると収まることが多いです。

それでも無理なら POST /users/123/resend-invitation のような書き方で構いません。原則にこだわって分かりにくくなるくらいなら、例外として明示するほうが良いと思います。

まとめ

  • エラーには機械可読な code を入れる
    メッセージ本文で分岐させない
  • バリデーションエラーは項目ごとに、まとめて返す
    1往復で全部分かるようにする
  • 400と422、401と403の使い分けは、一貫していることが最優先
    混在がいちばん困る
  • 次ページのURLを返す
    件数が多いならカーソル方式を検討する
  • レート制限は常に残量を返す
    429だけでは遅い。Retry-After も付ける
  • バージョンはパスに置く
    curlで叩けることの価値は大きい

設計に迷ったときは、実際に使われているAPIに壊れたリクエストを投げてみるのが手っ取り早いと思います。今回のように、こちらから2〜3回叩くだけで、そのAPIがエラーをどう扱う方針なのかがはっきり見えます。

次に読む記事

よかったらシェアしてね!
  • URLをコピーしました!
  • URLをコピーしました!

この記事を書いた人

わどこんのアバター わどこん

実務12年のバックエンド・インフラエンジニア。バックエンド開発からクラウド・インフラの設計・構築・運用まで担当しています。主要言語は Java・Kotlin・PHP・Python。運用の現場で拾った知見を、再現できる手順に落として残すのがこのブログのテーマです。

目次