- API
APIエラーレスポンス設計|RFC 9457準拠のJSON例と実装コード
APIエラーレスポンスの設計とは、「失敗したときに、クライアントが次に何をすればよいか判断できる形」で、HTTPステータスコードと本文(JSON)をそろえて返すルールを決めることです。 現在の標準は RFC 9457「Problem Details for HTTP APIs」(2023年7月発行、RFC 7807 を置き換え)で、Content-Type: application/problem+json と type・title・status・detail・instance の5つのメンバーを基本にします。
この記事では、RFC 9457 の原文で確認した仕様を早見表にまとめたうえで、400/401/403/404/409/422/429/500/503 のコピペ用 JSON テンプレート、ステータスコードの選び方判断表、エラーコード体系の設計表を用意しました。さらに FastAPI 0.142.2 と Express 5.2.1 で同じ API を実装し、curl で実際に返ってきたレスポンスをそのまま載せています。フレームワーク標準のエラー出力(直す前)と、Problem Details にそろえた後の違いも実行結果で比べられます。
APIエラーレスポンス設計の早見表
| 決めること | おすすめの決め方(RFC 9457 準拠) | 例 |
|---|---|---|
| メディアタイプ | application/problem+json |
Content-Type: application/problem+json |
| 問題の種類 | type(URI)。省略時は about:blank |
https://api.example.com/problems/validation-error |
| 短い要約 | title。同じ type なら毎回同じ文言 |
入力内容に誤りがあります |
| ステータス | status(数値)。HTTP のステータスと必ず一致 |
422 |
| 今回の詳細 | detail。利用者が直し方をわかる文 |
2 件の項目を確認してください。 |
| 発生箇所 | instance(URI 参照) |
/tasks |
| 機械向けコード | 拡張メンバー code(独自) |
VALIDATION_FAILED |
| 項目ごとのエラー | 拡張メンバー errors 配列(pointer で項目を指す) |
[{"pointer":"#/title", ...}] |
| 問い合わせ用ID | 拡張メンバー trace_id +レスポンスヘッダー |
X-Trace-Id: 6e91... |
| 再試行の目安 | Retry-After ヘッダー(429/503) |
Retry-After: 10 |
| 認証方式の案内 | WWW-Authenticate ヘッダー(401) |
Bearer realm="api" |
| 返さないもの | スタックトレース、SQL、内部パス、ライブラリのバージョン | ログにだけ出す |
| ステータス | 使う場面(一言) | code の例 |
追加ヘッダー |
|---|---|---|---|
| 400 | JSON が壊れている・形式として読めない | MALFORMED_JSON |
― |
| 401 | 認証情報がない・無効 | AUTH_REQUIRED / AUTH_INVALID_TOKEN |
WWW-Authenticate |
| 403 | 認証済みだが権限がない | PERMISSION_DENIED |
― |
| 404 | 対象のリソースがない | TASK_NOT_FOUND |
― |
| 409 | 今の状態と衝突(重複・版の不一致) | TASK_TITLE_DUPLICATED |
― |
| 422 | 読めたが値が業務ルールに合わない | VALIDATION_FAILED |
― |
| 429 | 回数制限を超えた | RATE_LIMITED |
Retry-After |
| 500 | サーバー側の想定外の失敗 | INTERNAL_ERROR |
― |
| 503 | 一時的に受け付けられない(メンテナンス・過負荷) | MAINTENANCE |
Retry-After |
本記事の内容は、ご自由にお使いください。
ご利用の際は、出典として本ページへのリンクを記載いただけますようお願いします。
(記載例)出典:株式会社RJC「APIエラーレスポンス設計|RFC 9457準拠のJSON例と実装コード」
RFC 9457 の Problem Details とは
Problem Details は「HTTP API のエラー本文の共通フォーマット」で、独自形式を毎回考えなくてよくなるのが最大の利点です。 クライアントは Content-Type が application/problem+json なら同じパーサーで読めるので、API ごとにエラー処理を書き分ける必要が減ります。
5つの標準メンバー(RFC 原文の要点)
RFC 9457 の 3.1 節で定義されたメンバーを、原文の意図に沿ってまとめます。
| メンバー | 型 | 意味 | 原文で押さえておきたい点 |
|---|---|---|---|
type |
文字列(URI 参照) | 問題の種類を表す主な識別子 | 省略時は "about:blank"。相対 URI は混乱のもとなので絶対 URI を推奨。たどれる URL なら人が読める説明ページを置くとよい |
status |
数値 | HTTP ステータスコード | 参考情報。実際のレスポンスのステータスと一致させる(プロキシなど汎用ソフトは本文ではなくヘッダーのステータスを見る) |
title |
文字列 | 問題の種類の短い要約 | 同じ type なら発生ごとに変えない(翻訳は可) |
detail |
文字列 | 今回の発生についての説明 | クライアントが問題を直す助けになる内容にする。デバッグ情報は入れない |
instance |
文字列(URI 参照) | 今回の発生を指す識別子 | たどれる場合は詳細情報を返してもよい。相対 URI の注意は type と同じ |
about:blank は「ステータスコード以上の意味はない」という印です。この場合 title は HTTP の標準フレーズ(Not Found など)にそろえるのが基本です。ルートが存在しない 404 や 405 のように、業務的な意味づけが不要なエラーに使うと便利です。
拡張メンバーと複数エラーの表し方
5つで足りない情報は「拡張メンバー」として同じ階層に追加できます。 RFC 9457 の 3 節には、入力検証エラーを errors 配列で返す例が載っています(原文の例をそのまま引用)。
{
"type": "https://example.net/validation-error",
"title": "Your request is not valid.",
"errors": [
{
"detail": "must be a positive integer",
"pointer": "#/age"
},
{
"detail": "must be 'green', 'red' or 'blue'",
"pointer": "#/profile/color"
}
]
}
拡張メンバーの名前は、英字で始め、英数字とアンダースコアで3文字以上にすると、XML など他の形式に変換しやすいとされています(trace_id・code・errors はこの条件を満たします)。また、クライアントは知らない拡張メンバーを無視する決まりなので、後からメンバーを足しても既存クライアントは動き続けます。
なお、種類の違う問題が同時に起きた場合は、もっとも重要・緊急なもの1つを返すのが RFC 9457 の方針です(1つのレスポンスに 401 と 404 を混ぜない)。同じ種類の問題が複数ある入力検証は、上の例のように1つの type の中で errors 配列にまとめます。
RFC 7807 との違い
JSON の形は RFC 7807 から変わっていないので、7807 準拠の API はそのまま 9457 準拠として扱えます。 RFC 9457 の付録 D に挙げられた変更点は次の3つです。
| 変更点 | 内容 | 設計への影響 |
|---|---|---|
| 共通の問題タイプのレジストリ | IANA に「HTTP Problem Types」レジストリを新設(about:blank などを登録) |
汎用的な問題は登録済みの type を使える |
| 複数の問題の扱いを明確化 | 異なる種類の問題は最重要の1つを返す | 1レスポンスに複数種類を詰め込まない |
たどれない type URI の扱い |
tag: URI など、アクセスできない URI の使い方を案内 |
ドキュメントを用意できなくても URI で種類を区別できる |
ステータスコードの選び方判断表
ステータスコードは「クライアントが次にとる行動が変わるか」で選ぶと迷いません。 コード一覧の詳しい説明は既存記事「HTTPステータスコード 一覧」、401 と 403 の線引きは「401と403の違い」で扱っているため、ここではエラー設計で迷いやすい分かれ目だけに絞ります。
| 状況 | 返すステータス | 判断の理由 |
|---|---|---|
JSON の構文が壊れている({"title":"a", で終わる等) |
400 | 中身を解釈する前の段階で失敗している |
| JSON は読めたが、必須項目がない・範囲外 | 422 | 形式は正しく、内容が受け付けられない(FastAPI の既定も 422) |
| トークンがない・期限切れ | 401 | 認証し直せば成功する可能性がある |
| ログイン済みだが権限が足りない | 403 | 認証し直しても結果は変わらない |
| 他人のリソースで、存在自体を知られたくない | 404 | 存在の有無を推測されないようにする |
| ID のリソースがない | 404 | 対象がない |
| 同じタイトル・メールアドレスが既にある | 409 | 現在の状態との衝突。内容を変えれば成功する |
| 楽観ロックの版番号が古い | 409(If-Match を使う場合は 412) |
最新を取り直してから再送する |
| 回数制限を超えた | 429 | 待てば成功する。Retry-After を付ける |
| 想定外の例外 | 500 | クライアント側では直せない |
| メンテナンス中・依存先が一時停止 | 503 | 一時的。Retry-After で再開の目安を伝える |
400 と 422 は「統一ルールを決めて文書化する」ことが大切です。 どちらかに寄せる設計も見かけますが、本記事のように「構文エラーは 400、値の誤りは 422」と分けておくと、クライアントは 422 のときだけ errors 配列を画面の各項目に表示する、という分岐が書きやすくなります。
エラーコード体系の設計表
type は「種類」、code は「クライアントが分岐に使う細かい理由」と役割を分けると整理しやすくなります。 type は数を増やしすぎず、同じ 401 でも「トークンなし」と「期限切れ」で画面の動きを変えたい場合は code で区別します。
| 設計項目 | おすすめ | 理由 |
|---|---|---|
| 書式 | 英大文字のスネークケース(TASK_NOT_FOUND) |
ログ検索・定数化しやすい |
| 構成 | {対象}_{状態}(TASK_TITLE_DUPLICATED) |
何に対するエラーか一目でわかる |
| 共通コード | VALIDATION_FAILED AUTH_REQUIRED PERMISSION_DENIED RATE_LIMITED INTERNAL_ERROR |
どの API でも同じ意味にする |
| 番号を使うか | 使うなら E40901 のように先頭をステータスに合わせる |
文字列コードと併記すると読みやすい |
| 変更ルール | 一度公開したコードの意味は変えない。廃止は告知してから | クライアントの分岐が壊れない |
| 管理場所 | OpenAPI の components とエラーコード表を1か所で管理 |
実装とドキュメントのずれを防ぐ |
type URI |
https://api.example.com/problems/{slug} に説明ページ |
開発者が URL から原因と対処を読める |
エラーコード一覧表(テンプレート)
| code | status | type の slug | 意味 | クライアントの対処 |
|---|---|---|---|---|
MALFORMED_JSON |
400 | malformed-json |
JSON として読めない | 送信処理を修正 |
VALIDATION_FAILED |
422 | validation-error |
入力値の誤り | errors を項目ごとに表示 |
AUTH_REQUIRED |
401 | unauthorized |
認証情報なし | ログイン画面へ |
AUTH_INVALID_TOKEN |
401 | unauthorized |
トークン無効・期限切れ | リフレッシュ後に再送 |
PERMISSION_DENIED |
403 | forbidden |
権限不足 | 操作ボタンを無効化 |
TASK_NOT_FOUND |
404 | not-found |
タスクが存在しない | 一覧へ戻す |
TASK_TITLE_DUPLICATED |
409 | conflict |
タイトル重複 | 入力欄にメッセージ |
RATE_LIMITED |
429 | rate-limited |
回数制限超過 | Retry-After 秒待って再送 |
INTERNAL_ERROR |
500 | internal-error |
想定外の失敗 | trace_id を表示して問い合わせへ |
MAINTENANCE |
503 | maintenance |
メンテナンス中 | 再開予定を表示 |
ステータス別 Problem Details JSON テンプレート
以下は FastAPI・Express で実装して実際に返ってきた本文を、読みやすく整形したものです。 trace_id は実行ごとに変わります。そのまま設計書やOpenAPI の例に貼って使えます。
400 Bad Request(JSON が壊れている)
{
"type": "https://api.example.com/problems/malformed-json",
"title": "Bad Request",
"status": 400,
"detail": "リクエストボディが JSON として解釈できません。",
"instance": "/tasks",
"code": "MALFORMED_JSON",
"trace_id": "bf9dc30510cf43c490c1d8732ffaef99"
}
401 Unauthorized(認証情報なし)
{
"type": "https://api.example.com/problems/unauthorized",
"title": "Unauthorized",
"status": 401,
"detail": "認証情報がありません。Authorization ヘッダーにトークンを指定してください。",
"instance": "/tasks/1",
"code": "AUTH_REQUIRED",
"trace_id": "82f27b2a60e2487a9a03d1f984da8edf"
}
ヘッダーに WWW-Authenticate: Bearer realm="api" を付けます(トークン無効のときは Bearer error="invalid_token")。
403 Forbidden(権限不足)
{"type": "https://api.example.com/problems/forbidden", "title": "Forbidden", "status": 403, "detail": "タスクの削除には管理者権限が必要です。", "instance": "/tasks/1", "code": "PERMISSION_DENIED", "trace_id": "191cab928e414f45bea2b8630ec1578e"}
404 Not Found(リソースなし/ルートなし)
{"type": "https://api.example.com/problems/not-found", "title": "Not Found", "status": 404, "detail": "タスク 999 は見つかりません。", "instance": "/tasks/999", "code": "TASK_NOT_FOUND", "trace_id": "3d46e993de4340bb8f03845cbb387362"}
ルート自体が存在しない場合は about:blank を使います。
{"type":"about:blank","title":"Not Found","status":404,"detail":"Not Found","instance":"/nothing","trace_id":"63a9d132f83741c6800ea47d3845a9e8"}
409 Conflict(重複)
{"type": "https://api.example.com/problems/conflict", "title": "Conflict", "status": 409, "detail": "同じタイトルのタスク「備品の棚卸し」が既にあります。", "instance": "/tasks", "code": "TASK_TITLE_DUPLICATED", "trace_id": "182ea84d036e4236af55c4cd803faf94"}
422 Unprocessable Content(入力検証エラー・複数項目)
{
"type": "https://api.example.com/problems/validation-error",
"title": "入力内容に誤りがあります",
"status": 422,
"detail": "2 件の項目を確認してください。",
"instance": "/tasks",
"code": "VALIDATION_FAILED",
"errors": [
{"pointer": "#/title", "code": "string_too_short", "detail": "String should have at least 1 character"},
{"pointer": "#/priority", "code": "less_than_equal", "detail": "Input should be less than or equal to 5"}
],
"trace_id": "6e91060913d84cf4967f5bbce35f261b"
}
pointer は RFC 9457 の例と同じく、JSON Pointer(RFC 6901)にフラグメントの # を付けた形です。
429 Too Many Requests(回数制限)
{"type": "https://api.example.com/problems/rate-limited", "title": "Too Many Requests", "status": 429, "detail": "リクエストが多すぎます。10 秒後に再試行してください。", "instance": "/search", "code": "RATE_LIMITED", "retry_after": 10, "trace_id": "f788e928ea6549fc93b4ba23181429b2"}
500 Internal Server Error(想定外)
{"type": "https://api.example.com/problems/internal-error", "title": "Internal Server Error", "status": 500, "detail": "サーバー内部でエラーが発生しました。時間をおいて再度お試しください。", "instance": "/boom", "code": "INTERNAL_ERROR", "trace_id": "b5070e62f16247ad87bafd59375862b6"}
503 Service Unavailable(メンテナンス中)
{"type": "https://api.example.com/problems/maintenance", "title": "Service Unavailable", "status": 503, "detail": "メンテナンス中です。終了予定まで時間をおいてアクセスしてください。", "instance": "/tasks/1", "code": "MAINTENANCE", "trace_id": "94ed65d0ce87467b84c2fe02efb3c7f4"}
ヘッダーに Retry-After: 120(秒)を付けています。
FastAPI で Problem Details を返す実装
FastAPI では「業務エラー用の例外」「入力検証エラー」「フレームワークの HTTP エラー」「想定外の例外」の4つを1つの関数に集めると、どこで失敗しても同じ形式になります。
直す前:FastAPI 標準のエラー出力
何も設定しないと、入力検証エラーは次のように返ります(FastAPI 0.142.2 で実行)。
HTTP/1.1 422 Unprocessable Entity
content-type: application/json
{"detail":[{"type":"string_too_short","loc":["body","title"],"msg":"String should have at least 1 character","input":"","ctx":{"min_length":1}},{"type":"less_than_equal","loc":["body","priority"],"msg":"Input should be less than or equal to 5","input":9,"ctx":{"le":5}}]}
Content-Type が application/json で、detail が配列になっています。存在しないルートは {"detail":"Not Found"} と文字列なので、同じ detail でも型が変わる点にクライアント側で注意が必要です。また、送信された値が input としてそのまま返るため、パスワードなどを受け取る項目では表示内容を確認しておくと安心です。
直した後:完成コード
環境:Python 3.11.15、fastapi 0.142.2、pydantic 2.13.5、starlette 1.7.0、uvicorn 0.54.0。
python3 -m venv .venv && . .venv/bin/activate
pip install fastapi uvicorn
uvicorn main:app --port 7960
# main.py (Python 3.11 / FastAPI 0.142.2 / Pydantic 2.13.5)
import logging
import time
import uuid
from collections import defaultdict, deque
from http import HTTPStatus
from fastapi import FastAPI, Header, Request
from fastapi.exceptions import RequestValidationError
from fastapi.responses import JSONResponse
from pydantic import BaseModel, Field
from starlette.exceptions import HTTPException as StarletteHTTPException
logging.basicConfig(level=logging.INFO, format="%(levelname)s %(message)s")
log = logging.getLogger("api")
PROBLEM_BASE = "https://api.example.com/problems/"
MEDIA_TYPE = "application/problem+json"
app = FastAPI()
# ---------- Problem Details を作る共通部品 ----------
class ProblemError(Exception):
"""業務エラーを投げるための例外。ハンドラーで Problem Details に変換する。"""
def __init__(self, status: int, slug: str, title: str, detail: str,
headers: dict | None = None, **ext):
self.status, self.slug, self.title, self.detail = status, slug, title, detail
self.headers = headers or {}
self.ext = ext # 拡張メンバー(code, errors など)
def problem_response(request: Request, status: int, slug: str | None, title: str,
detail: str, headers: dict | None = None, **ext) -> JSONResponse:
trace_id = request.state.trace_id
body = {
"type": PROBLEM_BASE + slug if slug else "about:blank",
"title": title,
"status": status,
"detail": detail,
"instance": request.url.path,
**ext,
"trace_id": trace_id,
}
return JSONResponse(body, status_code=status, media_type=MEDIA_TYPE,
headers={"X-Trace-Id": trace_id, **(headers or {})})
@app.middleware("http")
async def add_trace_id(request: Request, call_next):
request.state.trace_id = uuid.uuid4().hex
try:
return await call_next(request)
except Exception:
# 想定外の例外:詳細はログだけに残し、レスポンスには出さない
log.exception("unhandled error trace_id=%s path=%s",
request.state.trace_id, request.url.path)
return problem_response(request, 500, "internal-error",
"Internal Server Error",
"サーバー内部でエラーが発生しました。時間をおいて再度お試しください。",
code="INTERNAL_ERROR")
@app.exception_handler(ProblemError)
async def handle_problem(request: Request, exc: ProblemError):
log.info("problem trace_id=%s status=%s type=%s", request.state.trace_id, exc.status, exc.slug)
return problem_response(request, exc.status, exc.slug, exc.title, exc.detail,
exc.headers, **exc.ext)
@app.exception_handler(StarletteHTTPException)
async def handle_http(request: Request, exc: StarletteHTTPException):
# ルートが無い 404 や 405 など、フレームワークが出すエラーも同じ形式にそろえる
phrase = HTTPStatus(exc.status_code).phrase
return problem_response(request, exc.status_code, None, # about:blank = ステータスコード以上の意味を持たない
phrase, str(exc.detail), exc.headers)
@app.exception_handler(RequestValidationError)
async def handle_validation(request: Request, exc: RequestValidationError):
errs = exc.errors()
# JSON として読めない → 400、形式は読めたが値が不正 → 422
if any(e["type"] == "json_invalid" for e in errs):
return problem_response(request, 400, "malformed-json", "Bad Request",
"リクエストボディが JSON として解釈できません。",
code="MALFORMED_JSON")
items = []
for e in errs:
loc = [str(p) for p in e["loc"] if p != "body"]
items.append({"pointer": "#/" + "/".join(loc), "code": e["type"],
"detail": e["msg"]})
return problem_response(request, 422, "validation-error",
"入力内容に誤りがあります",
f"{len(items)} 件の項目を確認してください。",
code="VALIDATION_FAILED", errors=items)
# ---------- サンプルの業務ロジック(タスク管理) ----------
TOKENS = {"token-member": "member", "token-admin": "admin"}
TASKS = {1: {"id": 1, "title": "備品の棚卸し", "priority": 3}}
HITS: dict[str, deque] = defaultdict(deque)
LIMIT, WINDOW = 3, 10 # 10 秒に 3 回まで
class TaskIn(BaseModel):
title: str = Field(min_length=1, max_length=50)
priority: int = Field(ge=1, le=5)
def current_role(authorization: str | None) -> str:
if not authorization or not authorization.startswith("Bearer "):
raise ProblemError(401, "unauthorized", "Unauthorized",
"認証情報がありません。Authorization ヘッダーにトークンを指定してください。",
headers={"WWW-Authenticate": 'Bearer realm="api"'},
code="AUTH_REQUIRED")
role = TOKENS.get(authorization.removeprefix("Bearer "))
if role is None:
raise ProblemError(401, "unauthorized", "Unauthorized",
"トークンが無効か、有効期限が切れています。",
headers={"WWW-Authenticate": 'Bearer error="invalid_token"'},
code="AUTH_INVALID_TOKEN")
return role
@app.get("/tasks/{task_id}")
def get_task(task_id: int, authorization: str | None = Header(default=None)):
current_role(authorization)
if task_id not in TASKS:
raise ProblemError(404, "not-found", "Not Found",
f"タスク {task_id} は見つかりません。",
code="TASK_NOT_FOUND")
return TASKS[task_id]
@app.post("/tasks", status_code=201)
def create_task(task: TaskIn, authorization: str | None = Header(default=None)):
current_role(authorization)
if any(t["title"] == task.title for t in TASKS.values()):
raise ProblemError(409, "conflict", "Conflict",
f"同じタイトルのタスク「{task.title}」が既にあります。",
code="TASK_TITLE_DUPLICATED")
new_id = max(TASKS) + 1
TASKS[new_id] = {"id": new_id, **task.model_dump()}
return TASKS[new_id]
@app.delete("/tasks/{task_id}", status_code=204)
def delete_task(task_id: int, authorization: str | None = Header(default=None)):
if current_role(authorization) != "admin":
raise ProblemError(403, "forbidden", "Forbidden",
"タスクの削除には管理者権限が必要です。",
code="PERMISSION_DENIED")
TASKS.pop(task_id, None)
@app.get("/search")
def search(request: Request, q: str = ""):
key = request.client.host
now = time.monotonic()
hits = HITS[key]
while hits and now - hits[0] > WINDOW:
hits.popleft()
if len(hits) >= LIMIT:
retry = int(WINDOW - (now - hits[0])) + 1
raise ProblemError(429, "rate-limited", "Too Many Requests",
f"リクエストが多すぎます。{retry} 秒後に再試行してください。",
headers={"Retry-After": str(retry)},
code="RATE_LIMITED", retry_after=retry)
hits.append(now)
return {"q": q, "items": []}
@app.get("/boom")
def boom():
{}["missing"] # わざと KeyError を起こす
curl で確認した実行結果
B=http://127.0.0.1:7960
curl -s -i -X POST $B/tasks -H 'Authorization: Bearer token-member' \
-H 'Content-Type: application/json' -d '{"title":"","priority":9}'
HTTP/1.1 422 Unprocessable Entity
server: uvicorn
x-trace-id: 6e91060913d84cf4967f5bbce35f261b
content-type: application/problem+json
{"type":"https://api.example.com/problems/validation-error","title":"入力内容に誤りがあります","status":422,"detail":"2 件の項目を確認してください。","instance":"/tasks","code":"VALIDATION_FAILED","errors":[{"pointer":"#/title","code":"string_too_short","detail":"String should have at least 1 character"},{"pointer":"#/priority","code":"less_than_equal","detail":"Input should be less than or equal to 5"}],"trace_id":"6e91060913d84cf4967f5bbce35f261b"}
curl -s -i $B/tasks/1 # 401
for i in 1 2 3 4; do curl -s -o /dev/null -w '%{http_code} ' $B/search; done # 429
curl -s -i $B/boom # 500
HTTP/1.1 401 Unauthorized
x-trace-id: 82f27b2a60e2487a9a03d1f984da8edf
www-authenticate: Bearer realm="api"
content-type: application/problem+json
200 200 200 429
HTTP/1.1 429 Too Many Requests
x-trace-id: f788e928ea6549fc93b4ba23181429b2
retry-after: 10
content-type: application/problem+json
HTTP/1.1 500 Internal Server Error
x-trace-id: b5070e62f16247ad87bafd59375862b6
content-type: application/problem+json
(本文は前の「テンプレート」節に掲載したものと同じです。)存在しないメソッドで呼ぶと、フレームワークの 405 も Allow: POST ヘッダー付きで同じ形式になりました。
HTTP/1.1 405 Method Not Allowed
allow: POST
content-type: application/problem+json
{"type":"about:blank","title":"Method Not Allowed","status":405,"detail":"Method Not Allowed","instance":"/tasks","trace_id":"3cb1127c8e144ef9a85dff0f86d36525"}
Express で Problem Details を返す実装
Express では「引数4つのエラーハンドラー」を最後に1つ置き、すべてのエラーを next(err) でそこへ集めます。 Express 5 では async ハンドラーで発生した例外(Promise の reject)も自動でエラーハンドラーに渡るため、try/catch を各ルートに書かずに済みます。
直す前:Express 標準のエラー出力
エラーハンドラーを置かずに例外を投げると、Express 5.2.1 は HTML を返しました。NODE_ENV を設定していない開発時は、スタックトレースとサーバー上のファイルパスが本文に含まれます。
HTTP/1.1 500 Internal Server Error
Content-Type: text/html; charset=utf-8
<!DOCTYPE html>
<html lang="en">
...
<pre>TypeError: Cannot read properties of undefined (reading 'id')<br> at file:///.../plain.mjs:3:38<br> at Layer.handleRequest (/.../node_modules/router/lib/layer.js:152:17)<br> ...
NODE_ENV=production で起動すると本文は <pre>Internal Server Error</pre> だけになりますが、HTML のままなので API クライアントは JSON として読めません。JSON の API ではエラーハンドラーを自分で用意するのが基本です。
直した後:完成コード
環境:Node.js 22.22.2、express 5.2.1。
npm init -y && npm i express@5
PORT=7962 node app.mjs
// app.mjs (Node.js 22.22 / Express 5.2.1)
import express from "express";
import { randomUUID } from "node:crypto";
import { STATUS_CODES } from "node:http";
const PROBLEM_BASE = "https://api.example.com/problems/";
const app = express();
// ---------- Problem Details の共通部品 ----------
class ProblemError extends Error {
constructor(status, slug, title, detail, { headers = {}, ...ext } = {}) {
super(detail);
Object.assign(this, { status, slug, title, detail, headers, ext });
}
}
function sendProblem(req, res, { status, slug, title, detail, headers = {}, ext = {} }) {
res.set(headers);
res.status(status).type("application/problem+json").json({
type: slug ? PROBLEM_BASE + slug : "about:blank",
title: title ?? STATUS_CODES[status],
status,
detail,
instance: req.originalUrl,
...ext,
trace_id: req.traceId,
});
}
// trace id を最初に付ける
app.use((req, res, next) => {
req.traceId = randomUUID().replaceAll("-", "");
res.set("X-Trace-Id", req.traceId);
next();
});
// メンテナンス中は 503(環境変数 MAINTENANCE=1 で切り替え)
app.use((req, res, next) => {
if (process.env.MAINTENANCE === "1") {
return next(new ProblemError(503, "maintenance", "Service Unavailable",
"メンテナンス中です。終了予定まで時間をおいてアクセスしてください。",
{ headers: { "Retry-After": "120" }, code: "MAINTENANCE" }));
}
next();
});
app.use(express.json());
// ---------- サンプルの業務ロジック(タスク管理) ----------
const TOKENS = { "token-member": "member", "token-admin": "admin" };
const TASKS = new Map([[1, { id: 1, title: "備品の棚卸し", priority: 3 }]]);
const hits = new Map();
const LIMIT = 3, WINDOW_MS = 10_000;
function auth(req, res, next) {
const h = req.get("Authorization");
if (!h?.startsWith("Bearer ")) {
return next(new ProblemError(401, "unauthorized", "Unauthorized",
"認証情報がありません。Authorization ヘッダーにトークンを指定してください。",
{ headers: { "WWW-Authenticate": 'Bearer realm="api"' }, code: "AUTH_REQUIRED" }));
}
req.role = TOKENS[h.slice(7)];
if (!req.role) {
return next(new ProblemError(401, "unauthorized", "Unauthorized",
"トークンが無効か、有効期限が切れています。",
{ headers: { "WWW-Authenticate": 'Bearer error="invalid_token"' }, code: "AUTH_INVALID_TOKEN" }));
}
next();
}
function validateTask(body) {
const errors = [];
const { title, priority } = body ?? {};
if (typeof title !== "string" || title.length < 1 || title.length > 50) {
errors.push({ pointer: "#/title", code: "LENGTH_OUT_OF_RANGE", detail: "title は 1〜50 文字で入力してください。" });
}
if (!Number.isInteger(priority) || priority < 1 || priority > 5) {
errors.push({ pointer: "#/priority", code: "OUT_OF_RANGE", detail: "priority は 1〜5 の整数で入力してください。" });
}
return errors;
}
app.get("/tasks/:id", auth, (req, res, next) => {
const task = TASKS.get(Number(req.params.id));
if (!task) {
return next(new ProblemError(404, "not-found", "Not Found",
`タスク ${req.params.id} は見つかりません。`, { code: "TASK_NOT_FOUND" }));
}
res.json(task);
});
app.post("/tasks", auth, (req, res, next) => {
const errors = validateTask(req.body);
if (errors.length) {
return next(new ProblemError(422, "validation-error", "入力内容に誤りがあります",
`${errors.length} 件の項目を確認してください。`, { code: "VALIDATION_FAILED", errors }));
}
if ([...TASKS.values()].some((t) => t.title === req.body.title)) {
return next(new ProblemError(409, "conflict", "Conflict",
`同じタイトルのタスク「${req.body.title}」が既にあります。`, { code: "TASK_TITLE_DUPLICATED" }));
}
const id = Math.max(...TASKS.keys()) + 1;
const task = { id, title: req.body.title, priority: req.body.priority };
TASKS.set(id, task);
res.status(201).location(`/tasks/${id}`).json(task);
});
app.get("/search", (req, res, next) => {
const now = Date.now();
const list = (hits.get(req.ip) ?? []).filter((t) => now - t < WINDOW_MS);
if (list.length >= LIMIT) {
const retry = Math.ceil((WINDOW_MS - (now - list[0])) / 1000);
return next(new ProblemError(429, "rate-limited", "Too Many Requests",
`リクエストが多すぎます。${retry} 秒後に再試行してください。`,
{ headers: { "Retry-After": String(retry) }, code: "RATE_LIMITED", retry_after: retry }));
}
list.push(now);
hits.set(req.ip, list);
res.json({ q: req.query.q ?? "", items: [] });
});
// Express 5 は async ハンドラーの reject も自動でエラーハンドラーへ渡す
app.get("/boom", async () => {
throw new TypeError("Cannot read properties of undefined (reading 'id')");
});
// どのルートにも一致しない → 404
app.use((req, res, next) => next(new ProblemError(404, null, null, "Not Found")));
// ---------- エラーハンドラー(引数4つで登録) ----------
app.use((err, req, res, next) => {
if (err instanceof ProblemError) {
console.info(`problem trace_id=${req.traceId} status=${err.status} type=${err.slug}`);
return sendProblem(req, res, err);
}
if (err.type === "entity.parse.failed") { // express.json() の JSON 構文エラー
return sendProblem(req, res, { status: 400, slug: "malformed-json", title: "Bad Request",
detail: "リクエストボディが JSON として解釈できません。", ext: { code: "MALFORMED_JSON" } });
}
// 想定外:スタックトレースはログにだけ出す
console.error(`unhandled error trace_id=${req.traceId} path=${req.originalUrl}\n${err.stack}`);
sendProblem(req, res, { status: 500, slug: "internal-error", title: "Internal Server Error",
detail: "サーバー内部でエラーが発生しました。時間をおいて再度お試しください。",
ext: { code: "INTERNAL_ERROR" } });
});
const port = Number(process.env.PORT ?? 7962);
app.listen(port, () => console.log(`listening on ${port}`));
curl で確認した実行結果
## 422
HTTP/1.1 422 Unprocessable Entity
X-Trace-Id: 9b06e1349bb046fd88d78f51168b2a9e
Content-Type: application/problem+json; charset=utf-8
{"type":"https://api.example.com/problems/validation-error","title":"入力内容に誤りがあります","status":422,"detail":"2 件の項目を確認してください。","instance":"/tasks","code":"VALIDATION_FAILED","errors":[{"pointer":"#/title","code":"LENGTH_OUT_OF_RANGE","detail":"title は 1〜50 文字で入力してください。"},{"pointer":"#/priority","code":"OUT_OF_RANGE","detail":"priority は 1〜5 の整数で入力してください。"}],"trace_id":"9b06e1349bb046fd88d78f51168b2a9e"}
## 503(MAINTENANCE=1 で起動)
HTTP/1.1 503 Service Unavailable
Retry-After: 120
{"type":"https://api.example.com/problems/maintenance","title":"Service Unavailable","status":503,"detail":"メンテナンス中です。終了予定まで時間をおいてアクセスしてください。","instance":"/tasks/1","code":"MAINTENANCE","trace_id":"94ed65d0ce87467b84c2fe02efb3c7f4"}
## 201(成功時は通常の application/json)
HTTP/1.1 201 Created
Location: /tasks/2
{"id":2,"title":"会議室の予約","priority":2}
res.type("application/problem+json") を指定すると、Express は Content-Type: application/problem+json; charset=utf-8 を付けました。res.json() だけでは application/json になるので、type() の指定を忘れないようにします。
FastAPI と Express の実装の比較
| 観点 | FastAPI 0.142.2 | Express 5.2.1 |
|---|---|---|
| 入力検証 | Pydantic が自動で検出(RequestValidationError) |
自前の関数や Zod などで検出 |
| JSON 構文エラーの判定 | errors() の type == "json_invalid" |
err.type === "entity.parse.failed" |
| 想定外の例外を拾う場所 | @app.middleware("http") の try/except |
引数4つのエラーハンドラー |
| ルートなし 404 | StarletteHTTPException のハンドラー |
最後に置く app.use で next(new ProblemError(404)) |
| Content-Type の指定 | JSONResponse(media_type=...) |
res.type("application/problem+json") |
| 標準の出力 | {"detail": ...}(JSON) |
HTML |
Spring Boot の ProblemDetail(6.0 以降)
Spring Framework 6.0 以降には RFC 9457 用の ProblemDetail クラスがあり、Spring Boot では spring.mvc.problemdetails.enabled=true で組み込み例外を Problem Details で返せます。 以下は Spring 公式リファレンス「Error Responses」の説明に沿って書いたコードで、この記事の検証環境では実行していません(Maven Central に接続できないため)。
# application.properties
spring.mvc.problemdetails.enabled=true
// TaskNotFoundException.java
import java.net.URI;
import org.springframework.http.HttpStatus;
import org.springframework.http.ProblemDetail;
import org.springframework.web.ErrorResponseException;
public class TaskNotFoundException extends ErrorResponseException {
public TaskNotFoundException(long id) {
super(HttpStatus.NOT_FOUND, problem(id), null);
}
private static ProblemDetail problem(long id) {
ProblemDetail pd = ProblemDetail.forStatusAndDetail(
HttpStatus.NOT_FOUND, "タスク " + id + " は見つかりません。");
pd.setType(URI.create("https://api.example.com/problems/not-found"));
pd.setTitle("Not Found");
pd.setProperty("code", "TASK_NOT_FOUND"); // 拡張メンバーはトップレベルに展開される
return pd;
}
}
// ApiExceptionHandler.java
import java.net.URI;
import org.springframework.http.HttpStatus;
import org.springframework.http.ProblemDetail;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.RestControllerAdvice;
@RestControllerAdvice
public class ApiExceptionHandler {
// DuplicateTitleException は、タイトル重複時に投げる自前の RuntimeException
@ExceptionHandler(DuplicateTitleException.class)
public ProblemDetail handleDuplicate(DuplicateTitleException ex) {
ProblemDetail pd = ProblemDetail.forStatusAndDetail(HttpStatus.CONFLICT, ex.getMessage());
pd.setType(URI.create("https://api.example.com/problems/conflict"));
pd.setTitle("Conflict");
pd.setProperty("code", "TASK_TITLE_DUPLICATED");
return pd; // instance は未設定ならリクエストのパスが入る
}
}
公式リファレンスの要点は次のとおりです。
| 機能 | 内容 |
|---|---|
ProblemDetail |
標準メンバーと独自プロパティを保持。status がレスポンスのステータスになる |
ErrorResponseException |
独自例外の土台。投げるだけで Problem Details になる |
ResponseEntityExceptionHandler |
Spring MVC の組み込み例外をまとめて Problem Details に変換する基底クラス |
setProperty() |
Jackson がトップレベルのメンバーとして出力 |
| メッセージの国際化 | problemDetail.title.{例外の完全修飾名} などのキーで MessageSource から解決 |
| Spring Boot での注意 | 自動設定のハンドラーと併用する場合、自分の @ControllerAdvice を優先順位 0 より前に置く |
載せてはいけない情報と、ログとの対応
エラーレスポンスは「利用者が直すための情報」に限り、調査に必要な情報はログに残して trace_id でつなぐのが基本です。RFC 9457 のセキュリティの節でも、スタックダンプなど実装の詳細を HTTP で公開しないよう注意しています。
| 情報 | レスポンス | ログ | 理由 |
|---|---|---|---|
| スタックトレース | 載せない | 載せる | 内部構造が外から見えてしまう |
| SQL 文・DB のエラー文 | 載せない | 載せる | テーブル名や構造が伝わる |
| サーバーのファイルパス・ライブラリのバージョン | 載せない | 必要に応じて | 環境の特定につながる |
| 入力されたパスワード・トークン | 載せない | 載せない(マスク) | 秘密情報 |
| 「メールアドレスは登録済み」 | 場面に応じて検討 | 載せる | 会員登録の有無が推測できるため、ログイン画面では一般的な文言にする |
trace_id |
載せる | 載せる | 問い合わせとログを結びつける |
| 項目名と直し方 | 載せる | 任意 | 利用者が自分で直せる |
trace_id でログとつなぐ
上の FastAPI 実装では、500 のときにレスポンスには trace_id だけを返し、ログには同じ ID とスタックトレースを出しています。実際のログは次のとおりです(途中を省略)。
ERROR unhandled error trace_id=b5070e62f16247ad87bafd59375862b6 path=/boom
Traceback (most recent call last):
File ".../main.py", line 54, in add_trace_id
return await call_next(request)
...
File ".../main.py", line 176, in boom
{}["missing"] # わざと KeyError を起こす
KeyError: 'missing'
利用者から「お問い合わせ番号 b5070e62…」と連絡を受ければ、ログをその ID で検索して原因にたどり着けます。分散トレーシング(OpenTelemetry など)を使っている場合は、W3C Trace Context の traceparent ヘッダーに含まれる trace-id をそのまま trace_id に使うと、API ゲートウェイや他サービスのログとも同じ ID でつながります。
クライアント側での扱い方
クライアントは「Content-Type を見てから読む」「code で分岐する」「知らない code は title と trace_id で表示する」の3点を押さえると、API 側でエラーが増えても壊れにくくなります。 プロキシやロードバランサーが HTML のエラーページを返すこともあるため、problem+json でない場合の分岐も入れておきます。
// client.mjs (Node.js 22 の組み込み fetch)
const BASE = process.env.BASE ?? "http://127.0.0.1:7962";
class ApiProblem extends Error {
constructor(status, problem) {
super(problem.title ?? `HTTP ${status}`);
this.status = status;
this.problem = problem;
}
}
async function api(path, init = {}) {
const res = await fetch(BASE + path, init);
if (res.ok) return res.status === 204 ? null : res.json();
const ctype = res.headers.get("content-type") ?? "";
// problem+json 以外(プロキシの HTML など)が返っても落ちないようにする
const problem = ctype.includes("application/problem+json")
? await res.json()
: { type: "about:blank", title: res.statusText, status: res.status };
throw new ApiProblem(res.status, problem);
}
function toMessage(e) {
const p = e.problem;
switch (p.code) {
case "VALIDATION_FAILED":
return p.errors.map((x) => `${x.pointer.slice(2)}: ${x.detail}`).join(" / ");
case "AUTH_REQUIRED":
case "AUTH_INVALID_TOKEN":
return "ログインし直してください";
case "RATE_LIMITED":
return `${p.retry_after} 秒後に再試行します`;
default:
// 未知の code でも title と trace_id で問い合わせできるようにする
return `${p.title}(お問い合わせ番号: ${p.trace_id ?? "-"})`;
}
}
const json = { "Content-Type": "application/json", Authorization: "Bearer token-member" };
for (const [path, init] of [
["/tasks", { method: "POST", headers: json, body: JSON.stringify({ title: "", priority: 9 }) }],
["/tasks/1", {}],
["/tasks/999", { headers: json }],
["/boom", {}],
]) {
try {
console.log(path, await api(path, init));
} catch (e) {
console.log(`${path} -> ${e.status} ${e.problem.code ?? e.problem.type}: ${toMessage(e)}`);
}
}
node client.mjs # Express のサーバー(7962番)に対して実行
/tasks -> 422 VALIDATION_FAILED: title: title は 1〜50 文字で入力してください。 / priority: priority は 1〜5 の整数で入力してください。
/tasks/1 -> 401 AUTH_REQUIRED: ログインし直してください
/tasks/999 -> 404 TASK_NOT_FOUND: Not Found(お問い合わせ番号: 05e3b9764c004e21bfae1d2a7b3ed266)
/boom -> 500 INTERNAL_ERROR: Internal Server Error(お問い合わせ番号: 673118294bcd4c149ff65adb5cb1f087)
| クライアントの判断 | 見る場所 | 動き |
|---|---|---|
| 再試行してよいか | ステータス(429・503)と Retry-After |
指定秒数待って再送。500 は回数を絞って再試行 |
| 画面のどこに出すか | errors[].pointer |
該当する入力欄の下に表示 |
| ログインし直すか | 401 と code |
トークン更新、またはログイン画面へ |
| 利用者に何を見せるか | title・detail |
文言をそのまま、または code から画面用の文言に変換 |
| 問い合わせの手がかり | trace_id |
画面の隅に小さく表示 |
よくある失敗と直し方
| よくある状態 | 起きること | 直し方 |
|---|---|---|
200 OK で本文に {"error": ...} |
キャッシュや監視が成功扱いにする | 失敗は 4xx/5xx で返す |
本文の status とヘッダーのステータスが違う |
プロキシや監視と、アプリで判断が食い違う | 同じ値を1か所から設定する |
Content-Type: application/json のまま |
クライアントが Problem Details と判別できない | application/problem+json を指定 |
| ルートなし 404 だけ形式が違う | クライアントのパースが分岐だらけになる | フレームワークの例外ハンドラーも上書き |
detail に例外メッセージをそのまま入れる |
内部の情報が外に出る | 想定外の例外は固定文言+trace_id |
エラーの種類ごとに type を作りすぎる |
ドキュメントが追いつかない | 種類は type、細かい理由は code |
429 に Retry-After がない |
クライアントがすぐ再送を繰り返す | 秒数をヘッダーと拡張メンバーで返す |
よくある質問
Q. RFC 7807 で作った API は作り直す必要がありますか? A. 作り直す必要はありません。 RFC 9457 は JSON の形を変えておらず、追加されたのはレジストリや複数問題の扱いなどの説明です。ドキュメントの参照先を RFC 9457 に更新すれば十分です。
Q. type の URL には本当にページを用意する必要がありますか?
A. 必須ではありませんが、用意すると開発者が原因と対処を調べやすくなります。 RFC 9457 は、たどれない URI(tag: URI など)の利用も認めています。まずは URI で種類を区別し、余裕ができたら説明ページを置く進め方で問題ありません。
Q. エラーメッセージ(title・detail)は日本語と英語のどちらにすべきですか?
A. 利用者に見せるなら利用者の言語、機械判定は code に任せるのがおすすめです。title は同じ type で毎回同じにする決まりですが、翻訳は認められています。Accept-Language ヘッダーで言語を切り替える方法もあります。
Q. GraphQL や gRPC でも Problem Details を使えますか?
A. Problem Details は HTTP API 向けの仕様です。 GraphQL は本文の errors 配列、gRPC は google.rpc.Status という独自の仕組みを持つため、それぞれの標準に合わせるのが自然です。
まとめ
- エラー本文は RFC 9457 の Problem Details(
application/problem+json)にそろえると、クライアントが1つの処理で読める type・title・status・detail・instanceの5つが基本。statusはヘッダーのステータスと必ず一致させる- 足りない情報は拡張メンバー(
code・errors・trace_id)で追加し、クライアントは知らないメンバーを無視する - ステータスは「クライアントの次の行動が変わるか」で選ぶ。構文エラー 400、値の誤り 422、重複 409、回数制限 429
typeは種類、codeは細かい理由と役割を分け、コード表で一元管理する- フレームワーク標準のエラー出力も上書きする。Express の標準は HTML、FastAPI の標準は
{"detail": ...} - スタックトレースはログにだけ出し、
trace_idで問い合わせとつなぐ
本記事の内容は、ご自由にお使いください。
ご利用の際は、出典として本ページへのリンクを記載いただけますようお願いします。
(記載例)出典:株式会社RJC「APIエラーレスポンス設計|RFC 9457準拠のJSON例と実装コード」
株式会社RJCでは、使いやすく安全な API の設計を仲間と学び合っています。こうした知識を共有しながら、日々技術を磨いています。
株式会社RJC ― SI事業・SES事業・AI駆動開発。RJCは一緒に成長を楽しめる会社です。
WE ARE HIRING
RJCで一緒に開発しながら、
成長を楽しみませんか?
RJCは、Web・モバイル・AIを活用した開発プロジェクトで、テックリードやPM・PMOも活躍するシステム開発会社です。会社を知る、待遇を確かめる、話を聞いてみる。気になるところ見てみてください!
- 127日年間休日
- 12時間平均残業時間
- 毎日ガチャ遊びココロも大切にする福利厚生。アマギフなどの賞品ラインナップ!
ほかにも、チケットレストラン、書籍読み放題、2年ごとの慰労報奨(休暇 or 金一封)、11期連続の黒字決算。