エラーレスポンス設計の現在地 2026、標準はコードに焼いて規約は最小にする
前回の記事「Web API設計の現在地 2026、いま従うべき標準とデファクトの一覧」で、この10年でいちばん大きく変わった領域としてエラーレスポンスを挙げました。この記事はその深掘りです。
エラーレスポンスの設計というと RFC 9457(Problem Details)のボディ形式の話になりがちですが、実際にエラーを受け取るクライアントが見ているのはボディだけではありません。ステータスコード、ヘッダー、ボディの3層すべてに仕様とデファクトがあり、どれか1層でも欠けると監視やリトライやフォームのエラー表示のどこかが壊れます。
この記事では3層それぞれの「いま従うべきもの」を一覧できる状態にします。そのうえで最後に、この標準をプロジェクトに定着させる方法まで書きます。
想定読者はこれからWeb APIのエラー形式を決める人です。
エラーは3層でできている
| 層 | 何を伝えるか | 誰が見るか | 正典 |
|---|---|---|---|
| ステータスコード | エラーの分類。リトライ可否・キャッシュ可否 | 監視・CDN・HTTPクライアント・プロキシ | RFC 9110(429のみ RFC 6585) |
| ヘッダー | エラーの付帯情報。いつ再試行するか、どう認証するか | HTTPクライアント・SDK | RFC 9110 / 6750 ほか |
| ボディ | 具体的に何が起きたか。ユーザー表示と回復処理の材料 | アプリケーションコード | RFC 9457 |
上2層はHTTPの汎用インフラが機械的に解釈する領域、ボディはアプリケーションが解釈する領域です。この分担を理解しておくと、後で出てくる「エラーを200で返してはいけない」「エラーコードとステータスコードは別物」がすべて同じ原理の言い換えだと分かります。
第1層 ステータスコード、正典は RFC 9110
メソッドとステータスコードの意味論を定めている現行の正典は RFC 9110(2022年)です。長く引用されてきた RFC 2616 や 7231 はここに統合されて置き換わりました。エラーで頻出のコードのうち 429 Too Many Requests だけは、追加ステータスコードを定義した RFC 6585(2012年)が出典です。
大分類、400番台と500番台
エラー系のステータスコードは2つの番台に分かれていて、この区別がすべての基礎になります。
- 400番台(クライアントエラー): リクエストの側に問題がある。同じリクエストを何度送っても結果は変わらないので、クライアントはリクエストを直す必要がある
- 500番台(サーバエラー): サーバの側に問題がある。リクエスト自体は正しい可能性があり、時間を置いて同じリクエストを送れば成功するかもしれない
「リトライして意味があるのはどちらか」という後述のクライアント規約は、この分類から直接導かれます。
エラーで使うコードの一覧
Web APIのエラーで実際に使うコードは、それほど多くありません。それぞれの意味を押さえます。
| コード | 名前 | 意味 |
|---|---|---|
| 400 | Bad Request | リクエストそのものが不正で、サーバが解釈できない。JSONが壊れている、必須パラメータがない、型が違うなど |
| 401 | Unauthorized | 認証されていない。名前は Unauthorized だが実際の意味は「未認証」で、認証情報が無い・無効・期限切れのケース。誰なのかがまだ分かっていない状態 |
| 403 | Forbidden | 認証は済んでいるが、その操作をする権限がない。誰かは分かっているが、許されていない状態 |
| 404 | Not Found | 指定されたリソースが存在しない |
| 405 | Method Not Allowed | URLは存在するが、そのHTTPメソッド(GET/POST等)を受け付けていない |
| 409 | Conflict | リソースの現在の状態と矛盾する操作。編集の競合、同じものの重複作成など |
| 410 | Gone | かつて存在したが、恒久的に削除された。404と違い「もう戻らない」ことを明示する |
| 422 | Unprocessable Content | リクエストの形式は正しく解釈できたが、内容が意味的に処理できない。バリデーションエラーの定番 |
| 429 | Too Many Requests | 一定時間内のリクエスト数が制限を超えた(レート制限) |
| 500 | Internal Server Error | サーバ内部の予期しないエラー。いわゆるバグや例外 |
| 502 | Bad Gateway | 経路上のゲートウェイ(リバースプロキシ等)が、背後のサーバから不正な応答を受け取った |
| 503 | Service Unavailable | サーバが一時的にリクエストを処理できない。過負荷やメンテナンス中 |
| 504 | Gateway Timeout | ゲートウェイが背後のサーバの応答を待ちきれずタイムアウトした |
実務で迷う定番の使い分け
個々の意味が分かっていても、実際のAPIでは「どっちを返すべきか」で迷う場面があります。定番の迷いどころには答えが決まっています。
| 迷いどころ | 使い分け |
|---|---|
| 400 vs 422 | 400 はリクエストの形式が不正(JSONが壊れている等)。422 は形式は正しいが意味的に処理できない(バリデーションエラー等)。422 は WebDAV 出身だったが RFC 9110 に昇格して一般のHTTPコードになった |
| 401 vs 403 | 401 は「認証されていない」(誰か分からない)、403 は「認証済みだが権限がない」。401 を返すなら WWW-Authenticate ヘッダが必須(第2層で後述) |
| 404 vs 403 | リソースの存在自体を隠したい場合、403 の代わりに 404 を返してよいと RFC 9110 が明記している。他人のリソースへのアクセスに 403 を返すと「存在すること」が漏れる |
| 404 vs 410 | 410 Gone は「かつて存在したが恒久的に消えた」。クライアントに再取得を諦めさせたい場合に使う |
| 409 | 現在のリソース状態と矛盾する操作(楽観ロックの競合、重複作成など) |
| 429 vs 503 | 429 はクライアント単位のレート制限超過。503 はサーバ全体の過負荷・メンテナンス。どちらも Retry-After とセットで使う |
そして第1層の大原則が、エラーを 200 で返さないです。ステータスコードを常に 200 にして { success: false } で表現する設計は、監視のエラー率グラフを平坦にし、キャッシュすべきでないエラーをCDNにキャッシュさせ、HTTPクライアントの自動リトライを無効化します。トランスポート層のインフラはボディを読まないので、この層の情報はこの層で表現するしかありません。
第2層 ヘッダー、ステータスコードとセットで義務があるもの
ヘッダー層は見落とされがちですが、「このステータスコードを返すならこのヘッダーを付けなければならない」という組み合わせが仕様で決まっています。
401 には WWW-Authenticate
WWW-Authenticate は「このリソースにはどの方式で認証すればよいか」をクライアントに伝えるヘッダです。RFC 9110 上、401 を返すレスポンスにはこのヘッダが必須です。401 は「認証をやり直せ」という指示なので、やり直し方をセットで返す必要がある、という理屈です。
Authorization: Bearer <トークン> の形でアクセストークンを送るBearer トークン認証の場合は、RFC 6750 がさらに細かく、error 属性でエラー種別を返す形式まで決めています。
HTTP/1.1 401 Unauthorized
WWW-Authenticate: Bearer realm="api", error="invalid_token", error_description="The access token expired"
error に入る値も3つに標準化されています。invalid_request(400相当)、invalid_token(401。期限切れはこれ)、insufficient_scope(403相当)。トークン期限切れの表現を独自設計する必要はもうありません。
405 には Allow
405 Method Not Allowed を返すときは、許可されているメソッドの一覧を Allow: GET, POST の形で返すことが RFC 9110 で必須になっています。
429 と 503 には Retry-After
「いつ再試行してよいか」を伝えるヘッダーです。秒数(Retry-After: 120)とHTTP日時(Retry-After: Fri, 08 Aug 2026 07:00:00 GMT)の両方が使えます。これを返しておくと、行儀のよいクライアントやSDKが勝手に適切な待ち時間でリトライしてくれます。逆にこれ無しで 429 を返すと、クライアントは待ち時間を当てずっぽうで決めるしかありません。
レートリミット残量、デファクトと標準化ドラフト
429 は「制限を超えた後」に返すものですが、超える前に残量を伝えるヘッダーには標準がまだありません。現状のデファクトは GitHub などが使う3点セットです。
| ヘッダ | 意味 |
|---|---|
X-RateLimit-Limit |
現在のウィンドウ(例: 1時間)あたりのリクエスト数上限 |
X-RateLimit-Remaining |
現在のウィンドウでの残り回数 |
X-RateLimit-Reset |
制限がリセットされる時刻(UNIXエポック秒が多い) |
これを全レスポンスに付けておくと、クライアントは 429 を食らう前にペースを落とせます。IETFでは RateLimit / RateLimit-Policy ヘッダとして標準化するドラフト(draft-ietf-httpapi-ratelimit-headers)が進行中ですが、2026年8月時点でまだRFCになっていません。前回の記事で書いた「標準化が追いつかず実装がデファクトになる」構図がそのまま残っている領域です。
第3層 ボディ、RFC 9457 Problem Details
ボディ形式の現行標準が RFC 9457(2023年発行。初出は2016年の RFC 7807)です。成熟度は Proposed Standard で、TLS 1.3 や OAuth 2.0 と同じく標準化過程のRFCとして広く実装されている段階です。Content-Type: application/problem+json で返します。
HTTP/1.1 403 Forbidden
Content-Type: application/problem+json
{
"type": "https://example.com/errors/insufficient-funds",
"title": "残高が不足しています",
"status": 403,
"detail": "残高は30ポイントですが、この取引には50ポイント必要です",
"instance": "/accounts/12345/transfers/67890"
}
まず5つのフィールドの意味からです。
| フィールド | 意味 |
|---|---|
type |
エラー種別を識別するURI。アプリ固有のエラーコードに相当する |
title |
そのエラー種別の短い説明。人間向けで、同じ type なら常に同じ文言 |
status |
HTTPステータスコード。第1層と同じ値をボディにも入れる |
detail |
この発生1回に固有の説明。具体的な値はここに入る |
instance |
エラーが起きた具体的なリソースのURI |
決めているのは実質3つだけ
仕様書と聞くと分厚い要求リストを想像しますが、RFC 9457 が決めていることは実質3つです。
-
Content-Typeはapplication/problem+json - 5つのメンバー(
type/title/status/detail/instance)の意味が予約されている。ただしすべて任意 - それ以外の拡張メンバーを自由に足してよい
全メンバー任意で拡張も自由。つまりこれは「独自形式を捨てて乗り換える型」ではなく「既存のエラー形式に共通の骨格を与える合意」で、移行のハードルが低いのはこの緩さが理由です。
title と detail の使い分け
-
titleはtypeに対して固定。同じエラー種別なら毎回同じ文言 -
detailは発生ごとに変わる。具体的な値はこちら - クライアントの分岐に使うのは
type。titleの文字列マッチを始めると、文言修正が破壊的変更になる -
typeのURIは解決できなくてもよい(識別子であればよい)が、ドキュメントURLにしておくとエラーレスポンス自体がドキュメントへの導線になる -
typeを省略するとabout:blank扱いで「ステータスコード以上の情報はない」の意味になる -
instanceはエラーが起きたリソースのURI。ログの相関IDを入れる用途にも使われ、ユーザーから送られたエラーレスポンスだけでログを特定できるようになる
バリデーションエラーは拡張メンバーで返す
実務で一番よく返すフィールド単位のバリデーションエラーを、RFC 9457 本体は定義していません。ここは拡張メンバーの出番で、errors 配列を足すのが慣例です。
{
"type": "https://example.com/errors/validation",
"title": "入力内容に誤りがあります",
"status": 422,
"errors": [
{ "field": "email", "code": "ALREADY_TAKEN", "message": "既に登録されています" },
{ "field": "password", "code": "TOO_SHORT", "message": "8文字以上にしてください" }
]
}
なお 7807 から 9457 への改訂は、この種の拡張の使い方の追認と type 解決の明確化が主で、構造は変わっていません。7807 準拠の実装やライブラリはそのまま使えます。
フレームワークの足場
対応状況は言語圏でかなり差があります(2026年8月時点、各公式ドキュメントで確認)。
| フレームワーク | Problem Details 対応 |
|---|---|
| Spring Framework 6 / Boot 3 |
ProblemDetail クラスを標準搭載。組み込み例外の自動 problem+json 化はデフォルト無効で、spring.mvc.problemdetails.enabled=true で有効化する |
| ASP.NET Core |
[ApiController] のエラーは ProblemDetails へ自動変換(デフォルト有効)。Minimal API は AddProblemDetails()(.NET 7 から) |
| NestJS | 組み込み対応なし。既定は { statusCode, message }。寄せるなら例外フィルタで自前実装 |
| Express / Fastify / Hono | 組み込み対応なし。ライブラリか自前実装 |
JavaやC#では標準がフレームワークに入り済み、JS/TSではエラー形式を決めるのは依然として設計者の仕事です。
層をまたぐ設計判断
エラーコードとステータスコードは別レイヤー
自分のエラー設計が情報を持っているかを測る物差しを1つ。アプリ固有のエラーコードからステータスコードが一意に導けるなら、そのエラーコードは情報を持っていません。エラーコード 40001 が常に 400 を意味するだけならステータスコードの複製です。逆にステータスコードだけでは「400が返った」までしか分からず、メール重複かパスワード不足かを区別できないクライアントは、日本語メッセージの文字列マッチという地獄に落ちます。
トランスポート層の分類は status、アプリケーション層の識別は type(と拡張メンバー)。RFC 9457 の本質は、この2レイヤーを1つのボディで両立させる置き場が決まったことです。
セキュリティ、detail に入れてはいけないもの
エラーレスポンスは攻撃者にとって偵察の入り口でもあります。detail や title に次のものを入れてはいけません。
- スタックトレース、例外クラス名、ライブラリ名とバージョン
- SQL文やクエリの断片
- 内部ID、内部ホスト名、ファイルパス
- 「ユーザーは存在するがパスワードが違う」のような存在情報の切り分け(ログイン失敗は1種類のエラーに丸める)
デバッグ情報はログに書き、レスポンスには instance の相関IDだけを出して突き合わせる。これが「ユーザーには親切、攻撃者には無口」を両立する形です。
リトライしてよいエラーはどれか
クライアント側の規約も1行で決まります。自動リトライしてよいのは 429・503・504、および Retry-After が付いているレスポンスのみ。400番台の大半は同じリクエストを何度送っても同じ結果になるので、リトライは無意味どころかレート制限行きです。
定着のさせ方、標準はコードへ、判断はハーネスへ
ここまでの内容をプロジェクトにどう定着させるか。CLAUDE.md や AGENTS.md のような規約ファイルにこの記事の内容を丸写しする、が素直な答えに見えますが、やめた方がいいと思っています。最近のモデルは RFC 9457 もステータスコードの意味論も知っています。モデルが知っていることを毎回コンテキストに積むのは冗長で、固定費を払って得るものがありません。やることは2つです。
1. 標準は共通関数に焼き込む
エラーレスポンスの組み立てを1箇所に集約し、この記事の「決まっていること」を全部そこに焼き込みます。RFC 9457 のフィールド構成と Content-Type、ステータスコードとヘッダーの対応(401→WWW-Authenticate、405→Allow、429/503→Retry-After)、detail に内部情報を入れないこと。呼び出し側は problem(status, type, detail, extensions) のような共通関数を呼ぶだけにして、正しいレスポンスしか作れない状態を作ります。
コードに焼き込まれたルールは、AIにも人間にも思い出させる必要がありません。守られているかのチェックも「共通関数以外でレスポンスを組み立てていないか」の1点に縮み、これはlintで機械的に強制できます。
TypeScriptなら型でさらに縛れます。ステータスごとに必須引数が変わるように定義しておくと、problem(429, ...) は retryAfter を渡さない限りコンパイルが通らず、problem(405, ...) は allow の一覧が必須になる。「401なのに WWW-Authenticate を忘れた」がレビューで見つける指摘ではなくコンパイルエラーになります。規約をコードに焼くというより、型に焼く。ここまでやると、ヘッダーの義務の節で書いた内容は全部、忘れることが不可能になります。
Hono での実装例を gist に置いておきます。
緩さが残るのは拡張メンバーの受け皿くらいで、ここはどうしても Record<string, unknown> のような型になりがちです。正直このシグネチャはレビューでつついてきた側なので、拡張を多用するプロジェクトなら、エラー種別ごとに拡張の型を定義して(残高不足なら { balance: number; required: number } を必須にする等)ここも縛るのをおすすめします。
2. ハーネスに書くのはプロジェクト固有の判断だけ
共通関数に焼き込めないのは、コードでは決められない「このプロジェクトの判断」だけです。これを書く先がハーネス、つまり CLAUDE.md / AGENTS.md や .claude/rules/ といった「モデルに何をどう読ませるか」の設定側です。
置き場所を考えるとき大事なのは、この規約が必要になるのは共通関数を実装するときではなく呼び出すときだという点です。401か403か、存在を隠して404にするか。判断はエラーを返す側、つまりハンドラやルーティングの層で起きます。規約はそこに効かせます。呼び出し側の層のディレクトリに CLAUDE.md / AGENTS.md を置くか、.claude/rules/ に置くルールの paths: で src/api/** のように呼び出し側の層を指すか、どちらでも同じです。
そして、このパス指定がすっと書けるかどうか自体がひとつの診断になります。共通関数を呼び出す場所、つまり「エラーを返す」と決めるコードがあちこちのディレクトリに散らばっていて、パスで絞れず、規約をほぼルートに置くしかない。そうなっているなら、規約の書き方以前に設計を見直すサインです。エラーを返す判断をする層が決まっていれば、規約はそこに向けるだけで迷う余地はありません。
書くべきは3種類しかありません。
- 共通関数の強制。エラーレスポンスは必ずこの関数で組み立てる、という1行
- プロジェクト固有の判断。標準が「選んでよい」としている部分をこのプロジェクトはどう決めたか。
title/detailを日本語と英語のどちらで書くか、存在秘匿の 404/403 の方針、ログイン失敗の丸め方、typeのスラッグやエラーコードの命名規則など - デファクトから外した点とその理由。外すこと自体は悪くありませんが、記録がないと後から来た人(とAI)が「知らずに外れている」のか「決めて外した」のか区別できません
サンプルはこの程度の分量です。
---
paths: src/api/** # 共通関数を呼び出す層(ハンドラ・ルーティング)に合わせる。その層に CLAUDE.md / AGENTS.md を直接置くなら不要
---
# エラーレスポンス
- エラーレスポンスは必ず `problem()`(src/middleware/error/problem.ts)で組み立てる。`c.json()` でエラーを直接返さない
- title / detail は日本語で書く(利用者が国内のみのため)。多言語化はしない
- type のスラッグは kebab-case。新しいエラー種別を追加したら docs/errors.md のカタログにも1行追加する
- errors 配列の code は SCREAMING_SNAKE_CASE(例: ALREADY_TAKEN)
- 存在秘匿: 他ユーザーのリソースへのアクセスは 403 ではなく 404 を返す
- ログイン失敗は原因を切り分けず、常に同一の 401 レスポンスに丸める
- デファクトからの逸脱: バリデーションエラーは 422 ではなく 400 に統一している(既存クライアントとの互換のため)
モデルが知っている標準の説明は1行も入っていません。この分量なら常時ロードされても固定費はほぼゼロです。標準はコードが守り、コンテキストはプロジェクトの判断だけを運ぶ。この分担が、いまの規約の書き方として一番無駄がないと思っています。
まとめ
3層のそれぞれに正典があり、決めごとの大半はすでに決まっています。設計者に残っている仕事は、標準を共通関数と型に焼き込むことと、コードでは決められないプロジェクト固有の判断をハーネスに書き残すことの2つだけです。401 に WWW-Authenticate を付けたか、リトライしてよいコードはどれか。この記事の内容をチェックリストとして人間が覚えるのではなく、コンパイラと lint に覚えさせてしまうのが2026年のやり方だと思っています。
シリーズの前回分はこちらです。
参考
- RFC 9110 HTTP Semantics: https://www.rfc-editor.org/rfc/rfc9110.html
- RFC 6585 Additional HTTP Status Codes: https://www.rfc-editor.org/rfc/rfc6585.html
- RFC 6750 OAuth 2.0 Bearer Token Usage: https://www.rfc-editor.org/rfc/rfc6750.html
- RFC 9457 Problem Details for HTTP APIs: https://www.rfc-editor.org/rfc/rfc9457.html
- RateLimit header fields for HTTP(ドラフト): https://datatracker.ietf.org/doc/draft-ietf-httpapi-ratelimit-headers/
株式会社シンシア
株式会社xincereでは、実務未経験のエンジニアの方や学生エンジニアインターンを採用し一緒に働いています。
※ シンシアにおける働き方の様子はこちら
シンシアでは、年間100人程度の実務未経験の方が応募し技術面接を受けます。
その経験を通して見えてきた「実務未経験の方にぜひ身につけてほしい技術力」を、ここでは紹介していきます。
