Problem Details実装パターン|422・401/403・429の返し方

公開:

エラーレスポンスの形式をRFC 9457(Problem Details)に揃えると決めても、実装の段階では「この入力エラーは400か422か」「トークン切れは401か403か」「429には何を付ければよいか」と、ステータスコードとヘッダーの選び方で迷います。ボディの形式が正しくても、ステータスやヘッダーの選び方がずれていると、クライアントは正しく動けません。

この記事では、実務で特によく使う 422(バリデーションエラー)・401/403(認証・認可)・429(レート制限) の3つについて、Problem Detailsでの返し方と、HTTPの仕様上守るべき点をまとめます。Problem Detailsの構造そのもの(type・title・status・detail・instance)は RFC 9457対応のAPIエラーレスポンス設計 で解説しています。

422 バリデーションエラー: 400との使い分け

HTTPの仕様(RFC 9110)は、422 Unprocessable Contentを「リクエストの内容の種類を理解でき、構文も正しいが、含まれる指示を処理できなかった」場合のステータスとしています。実務では次のように分けると判断しやすくなります。

状況 ステータス
JSONとして壊れている、必須のヘッダーがない 400 Bad Request
JSONとしては正しいが、値がビジネスルールに合わない(個数が0、日付が過去など) 422 Unprocessable Content

422では、どの入力がなぜ不正かをフィールド単位で返すのが基本です。RFC 9457の例にならい、拡張メンバー errors にJSON Pointerで問題箇所を並べます。

{
  "type": "https://api.example.com/problems/validation-error",
  "title": "Your request is not valid.",
  "status": 422,
  "detail": "2件の入力内容に誤りがあります。",
  "errors": [
    { "pointer": "#/quantity", "detail": "1以上の整数を指定してください" },
    { "pointer": "#/deliveryDate", "detail": "明日以降の日付を指定してください" }
  ]
}

実装のポイントは、最初のエラーで止めずに、検出したエラーをすべて返すことです。1件ずつ返すと、利用者は送信と修正を何度も繰り返すことになります。Zodのようなスキーマバリデーションのライブラリは、検出したエラーを一覧で取り出せるので、それを errors に変換する処理を共通のエラーハンドラーに1つ置いておけば済みます。

401と403: 「もう一度ログインすれば通るか」で分ける

401と403は混同されやすいのですが、仕様上の区別ははっきりしています。

クライアントの動きも変わります。401ならトークンのリフレッシュやログイン画面への誘導、403なら「権限がありません」の表示です。ステータスを取り違えると、権限不足のユーザーがログイン画面に何度も戻される、といった不具合になります。

401ではWWW-Authenticateヘッダーが必須

見落としやすいのが、401を返すときは WWW-Authenticate ヘッダーを少なくとも1つ付けなければならない(MUST) というRFC 9110の規定です。Bearerトークン(RFC 6750)なら、トークンの期限切れは次のように返します。

HTTP/1.1 401 Unauthorized
WWW-Authenticate: Bearer realm="api", error="invalid_token", error_description="The access token expired"
Content-Type: application/problem+json

{
  "type": "https://api.example.com/problems/token-expired",
  "title": "Access token expired",
  "status": 401,
  "detail": "アクセストークンの有効期限が切れています。"
}

なお、リクエストに認証情報がまったく含まれていない場合、RFC 6750は error などのエラー情報を含めないこと(SHOULD NOT)としています。この場合は WWW-Authenticate: Bearer realm="api" だけを返します。Problem Detailsの type を「トークンなし」と「期限切れ」で分けておけば、クライアントは type を見て処理を分けられます。

権限不足(RFC 6750の insufficient_scope)は403です。また、存在自体を知られたくないリソースには、403の代わりに404を返してもよい(MAY)とされています。

429 レート制限: Retry-Afterと「誰の回数か」

429 Too Many RequestsはRFC 6585で定義されています。仕様が求めているのは次の3点です。

Retry-After は任意ですが、付けておくとクライアントが「いつ再試行すればよいか」を推測せずに済みます。秒数で指定するのが扱いやすく、Problem Detailsの拡張メンバーにも同じ値を入れておくと、ヘッダーを読みにくい環境でも使えます。

HTTP/1.1 429 Too Many Requests
Retry-After: 30
Content-Type: application/problem+json

{
  "type": "https://api.example.com/problems/rate-limit-exceeded",
  "title": "Too Many Requests",
  "status": 429,
  "detail": "1分間のリクエスト上限(60回)を超えました。",
  "retryAfterSeconds": 30
}

