REST APIのエラー処理を設計する:ステータスコード、Problem Details、再試行、テスト
APIのエラー応答は契約の一部です。クライアントは応答を解析し、リトライロジックはそれに応じて分岐し、サポートエンジニアは午前2時にエラーをgrepで検索します。それにもかかわらず、正常系だけを詳細に設計し、エラー処理をフレームワークのデフォルトに任せるチームは少なくありません。その結果、1つのAPIに複数のエラー形式が存在したり、"success": falseを含む200応答が返ったり、データベーススキーマを漏らすスタックトレースが公開されたりします。
このガイドでは、RESTサービスのエラー処理を契約として設計する方法を解説します。適切なHTTPステータスコード、RFC 9457 Problem Detailsによるエラー形式の統一、機械可読コードと人間向けメッセージの分離、再試行可能性の明示、機密情報の除外、そしてApidogを使った失敗パスのテストが対象です。
1. エラーボディより先にステータスコードを決める
HTTPは、エラーセマンティクスの第一層を提供します。RFC 9110では、4xxはクライアント側の問題、5xxはサーバー側の問題を表します。汎用クライアント、プロキシ、キャッシュ、リトライライブラリは、JSONを読む前にこの区分で動作を決めるため、エラーボディを設計する前にステータスコードを確定してください。
設計時はMDNのHTTPステータスコードリファレンスも参照し、迷いやすいケースには次の表を使います。
| 状況 | 使用 | 非推奨 | 理由 |
|---|---|---|---|
| 壊れたJSON、誤ったContent-Type、必須フィールドの欠落 | 400 Bad Request | 422 | サーバーがリクエストを解析・理解できない |
| 整形式だが意味論的に不正(金額が負、未対応通貨など) | 422 Unprocessable Content | 400 | 構文は正しいが値が不正 |
| 認証情報がない、期限切れまたは無効なトークン | 401 Unauthorized | 403 | クライアントが身元を証明していない。WWW-Authenticateを送信する |
| 認証済みだが権限が不足 | 403 Forbidden | 401 | 身元は確認済みだがアクセスを拒否した。再認証では解決しない |
| リソースが存在しない、または存在を確認させたくない | 404 Not Found | 410 | 安全なデフォルト。不正なプロービングからリソースを隠せる |
| リソースが意図的かつ永続的に削除済み | 410 Gone | 404 | クライアントやクローラーに参照の削除を促す |
| 重複キー、古いバージョン、編集の衝突 | 409 Conflict | 400 | リクエストは有効だが現在のリソース状態と衝突する |
| クライアントがレート制限を超過 | 429 Too Many Requests | 503 | クライアントが適切に後退できるよう、常にRetry-Afterを含める |
| コード内の未処理例外 | 500 Internal Server Error | 502 | サーバー自体が失敗した |
| アップストリームがゲートウェイに不正なデータを返した | 502 Bad Gateway | 500 | 障害はエッジではなくダウンストリームにある |
| サーバーが過負荷またはメンテナンス中 | 503 Service Unavailable | 500 | 定義上、一時的な障害。可能であればRetry-Afterを追加する |
| アップストリームがタイムアウトした | 504 Gateway Timeout | 500 | 遅い依存関係と壊れたコードを区別できる |
特に重要なのは次の2点です。
- 401と403はセキュリティ境界であり、スタイルの問題ではない 未認証の呼び出し元に403を返すと、リソースの存在を推測される可能性があります。
-
429には
Retry-Afterを付ける ヘッダーがなければ、クライアントはタイトループで再試行する可能性があります。
レート制限のヘッダー計算やアルゴリズムについては、APIレート制限の実装方法も参照してください。
2. RFC 9457 Problem Detailsでエラー形式を統一する
ステータスコードを正しく設定したら、すべてのエラーで同じメディアタイプとスキーマを使います。標準的な選択肢は、application/problem+jsonで返すRFC 9457 Problem Detailsです。
Problem Detailsのコアメンバーは次の5つです。
-
type: エラーカテゴリを識別するURI -
title: 短い人間向けの要約 -
status: HTTPステータスコード -
detail: このリクエストで何が問題だったか -
instance: この障害インスタンスを識別するURI
それ以外の情報は、独自の拡張メンバーとして追加します。RFC 9457解説では、各メンバー、レジストリルール、RFC 7807からの変更点を詳しく説明しています。
たとえば、決済エンドポイントのバリデーションエラーは次のように返せます。
POST /v1/payments HTTP/1.1
Content-Type: application/json
{ "amount": -1400, "currency": "USD", "source": "card_8xKt2" }
HTTP/1.1 422 Unprocessable Content
Content-Type: application/problem+json
{
"type": "https://api.example.com/problems/validation-error",
"title": "リクエストの検証に失敗しました",
"status": 422,
"detail": "1つ以上のフィールドの検証に失敗しました。",
"instance": "/v1/payments/requests/req_9f3c1a7b",
"code": "PAYMENT_VALIDATION_FAILED",
"errors": [
{
"field": "amount",
"code": "AMOUNT_NOT_POSITIVE",
"message": "amountはマイナーユニットで正の整数である必要があります"
}
],
"request_id": "req_9f3c1a7b"
}
errors[]は拡張メンバーです。フロントエンドはこれを使って、曖昧なエラーバナーではなく、該当するフォームフィールドにエラーを表示できます。
クライアントがプログラムで処理できるように、フィールドパスはJSON Pointerまたはドットパスのどちらかに統一し、安定させてください。
また、アプリケーションのハンドラーだけでなく、フレームワークやゲートウェイが生成するエラーにも同じ形式を適用します。ハンドラーではProblem Details、ロードバランサーではHTMLという状態になると、クライアントは2種類のパーサーを実装しなければなりません。
3. 機械可読コードと人間向けメッセージを分離する
codeとmessageを併用するのは意図的です。両者の対象読者が異なるため、1つの文字列にまとめないでください。
機械可読コード
AMOUNT_NOT_POSITIVE、CURRENCY_UNSUPPORTED、IDEMPOTENCY_KEY_REUSEDのようなコードはAPI契約です。クライアントが分岐に使うため、次の条件を満たす必要があります。
- 安定している
- ドキュメント化されている
- 列挙可能である
- 意味を変更しない
クライアントに文章を解析させてはいけません。たとえば、if (message.includes("positive"))のような実装は、文言の修正を破壊的変更にしてしまいます。
人間向けメッセージ
メッセージはログを読む開発者や利用者のためのものです。何が失敗し、どう修正すればよいかを明確にします。
"invalid amount"よりも、次のようなメッセージの方が有用です。
amountはマイナーユニットで正の整数である必要があります
ローカライズする場合はメッセージだけを翻訳し、コードは変更しません。
APIコンシューマーに自律エージェントが含まれる現在、この分離はさらに重要です。LLMベースのクライアントは、構造化され自己記述的なエラーからより適切に回復できます。詳しくはAIエージェント向けAPIエラー設計を参照してください。
4. エラー応答から機密情報を除外する
エラー応答は攻撃者の偵察チャネルになります。エラーミドルウェアでは、次の情報がクライアントに届かないようにします。
- スタックトレース、クラス名、ファイルパス
- 生SQL、クエリ断片、ORMエラー
- 内部ホスト名、IPアドレス、ポート、サービス名
- ライブラリのバージョン、フレームワークのバナー
- 例外テキストに含まれる秘密情報、トークン、接続文字列
- ユーザーアカウントの存在有無 ログインやパスワードリセットでは、成功・失敗の応答を対称的に保つ
基本パターンは次のとおりです。
- 境界で例外をすべて捕捉する
- リクエストIDとともに完全な例外をサーバー側へ記録する
- 同じIDを持つ汎用Problem Detailsを返す
クライアントには次のような情報だけを返します。
{
"detail": "内部エラーが発生しました",
"request_id": "req_51ad0"
}
ログには詳細な原因を残し、サポート担当者はrequest_idで両者を結び付けます。
5. エラーを再試行可能または終端として定義する
すべてのエラーは、クライアントが次に知りたい問いに答えるべきです。
もう一度試すべきか?
一般的なデフォルトは次のとおりです。
-
429、502、503、504: 指数バックオフとジッターを付けて再試行 -
500: 通常は慎重な1回の再試行 - その他の多くの4xx: 終端エラーとして再試行しない
401、403、404、422を同じリクエストで繰り返しても、クォータを消費しログを汚すだけです。
タイムアウトには注意が必要です。クライアントがタイムアウトした後でも、サーバー側では処理が成功している可能性があります。変更を伴うエンドポイントでは冪等性キーを受け入れ、リトライによる二重請求や二重作成を防いでください。
再試行可能性は拡張メンバーで明示できます。
{
"type": "https://api.example.com/problems/rate-limited",
"title": "リクエストが多すぎます",
"status": 429,
"code": "RATE_LIMITED",
"retryable": true,
"retry_after_seconds": 30
}
retryableを使えば、ステータスコードのデフォルトを上書きできます。たとえば、再試行すると状態が壊れる特定の500サブコードを終端エラーとして扱うケースです。
この仕様を一度文書化すれば、各クライアントSDKで同じバックオフ動作を実装できます。
6. 相関IDとエラー契約をバージョン管理する
後から追加すると高くつくため、次の2点を最初に決めます。
リクエストIDを全リクエストに付与する
- 受信した
X-Request-Idを利用する、または生成する - すべてのログ行に記録する
- すべてのエラーボディに
request_idとして返す - 分散システムではW3Cの
traceparentもサービス間で伝播する
顧客がエラーをサポートチケットに貼り付ければ、request_idによって1時間のログ調査を1回のクエリに短縮できます。
エラー契約をAPIと同じように管理する
次の変更は通常、安全です。
- 新しい拡張メンバーの追加
- 新しいエラーコードの追加
一方、次の変更は破壊的です。
-
errors[].fieldの名前変更 - 既存コードの意味の変更
- アドホック形式からProblem Detailsへの移行
type URIを安定した識別子として使い、古い型URIは永続的に維持します。新しいセマンティクスには新しい型URIを導入し、未知の拡張メンバーや未知のコードは無視して処理を継続する、とドキュメントに明記してください。この前方互換性のルールにより、v2を待たずに契約を進化させられます。
7. Apidogで全エラーパスをテストする
エラー契約は、実行しなければ腐敗します。正常系はデモで常に実行されますが、422ブランチは顧客が実際に遭遇するまで放置されがちです。
解決策は、失敗ケースをテストスイートの第一級のシナリオにすることです。Apidogでは、サーバー側とクライアント側の両方を検証できます。
サーバーサイドのテストシナリオ
エンドポイントごとに失敗ケースを作成します。
- 認証情報なし:
401 - 権限不足:
403 - 負の金額:
errors[0].codeがAMOUNT_NOT_POSITIVEの422 - バーストトラフィック:
Retry-After付きの429
Apidogのビジュアルアサーションを使えば、スクリプトなしで次を検証できます。
- ステータスコード
- レスポンスヘッダー
- Problem Detailsの各フィールド
- JSONスキーマ全体
エラー形式のずれはCIで検出し、本番環境で発見しないようにします。アサーションのパターンについては、APIアサーションガイドを参照してください。
クライアントサイドのモックサーバー
フロントエンドチームやSDKチームは、バックエンドが失敗レスポンスを生成する前から、4xx・5xxへの対応を実装できる必要があります。
Apidogのモックサーバーは、API仕様からProblem Detailsを返せます。たとえば次のケースをシミュレートできます。
-
Retry-After: 120付きの503 - 二重送信時の
409 -
errors[]を含む完全なバリデーションエラー
これにより、クライアントの表示や再試行動作を確認できます。手作業のExpressスタブや、バックエンドコードを一時的にコメントアウトする必要はありません。
エラー契約を設計し、シナリオとモックとして定義し、両方をCIに組み込みましょう。Apidogをダウンロードし、無料で試せます。既存のOpenAPI仕様をインポートするだけで、数分でモック可能なエラー応答を作成できます。
FAQ
バリデーションエラーには400と422のどちらを使うべきですか?
リクエスト形式が不正でサーバーが理解できない場合は400を使います。無効なJSON、誤ったContent-Type、必須フィールドの欠落などが該当します。
リクエストを解析できるものの、値がドメインルールに違反する場合は422を使います。負の支払い金額や未対応の通貨などです。
422は「データを修正してください」、400は「リクエスト形式を修正してください」という診断情報を伝えられます。どちらを選ぶ場合も、すべてのエンドポイントで一貫させてください。
application/problem+jsonとは何ですか?
RFC 9457が定義する、HTTP API向けProblem Detailsの標準メディアタイプです。このContent-Typeの応答には、type、title、status、detail、instanceと、errors[]のような独自拡張を含められます。
登録済みのメディアタイプを使うことで、汎用クライアントやミドルウェアはカスタム設定なしでエラーを認識できます。詳しくはRFC 9457解説を参照してください。
クライアントはどのHTTPエラーを自動的に再試行すべきですか?
429、502、503、504は、指数バックオフとジッターを付けて再試行します。Retry-Afterがあれば、その値に従ってください。
500は、慎重な1回の再試行に限定します。その他の4xxは通常、再試行しません。同じリクエストは毎回同じように失敗するためです。
変更を伴うエンドポイントでは、冪等性キーと組み合わせて、リプレイによる二重請求や二重作成を防ぎます。
バックエンドを壊さずにAPIエラー応答をテストするには?
エラーをシミュレートします。
クライアントをApidogモックサーバーに接続し、仕様から正確な4xx・5xxレスポンスを返して、表示と再試行動作を確認します。サーバー側では、無効なペイロード、認証情報の欠落、バーストトラフィックを送信するテストシナリオを作成し、ステータスコード、ヘッダー、エラーボディのスキーマを検証します。
両方をCIで実行すれば、手動で失敗を強制しなくても、エラー契約を維持できます。
Top comments (0)