APIのエラー応答は、その契約の一部です。クライアントはそれを解析し、リトライロジックはそれに分岐し、サポートエンジニアは午前2時にそれをgrepで検索します。しかし、ほとんどのチームは正常なパスを詳細に設計し、エラーはフレームワークがデフォルトで実行する内容に任せてしまいます。その結果、1つのAPIで3つの異なるエラー形式が発生したり、"success": falseをラップした200応答になったり、データベーススキーマがパブリックインターネットに漏洩するスタックトレースが出てしまったりします。
このガイドでは、RESTサービスにおけるAPIエラー処理のベストプラクティスを網羅的に解説します。適切なステータスコードの選択、RFC 9457 Problem Detailsを用いた単一のエラーボディへの標準化、機械可読なコードと人間が読むメッセージの分離、エラーの再試行可能マーク付け、応答からの機密情報の排除などです。これは、REST APIが使用すべきHTTPステータスコードに関する私たちの解説に基づき、そのガイドで残されている契約レベルの決定事項を追加します。また、テストされていないエラー契約は存在しないも同然であるため、Apidogで失敗パスをすべてテストする方法も示します。
ボディではなくステータスコードから始める
HTTPはすでに、エラーセマンティクスの第一層を無料で提供しています。RFC 9110はステータスコードのファミリーを定義しています。4xxはクライアントが何か間違ったことをしたため、同じリクエストを繰り返しても再び失敗することを示します。5xxはサーバーが失敗したため、クライアントのリクエストは問題なかった可能性があることを示します。エラーボディを1行書く前にこの区分を正しく理解してください。なぜなら、汎用クライアント、プロキシ、キャッシュ、リトライライブラリはすべて、JSONを読み取ることなくこの区分に基づいて分岐するからです。
最もよくある間違いは、似たような一握りのペアに集中しています。設計中はMDNのHTTPステータスコードリファレンスを開いておき、チームが戸惑うコードにはこの決定表を使用してください。
| 状況 | 使用 | 非推奨 | 理由 |
|---|---|---|---|
| 不正なリクエスト: 壊れたJSON、間違ったコンテンツタイプ、必須フィールドの欠落 | 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を返すと、そのリソースが存在するという事実が漏洩します。第二に、Retry-Afterなしの429は、クライアントがタイトループで猛攻撃するよう訓練してしまいます。レート制限を行う場合(行うべきです)、そのステータスと具体的なバックオフシグナルを組み合わせるべきです。APIレート制限の実装方法に関するガイドでは、ヘッダーの計算と背後にあるアルゴリズムを解説しています。
単一のエラーボディ形式: RFC 9457 Problem Details
ステータスコードが正しく設定されたら、APIが返すすべてのエラーは1つのメディアタイプと1つのスキーマを共有すべきです。標準的な答えは、application/problem+jsonとして提供されるRFC 9457 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[]配列は拡張メンバーであり、クライアントが最も好むものです。これは、フロントエンドが各エラーを曖昧なバナーを1つ表示するのではなく、正確なフォームフィールドにマッピングできるようにするためです。クライアントコードがプログラム的にバインドできるように、フィールドパスは安定した形式(JSONポインタまたはドットパス、どちらかを選択)に保ってください。
1つのルールが最も苦痛を軽減します。フレームワークやゲートウェイが生成するエラーを含め、すべてのエラーに対してこの形式を返してください。ハンドラからProblem Detailsを受け取るが、ロードバランサーの502ページからHTMLを受け取るクライアントは、やはり2つのパーサーを書かなければなりません。
機械可読コード vs 人間可読メッセージ
例にはcodeフィールドとmessageフィールドの両方が含まれていることに注目してください。これは意図的なものです。これらは異なる対象読者を対象としており、決して1つの文字列にまとめるべきではありません。
機械可読コード(AMOUNT_NOT_POSITIVE, CURRENCY_UNSUPPORTED, IDEMPOTENCY_KEY_REUSED)は契約です。クライアントはこれに基づいて分岐するため、安定していて文書化されており、列挙可能である必要があります。クライアントに散文を解析させるべきではありません。誰かがif (message.includes("positive"))と書いた途端、コピーの修正が破壊的変更になります。
人間可読メッセージはその逆です。いつでも改善可能で、ログを読む開発者向けに書かれており、ロードベアリングではありません。何が失敗し、どうすれば修正できるかを明確に述べます。「amount must be a positive integer in minor units」は、「invalid amount」よりも優れています。ローカライズする場合は、メッセージをローカライズし、コードはそのままにします。
この分割は、APIコンシューマーに自律エージェントが含まれるようになった今、さらに重要です。LLMベースのクライアントは、構造化され、自己記述的なエラーからよりよく回復します。AIエージェント向けAPIエラー設計でその側面を解説しています。
エラー応答に決して含めてはいけないもの
エラー応答は攻撃者にとって格好の偵察チャネルとなります。なぜなら、未処理の失敗は往々にして冗長だからです。エラーミドルウェアは、以下のものがクライアントに届かないことを保証すべきです。
- スタックトレース、クラス名、またはファイルパス
- 生SQL、クエリフラグメント、またはORMエラー
- 内部ホスト名、IP、ポート、またはサービス名
- ライブラリのバージョンとフレームワークのバナー文字列
- 例外テキストに埋め込まれた秘密情報、トークン、または接続文字列
- ユーザーアカウントが存在するかどうか(ログインおよびパスワードリセットフローでは、失敗を対称的に保つ)
パターンは単純です。境界ですべてを捕捉し、リクエストIDとともにサーバーサイドで完全な例外をログに記録し、その同じIDを持つ汎用Problem Detailsボディを返します。クライアントは"detail": "内部エラーが発生しました", "request_id": "req_51ad0"を受け取り、ログには真実が記録され、サポートはこれらを結合できます。
エラーを再試行可能または終端としてマークする
返すエラーはすべて、クライアントが次に尋ねるであろう質問に答えます。「これをもう一度試すべきか?」各クライアントチームに推測させるのではなく、その答えを契約に焼き付けてください。
ステータスコードはデフォルトのセマンティクスを持ちます。429、502、503、504は、指数バックオフとジッターを伴う再試行可能です。500は曖昧ですが、通常は慎重な1回の再試行の価値があります。他のほとんどの4xxコードは終端です。同じリクエストで401、403、404、または422を再試行すると、クォータが無駄になり、ログが汚染されます。タイムアウトには独自の注意が必要です。クライアントが諦めた後でもリクエストが成功している可能性があるからです。これが古典的な408リクエストタイムアウト問題であり、変更を伴うエンドポイントが冪等性キーを受け入れるべき理由です。これにより、再試行された支払いが二重に請求されることを防ぎます。
拡張メンバーを使用して、再試行可能性を明示的にすることもできます。
{
"type": "https://api.example.com/problems/rate-limited",
"title": "リクエストが多すぎます",
"status": 429,
"code": "RATE_LIMITED",
"retryable": true,
"retry_after_seconds": 30
}
明示的なretryableフラグを使用すると、デフォルトを上書きできます。たとえば、特定の500サブコードを、再試行すると状態が破損するため終端としてフラグを立てる場合などです。このフラグを一度文書化すれば、出荷するすべてのクライアントSDKが均一なバックオフ動作をするようになります。
相関IDとエラー契約のバージョン管理
さらに2つの小さな決定が契約を完成させます。どちらも今は安価ですが、後になると高価になります。
すべてのリクエストにIDを付与します。インバウンドのX-Request-Idヘッダーを受け入れ(または生成し)、すべてのログ行にそれをスタンプし、すべてのエラーボディにrequest_idとしてそれをエコーします。顧客がサポートチケットにエラーを貼り付けたとき、その1つのフィールドが、1時間のログ調査を1つのクエリに変えます。分散設定では、IDがサービス間でリクエストを追跡できるように、W3C traceparentを併せて伝播させます。
API自体と同様に、エラー契約もバージョン管理します。新しい拡張メンバーや新しいエラーコードの追加は安全です。errors[].fieldの名前変更、コードの意味の変更、またはアドホックな形式からProblem Detailsへの移行は破壊的であり、チームが最もテストしないコードパスを破壊します。type URIはクリーンなメカニズムを提供します。古い型URIは永続的に安定させ、新しいセマンティクスには新しい型URIを導入し、未知の拡張メンバーと未知のコードは無視すべきであり、失敗として扱わないことをドキュメントで明記します。この前方互換性条項が、v2なしで進化を可能にするものです。
Apidogで全てのエラーパスをテストする
これが不都合な真実です。エラー契約は、何も実行しないと腐敗します。正常なパスはすべてのデモで実行されますが、422ブランチは顧客がそれに遭遇したときに実行されます。その解決策は、テストスイートで失敗ケースを第一級の存在にすることです。ここでApidogがワークフローにおいてその価値を発揮します。
2つの機能がこの問題に直接対応します。
サーバーサイドのテストシナリオ。各エンドポイントについて、失敗ケースごとにシナリオを構築します。認証情報の欠落は401を期待し、権限不足は403を期待し、負の金額はerrors[0].codeがAMOUNT_NOT_POSITIVEである422を期待し、バーストトラフィックはRetry-Afterヘッダーを含む429を期待します。Apidogのビジュアルアサーションは、スクリプトなしでステータス、ヘッダー、ボディフィールドをチェックします。また、エラー形式のいかなるずれもCIで失敗させ、本番環境では失敗させないように、Problem Details JSONスキーマに対してペイロード全体を検証できます。APIアサーションガイドでは、アサーションパターンを詳細に示しています。
クライアントサイドのモックサーバー。フロントエンドチームとSDKチームは、バックエンドがオンデマンドで生成する前に、4xxおよび5xx応答に対してビルドする必要があります。Apidogモックサーバーは、API仕様から正確なProblem Detailsボディを返すため、Retry-After: 120を持つ503、二重送信時の409、または完全なerrors[]検証ペイロードをシミュレートし、クライアントがどのようにレンダリングおよび再試行するかを監視できます。手作りのExpressスタブや、失敗を強制するためのバックエンドコードのコメントアウトは不要です。
エラー契約を設計し、シナリオとモックとしてエンコードし、両方をCIに組み込みます。Apidogをダウンロードして無料で試してみてください。既存のOpenAPI仕様をインポートするだけで、数分でモック可能なエラー応答が得られます。
FAQ
バリデーションエラーには400と422のどちらを使用すべきですか?
リクエストの形式が不正でサーバーがそれを理解できない場合(無効なJSON、間違ったコンテンツタイプ、必須フィールドの欠落など)は400を使用します。リクエストはきれいに解析されるが、値がドメインルールに違反する場合(負の支払い金額や未サポートの通貨など)は422を使用します。実用的な利点は診断情報にあります。422はクライアントに「データを修正してください」と伝え、400は「リクエスト形式を修正してください」と伝えます。どちらの区分を選択するにしても、すべてのエンドポイントで一貫して適用してください。
application/problem+jsonとは何ですか?
これは、HTTP APIの標準JSONエラー形式であるProblem DetailsのためにRFC 9457によって定義されたメディアタイプです。このコンテンツタイプを持つ応答には、type、title、status、detail、instanceのメンバーと、フィールドレベルのバリデーション失敗のためのerrors[]配列のような、あなたが定義する任意の拡張が含まれます。登録されたメディアタイプを使用することで、汎用クライアントやミドルウェアはカスタム設定なしでエラーを認識できます。RFC 9457解説で完全な仕様をカバーしています。
クライアントはどのHTTPエラーを自動的に再試行すべきですか?
429、502、503、504は、指数バックオフとジッターを伴って再試行し、Retry-Afterが存在する場合はそれに従います。500は1回の慎重な再試行の価値があると見なします。他の4xx応答は再試行しないでください。同じリクエストは毎回同じように失敗します。変更を伴うエンドポイントの場合、再試行と冪等性キーを組み合わせることで、リプレイされたリクエストが二重に請求されたり、二重に作成されたりするのを防ぎます。
バックエンドを壊さずにAPIエラー応答をテストするにはどうすればよいですか?
それらをシミュレートします。クライアントをApidogモックサーバーに向け、仕様から正確な4xxおよび5xxボディを返し、それぞれに対してレンダリングと再試行の動作を確認します。サーバー側では、無効なペイロード、認証情報の欠落、バーストトラフィックを送信するテストシナリオを作成し、ステータスコード、ヘッダー、エラーボディスキーマについてアサートします。どちらの半分もCIで実行されるため、誰も手動で失敗を強制することなく、エラー契約が正直に保たれます。