もう1つ決めておく必要があるのが、回数を誰の単位で数えるかです。RFC 6585は、利用者の識別方法も回数の数え方も定めていません。APIキーごと、ユーザーごと、IPアドレスごとなど、APIの性質に合わせて設計します。IPアドレスで数える場合は、CDNやロードバランサーを経由すると、サーバーから見える接続元が中継サーバーになる点に注意が必要です(後述の「開発チームより」で実例を紹介します)。

クライアント側の対応表

ステータスと type を揃えておくと、フロントエンドは次のような対応表で処理を書けます。

ステータス クライアントの動き
422 errors[].pointer を見て、フォームの該当フィールドの横にメッセージを出す
401 トークンをリフレッシュして1回だけ再送。失敗したらログイン画面へ
403 「権限がありません」を表示。再送やログイン誘導はしない
429 Retry-After の秒数だけ待って再送。上限回数を決めておく

とくに401と429は「自動で再送する」処理になるため、無限ループにならないよう再送の回数に上限を設けておきます。

開発チームより

ScenarioMockのシナリオ機能には、N回目のリクエストで返すレスポンスを切り替える「シーケンス」があり、回数をIPアドレスごとに数える設定(per_ip)もあります。この記事のような解説記事を書くために仕様を確かめ直していたところ、この per_ip が本番環境では意図どおりに動いていなかったことがわかりました。

原因は、まさに前の節で書いた「中継サーバー」です。ScenarioMockの本番環境では、リクエストがCloudflareとホスティング先を経由して届きます。そのため X-Forwarded-For ヘッダーには「利用者のIP、中継サーバーのIP」が並びます。私たちはこのヘッダー全体をキーにして数えていましたが、中継サーバーのIPはリクエストのたびに変わります。結果として、同じ利用者からのリクエストでも毎回別の人として数えられていました。手元の開発環境では中継サーバーがないため、この問題は起きませんでした。

修正では、Cloudflareが付ける利用者のIPだけを表すヘッダー(cf-connecting-ip)を優先して使い、それがなければ X-Forwarded-For の先頭の値を使うようにしました。幸い、その時点で本番で per_ip を使っているシナリオはありませんでした。

レート制限をIPアドレス単位で実装する場合は、ローカルでの動作確認だけで済ませず、本番と同じ経路でリクエストが届いたときにサーバーから何が見えているかを、一度ログで確かめることをおすすめします。

ScenarioMockで422・401・429を再現する

これらのエラーを本物のバックエンドで起こすには、不正なデータの準備やトークンの失効、大量のリクエストが必要です。ScenarioMock なら、エラーを返すモックAPIを作ってフロントエンドやQAの担当者と共有できます。

429の Retry-After は、回数が戻るまでの時間より短くしないのがポイントです。上の設定では、4回目の時点で回数が戻るまでに最大60秒かかるので、Retry-After: 60 にしています。30秒と書くと、指示どおりに待って再送しても429が返ることがあります。

どのレスポンスも、ヘッダーに Content-Type: application/problem+json を指定すれば、Problem Detailsとして返せます。

この記事のエラーをそのまま試す

上の設定で作ったデモを公開しています。ブラウザで 共有ページ を開くと、エンドポイントの一覧からそのままリクエストを送れます。curlでも試せます。

H=https://demo-mock--error-catalog.scenariomock.com

# 422: quantity と deliveryDate の2項目がまとめて返る
curl -i -X POST $H/orders -H 'Content-Type: application/json' -d '{}'

# 401(トークンなし)と403(読み取り専用のトークン)
curl -i $H/admin/reports
curl -i $H/admin/reports -H 'Authorization: Bearer readonly-token'

# 429: 4回目で Retry-After: 60 が返る
for i in 1 2 3 4; do curl -s -o /dev/null -w "%{http_code}\n" $H/quota; done

期限切れのトークン(Bearer expired-token)では、WWW-Authenticate に error="invalid_token" が入った401が返ります。429の回数は利用者のIPアドレスごとに数えるので、ほかの人の試行に影響されません。モックAPIそのものの考え方は モックAPIとは?必要な理由から作り方、無料ツールの選び方まで で解説しています。

まとめ

まずは自分のAPIの401と429のレスポンスに、WWW-Authenticate と Retry-After が付いているかを確かめるところから始めてみてください。

422・401・429のエラー応答を、本物のバックエンドで起こさずにテストしてみませんか。ScenarioMockなら条件や呼び出し回数でエラーを返すモックAPIを数分で作れます。

エラー応答のモックを無料で作る