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 &#39;id&#39;)<br> &nbsp; &nbsp;at file:///.../plain.mjs:3:38<br> &nbsp; &nbsp;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期連続の黒字決算。

ABOUT RJC RJCがどんな会社か知る 考え方、研修、働き方、福利厚生、社員の前職まで。RJCのことが丸わかり! RJC丸わかりページへ JOB DESCRIPTION 仕事内容・待遇を見てみる 仕事内容、給与・待遇、選考の流れ。経験者も未経験も!応募前に知りたいこと、まとめました! 募集要項を見る ENTRY エントリーする エントリーは1〜2分・履歴書不要です。まずは話を聞いてみたい、という方でも歓迎です! エントリーフォームへ

RJCで一緒に開発しながら、 成長を楽しみませんか?