Web APIのエラーハンドリング設計|RFC 9457・error_code・400/422/409の使い分け
4xxはクライアント修正、5xxはサーバー調査が基本。レスポンスはRFC 9457のapplication/problem+jsonに揃え、HTTPステータス+error_code+人間向けdetailの3層で返す。400/422/409は「パース失敗」「ビジネスルール違反」「状態競合」で切り分け、スタックトレースは本番で秘匿する。
なぜエラーレスポンス設計がAPI品質を左右するか
フロントエンド、モバイルアプリ、サードパーティ連携——いずれも成功レスポンスだけでなく失敗時の形に強く依存する。ステータスコードが曖昧だと「再試行すべきか」「入力を直すべきか」「ログインし直すべきか」の判断ができず、ユーザー体験と運用コストの両方が悪化する。
たとえば決済APIが在庫不足を500 Internal Server Errorで返すと、クライアントのリトライライブラリは「サーバー障害」と判断して同じリクエストを何度も送り、結果として在庫引当の競合や二重決済リスクが高まる。逆に、想定外のDB接続エラーを400 Bad Requestで返すと、ユーザーは入力フォームを何度も修正し続ける——どちらも設計ミスが生む無駄なコストだ。
一貫したエラー形式を用意しておけば、クライアントはerror_codeで分岐し、サポートはinstance(リクエストID)でログを追跡できる。APIを公開するほど、エラー設計は「後付け」ではなく最初からスキーマと並べて決めるべき要素になる。
4xxと5xx:誰が直すべきエラーか
HTTPステータスの大分類は、責任の所在を示す。これを間違えると、クライアントが無限リトライしたり、サーバー障害をユーザー入力ミスと誤認したりする。
| 区分 | 意味と典型例 |
|---|---|
| 4xx Client Error | リクエスト側の問題。URL・認証・バリデーション・権限・状態競合。クライアントが修正または再設計すべき |
| 5xx Server Error | サーバー側の問題。DB障害・未処理例外・依存サービスダウン。再試行で直る場合もある |
| 401 / 403 | 401=未認証(トークン未送信・期限切れ)、403=認証済みだが権限不足。混同しない |
| 404 Not Found | リソースが存在しない。存在秘匿のため403にすることもある |
| 409 Conflict | リソースの現在状態とリクエストが矛盾(楽観ロック失敗、重複登録) |
| 422 Unprocessable | 形式は正しいがビジネスルール違反(在庫不足、クーポン期限切れ) |
| 500 Internal | 想定外の例外。必ずサーバーログに記録し、クライアントには汎用メッセージ |
| 503 Service Unavailable | メンテナンス・過負荷。Retry-Afterヘッダーで再試行時刻を示せる |
バリデーション失敗を一律500で返すと、フロントは「サーバー障害」と判断してリトライし続ける。入力ミスは4xx、想定外の例外だけ5xxに分ける。認証エラーを403にまとめるのも誤りで、トークン未送信・期限切れは401、権限不足は403だ。各コードの詳細はHTTPステータスコード一覧も参照。
HTTPステータスコードの全体像
エラーハンドリング設計の前提として、HTTPステータスコードの意味をチーム全員が共有しておく必要がある。2xxは成功、3xxはリダイレクト、4xxはクライアント起因、5xxはサーバー起因——この大枠を外すと、後述のerror_code設計もブレる。
実務で特に混同されやすいのが400・409・422の3つだ。いずれも「リクエスト内容に問題がある」ように見えるが、どの段階で・なぜ弾かれたかが異なる。次のセクションで決定ツリーを示すが、まずはHTTPステータスコード一覧で各コードの定義と対処法を押さえておくと、API設計の議論が速くなる。
RFC 9457 Problem Details:完全なレスポンス例
RFC 9457(旧 RFC 7807 の後継)は、Content-Type: application/problem+jsonで返すエラー形式を定義する。主要フィールドは次のとおり。
| フィールド | 必須 | 役割 |
|---|---|---|
type | 推奨 | エラー種別のURI(ドキュメントへのリンク) |
title | 推奨 | 短い人間可読タイトル(同一typeなら固定) |
status | 推奨 | HTTPステータスコード(ボディとヘッダーで一致) |
detail | 推奨 | このリクエスト固有の説明 |
instance | 任意 | 発生インスタンスのURI(多くはリクエストID) |
拡張フィールドとしてerror_code(機械可読識別子)やerrors(フィールド別バリデーション配列)を足すのが実務では一般的だ。以下は、注文作成APIが在庫不足で422を返す完全なHTTPレスポンスの例である。
HTTP/1.1 422 Unprocessable Content
Content-Type: application/problem+json; charset=utf-8
X-Request-Id: req_8f3a2b1c4d5e
Cache-Control: no-store
{
"type": "https://api.example.com/errors/insufficient_stock",
"title": "Insufficient Stock",
"status": 422,
"detail": "商品 SKU-12345 の在庫が不足しています。現在の在庫: 2、要求数量: 5",
"instance": "https://api.example.com/requests/req_8f3a2b1c4d5e",
"error_code": "ORDER_INSUFFICIENT_STOCK",
"errors": [
{
"field": "items[0].quantity",
"message": "在庫数(2)を超える数量(5)は指定できません",
"rejected_value": 5,
"constraint": "max_available"
}
],
"meta": {
"sku": "SKU-12345",
"available_quantity": 2,
"requested_quantity": 5
}
}
ポイントを整理する。
typeは社内のエラー一覧ページURLにすると、開発者が意味を調べやすい。OpenAPIのexternalDocsとリンクしてもよい。statusはHTTPヘッダーとボディの両方に載せ、クライアントがボディだけパースしても整合するようにする。instanceはリクエストID(X-Request-Idヘッダーと同値)にすると、サポートが「このエラー画面のIDを教えてください」でログを即座に追える。errors配列はフォームバリデーション向け。fieldはJSON Pointerまたはドット記法で統一する。metaは任意の拡張だが、クライアントがUIに表示する補助情報(残在庫数など)を載せる。機密情報は入れない。
認証エラー(401)やレート制限(429)も同じ形式で返すと、クライアントSDKが1つのパーサーで処理できる。
HTTP/1.1 429 Too Many Requests
Content-Type: application/problem+json
Retry-After: 60
X-RateLimit-Limit: 100
X-RateLimit-Remaining: 0
X-RateLimit-Reset: 1718870460
{
"type": "https://api.example.com/errors/rate_limit_exceeded",
"title": "Rate Limit Exceeded",
"status": 429,
"detail": "1分あたり100リクエストの上限に達しました。60秒後に再試行してください。",
"instance": "https://api.example.com/requests/req_a1b2c3d4",
"error_code": "RATE_LIMIT_EXCEEDED"
}
error_code:機械可読な識別子の命名規則
人間向けのdetailだけでは、クライアントのswitch分岐やi18nが難しい。error_codeは安定した識別子として別途定義する。HTTPステータスは「大分類」、error_codeは「具体的な失敗理由」——この2層構造が実務の定石だ。
命名規則
| ルール | 例 | 理由 |
|---|---|---|
| SCREAMING_SNAKE_CASE | USER_NOT_FOUND | 定数として認識しやすく、ログ検索もしやすい |
| ドメイン_理由の2段以上 | PAYMENT_INSUFFICIENT_FUNDS | 名前だけで所属コンテキストが分かる |
| 動詞より名詞・状態 | EMAIL_ALREADY_EXISTS(○) / CREATE_USER_FAILED(△) | 状態ベースの方が分岐しやすい |
| HTTPステータスと1対1にしない | 422でも複数コード | 同ステータス内のUI分岐に使う |
| 削除・改名を避ける | 非推奨マークで残す | 外部公開APIでは破壊的変更になる |
名前空間の例
# 認証・認可
AUTH_TOKEN_EXPIRED
AUTH_TOKEN_INVALID
AUTH_INSUFFICIENT_SCOPE
# バリデーション(400系)
VALIDATION_REQUIRED_FIELD
VALIDATION_INVALID_FORMAT
VALIDATION_OUT_OF_RANGE
# ビジネスルール(422系)
ORDER_INSUFFICIENT_STOCK
ORDER_COUPON_EXPIRED
ORDER_MINIMUM_AMOUNT_NOT_MET
# 状態競合(409系)
RESOURCE_VERSION_CONFLICT
RESOURCE_ALREADY_EXISTS
IDEMPOTENCY_KEY_REUSED
# サーバー(5xx系 — クライアント分岐用は最小限)
INTERNAL_ERROR
DEPENDENCY_UNAVAILABLE
フロントはでUIを切り替え、はフォールバック表示に使う。ログやアラートは(リクエストID)とセットで追跡する。エラー一覧ドキュメントには、各コードに対して推奨HTTPステータス・再試行可否・ユーザー向けメッセージキー(i18n)を表形式で載せておくと、フロント・バックエンド・QAが同じ資料を参照できる。
400・422・409の使い分け決定ツリー
この3つは「クライアントが送ったデータに問題がある」ように見えるが、失敗の段階と性質が異なる。以下の決定ツリーに沿って選ぶ。
flowchart TD
A[リクエスト受信] --> B{JSON/Content-Typeは<br/>正しくパースできるか}
B -->|No| C["400 Bad Request<br/>error_code: VALIDATION_*"]
B -->|Yes| D{スキーマ上の型・<br/>必須・形式は満たすか}
D -->|No| C
D -->|Yes| E{リソースの現在状態と<br/>リクエストは矛盾するか}
E -->|Yes| F["409 Conflict<br/>error_code: RESOURCE_*"]
E -->|No| G{ビジネスルール上<br/>処理できないか}
G -->|Yes| H["422 Unprocessable Content<br/>error_code: ORDER_* 等"]
G -->|No| I[処理続行 → 2xx]
400 Bad Request — パース・構造レベルの失敗
JSON構文エラー、必須フィールド欠落、型不一致(文字列を数値フィールドに)、enum外の値、日付フォーマット不正——リクエストボディをドメインロジックに渡す前に弾けるものすべて。
{
"status": 400,
"error_code": "VALIDATION_REQUIRED_FIELD",
"detail": "name フィールドは必須です",
"errors": [{ "field": "name", "message": "必須項目です" }]
}
422 Unprocessable Content — 形式は正しいが処理できない
パースもスキーマ検証も通過したが、ビジネスルール上実行不可能なケース。在庫不足、クーポン期限切れ、最低注文金額未満、年齢制限——サーバーが「内容は理解したが、ルール上お断りする」と返すとき。
{
"status": 422,
"error_code": "ORDER_MINIMUM_AMOUNT_NOT_MET",
"detail": "最低注文金額は3,000円です。現在の合計: 1,500円"
}
409 Conflict — リソース状態との矛盾
楽観ロック(If-Match / versionフィールド)の不一致、一意制約違反(メールアドレス重複)、べき等キーの再利用、削除済みリソースへの更新——現在のサーバー状態とリクエストが両立しないとき。
{
"status": 409,
"error_code": "RESOURCE_VERSION_CONFLICT",
"detail": "他のユーザーが先に更新しました。最新データを取得してから再試行してください。",
"meta": { "current_version": 7, "your_version": 5 }
}
「同じ入力を状態が変われば成功する」なら422(在庫が補充されれば注文可能)。「同じ入力をいつ送っても失敗する」なら409(メール重複)または422(クーポン期限切れ)——期限切れは状態依存なので422、一意制約は409が自然、と覚えると迷いにくい。
開発環境と本番でのスタックトレース秘匿
開発中はスタックトレースやSQL文があればデバッグが速い。本番で同じ情報を返すと情報漏洩のリスクがある。ファイルパス、ORMのクエリ、内部ホスト名、ライブラリのバージョン——これらは攻撃者の reconnaissance に使われる。
環境別レスポンス方針
| 項目 | 開発(NODE_ENV=development) | 本番 |
|---|---|---|
detail | 例外メッセージ・行番号 | ユーザーが取れる行動を示す汎用文 |
debug.stack_trace | 含める | 絶対に含めない |
debug.sql | 含めてよい | 含めない |
instance | リクエストID | リクエストID(ログ紐づけ用) |
| サーバーログ | フルスタック | フルスタック+構造化JSON |
本番の500レスポンス例:
{
"type": "https://api.example.com/errors/internal",
"title": "Internal Server Error",
"status": 500,
"detail": "予期しないエラーが発生しました。しばらくしてから再試行してください。問題が続く場合はサポートに instance ID をお知らせください。",
"instance": "https://api.example.com/requests/req_9c4d5e6f",
"error_code": "INTERNAL_ERROR"
}
開発環境のみdebug拡張フィールドを追加:
{
"status": 500,
"error_code": "INTERNAL_ERROR",
"detail": "Cannot read properties of null (reading 'id')",
"instance": "/requests/req_dev_001",
"debug": {
"exception": "TypeError",
"stack_trace": [
"at UserService.getProfile (src/services/user.ts:142:18)",
"at OrderController.create (src/controllers/order.ts:58:11)"
],
"cause": "user record was null for id=999"
}
}
サーバー側の実装パターン(Node.js / Express)
// errorHandler.ts — 本番では debug を strip する
function toProblemDetails(err: AppError, req: Request): ProblemDetails {
const isProd = process.env.NODE_ENV === 'production';
const base: ProblemDetails = {
type: err.typeUri,
title: err.title,
status: err.statusCode,
detail: isProd ? err.publicMessage : err.message,
instance: `${process.env.API_BASE_URL}/requests/${req.id}`,
error_code: err.code,
};
if (!isProd && err.stack) {
(base as ProblemDetails & { debug?: unknown }).debug = {
exception: err.name,
stack_trace: err.stack.split('\n'),
};
}
// 本番の500は必ずログにフル情報を書く(レスポンスには載せない)
if (err.statusCode >= 500) {
logger.error({ err, requestId: req.id, stack: err.stack }, 'unhandled error');
}
return base;
}
本番のdetailは「再ログインしてください」「入力を確認してください」「しばらく待って再試行してください」のように、ユーザーが次に取れる行動を示す。技術的な原因はサーバーログ、APM(Datadog、Sentry等)、instanceとの紐づけだけで追跡する。
TypeScriptクライアントSDKでのエラーパース実装
クライアント側もProblem Details形式を前提にパーサーを1つ用意すると、全API呼び出しで統一されたエラーハンドリングができる。以下はfetchベースの最小SDK例。
// types/problem-details.ts
export interface FieldError {
field: string;
message: string;
rejected_value?: unknown;
constraint?: string;
}
export interface ProblemDetails {
type?: string;
title?: string;
status: number;
detail?: string;
instance?: string;
error_code?: string;
errors?: FieldError[];
meta?: Record<string, unknown>;
}
// errors/api-error.ts
export class ApiError extends Error {
readonly status: number;
readonly errorCode: string;
readonly problem: ProblemDetails;
readonly requestId: string | undefined;
readonly fieldErrors: FieldError[];
readonly isRetryable: boolean;
constructor(problem: ProblemDetails) {
super(problem.detail ?? problem.title ?? 'API Error');
this.name = 'ApiError';
this.status = problem.status;
this.errorCode = problem.error_code ?? 'UNKNOWN_ERROR';
this.problem = problem;
this.requestId = extractRequestId(problem.instance);
this.fieldErrors = problem.errors ?? [];
this.isRetryable = [408, 429, 500, 502, 503, 504].includes(problem.status);
}
isAuthError(): boolean {
return this.status === 401 || this.errorCode.startsWith('AUTH_');
}
isValidationError(): boolean {
return this.status === 400 || this.status === 422;
}
isConflict(): boolean {
return this.status === 409;
}
getFieldError(field: string): string | undefined {
return this.fieldErrors.find((e) => e.field === field)?.message;
}
}
function extractRequestId(instance?: string): string | undefined {
if (!instance) return undefined;
const match = instance.match(/req_[a-z0-9]+/i);
return match?.[0];
}
// client/api-client.ts
export async function apiRequest<T>(
path: string,
init?: RequestInit,
): Promise<T> {
const res = await fetch(`${BASE_URL}${path}`, {
...init,
headers: {
'Content-Type': 'application/json',
Accept: 'application/problem+json, application/json',
...init?.headers,
},
});
const contentType = res.headers.get('content-type') ?? '';
const isProblemJson = contentType.includes('application/problem+json');
if (!res.ok) {
let problem: ProblemDetails;
if (isProblemJson) {
problem = (await res.json()) as ProblemDetails;
problem.status = problem.status ?? res.status;
} else {
// レガシーAPIやプロキシエラーのフォールバック
problem = {
status: res.status,
detail: await res.text().catch(() => res.statusText),
error_code: 'HTTP_ERROR',
};
}
throw new ApiError(problem);
}
if (res.status === 204) return undefined as T;
return res.json() as Promise<T>;
}
UI層での使い方
try {
await apiRequest('/orders', { method: 'POST', body: JSON.stringify(cart) });
} catch (e) {
if (!(e instanceof ApiError)) throw e;
switch (e.errorCode) {
case 'ORDER_INSUFFICIENT_STOCK':
toast.error(`在庫不足: ${e.problem.meta?.available_quantity ?? '?'}個まで`);
break;
case 'AUTH_TOKEN_EXPIRED':
redirectToLogin();
break;
case 'RESOURCE_VERSION_CONFLICT':
await refreshAndRetry();
break;
default:
if (e.isValidationError()) {
setFormErrors(Object.fromEntries(
e.fieldErrors.map((fe) => [fe.field, fe.message]),
));
} else if (e.isRetryable) {
toast.error('一時的なエラーです。再試行してください。');
} else {
toast.error(`${e.message} (ID: ${e.requestId ?? '不明'})`);
}
}
}
error_codeでビジネスロジック分岐、fieldErrorsでフォーム表示、isRetryableでリトライ制御、requestIdでサポート連携——サーバー設計と対になるクライアント設計だ。
サーバー側:グローバルエラーハンドラと例外階層
エンドポイントごとにエラーJSONを手書きすると形式がブレる。例外クラス+グローバルハンドラで統一する。
// errors/app-error.ts
export class AppError extends Error {
constructor(
public readonly statusCode: number,
public readonly code: string,
public readonly publicMessage: string,
public readonly typeUri: string,
public readonly title: string,
public readonly errors?: FieldError[],
public readonly meta?: Record<string, unknown>,
) {
super(publicMessage);
}
static badRequest(code: string, detail: string, errors?: FieldError[]) {
return new AppError(400, code, detail,
'https://api.example.com/errors/validation', 'Bad Request', errors);
}
static unprocessable(code: string, detail: string, meta?: Record<string, unknown>) {
return new AppError(422, code, detail,
`https://api.example.com/errors/${code.toLowerCase()}`, 'Unprocessable Content', undefined, meta);
}
static conflict(code: string, detail: string, meta?: Record<string, unknown>) {
return new AppError(409, code, detail,
'https://api.example.com/errors/conflict', 'Conflict', undefined, meta);
}
}
予期しない例外はハンドラの最後で500に変換し、必ずログに記録する。4xx系のAppErrorはwarn、5xxはerrorレベル——ログのノイズも制御できる。
実装チェックリスト
エラー一覧ドキュメントを用意し、type URI・error_code・推奨HTTPステータス・再試行可否を対応表にまとめる
4xx(入力・認証・権限・競合)と5xx(想定外・依存障害)の境界をチームで合意する
RFC 9457形式(type, title, status, detail, instance)を全エンドポイントで統一する
400/422/409の決定ツリーをバリデーション層とドメイン層の責務分界に反映する
error_codeをSCREAMING_SNAKE_CASEで定義し、外部公開後の改名・削除を避ける
本番ではスタックトレース・SQL・内部パスをレスポンスから除外し、instanceでログと紐づける
503/429時はRetry-Afterヘッダーを付与する
TypeScript SDKにApiErrorクラスとProblemDetails型を実装し、error_codeでUI分岐する
関連記事
この記事は API・バックエンド テーマの一環です。あわせて読むと理解が深まる関連記事をまとめました。
| トピック | 記事 |
|---|---|
| APIキャッシュ設計ガイド | APIキャッシュ設計ガイド|ETag・Cache-Control・304・CDNキャッシュキーの実務 |
| Web APIのべき等性(Idempotency)設計 | Web APIのべき等性(Idempotency)設計|二重決済を防ぐ実装ガイド |
| API設計におけるページネーションの使い分け:オフセット型とカーソル型の比較 | API設計におけるページネーションの使い分け:オフセット型とカーソル型の比較 |
| APIのレート制限(Rate Limiting)実装ガイド | APIのレート制限(Rate Limiting)実装ガイド|アルゴリズム選定と429エラーの適切なハンドリング |
| APIレート制限と429対応 | APIレート制限と429対応|X-RateLimit・Retry-Afterを使ったクライアント実装 |
| WebHook設計のベストプラクティス | WebHook設計のベストプラクティス|署名・再試行・べき等性で信頼性を担保する |
| ZodでAPI境界のランタイム検証を設計する | ZodでAPI境界のランタイム検証を設計する|z.infer・Expressミドルウェア・zod-to-openapi(2026) |
| OpenAPI 3.1仕様のCIバリデーション完全ガイド | OpenAPI 3.1仕様のCIバリデーション完全ガイド|Spectral Lint・Breaking Change検出・GitHub Actions |
| GraphQL N+1 問題の解決 | GraphQL N+1 問題の解決|DataLoader バッチング実装と Apollo Server 完全ガイド |
| Circuit Breaker + Retry パターン実装ガイド | Circuit Breaker + Retry パターン実装ガイド|Node.js opossum/cockatiel・指数バックオフ・Jitter |
| Sagaパターン完全ガイド | Sagaパターン完全ガイド|Orchestration vs Choreography と補償トランザクション(Node.js) |
| Event SourcingとCQRSの基礎 | Event SourcingとCQRSの基礎|Node.js + PostgreSQL 実装ガイド |
まとめ
良いエラーレスポンスは「何が起きたか」だけでなく誰が直すか・次に何をすべきかを伝える。
- 4xxと5xx — クライアント修正 vs サーバー調査(HTTPステータスコード一覧参照)
- RFC 9457 Problem Details —
application/problem+jsonで形式を統一 - error_code — SCREAMING_SNAKE_CASEで機械可読、HTTPステータスと独立
- 400 / 422 / 409 — パース失敗 / ビジネスルール違反 / 状態競合
- 本番秘匿 — スタックトレースはログだけ、レスポンスは
instance+汎用メッセージ - TypeScript SDK —
ApiErrorでerror_code・fieldErrors・isRetryableを一元処理
新規APIを設計するときは、成功レスポンスのOpenAPIスキーマと並べてエラースキーマも先に決めるのが最短ルートだ。エラー形式が揃っていれば、フロント・バックエンド・運用・サポートが同じ言語で障害に対応でき、APIの信頼性が一段上がる。