RFC 9457でAPIのエラー形式を統一する — Claude Codeで棚卸しと契約テスト
ハンドラごとにばらばらなエラーボディをgrepで棚卸しし、RFC 9457のProblem Detailsに寄せる手順。サブエージェントでの分担と、application/problem+jsonの契約テストまで示します。
RFC 9457のProblem Detailsとは何を決めた形式か
RFC 9457(Problem Details for HTTP APIs)は、HTTP APIのエラー内容を機械が読める形で返すための共通フォーマットです。目的は、APIごとに独自のエラー形式を作らずに済ませることにあります。RFC 7807を置き換える文書で、JSONで返すときのメディアタイプはapplication/problem+jsonです。
オブジェクトに入れる標準のメンバーは5つです。
| メンバー | 型 | 役割 |
|---|---|---|
type | 型URI参照 | 役割問題の種類を識別する主キー。省略時はabout:blankとして扱う |
status | 型数値 | 役割発生時のHTTPステータス。助言的な値で、実際のレスポンスと同じ値にする |
title | 型文字列 | 役割問題の種類の短い要約。発生ごとに変えない(翻訳を除く) |
detail | 型文字列 | 役割この発生に固有の説明。クライアントが直す手がかりに絞る |
instance | 型URI参照 | 役割この発生を識別するURI |
標準のメンバーに加えて、問題の種類ごとに拡張メンバーを足せます。受け取る側は、知らない拡張メンバーを無視しなければなりません。バリデーションエラーで違反箇所の配列を返したいときは、errorsのような拡張メンバーを自分で定義する形になります。
ここで押さえておきたい決まりが3つあります。
typeが識別の主キーです。クライアントはdetailの文面を解析せず、拡張メンバーから情報を取ります。statusと実際のHTTPステータスが食い違うと、プロキシなど一般的なHTTPソフトは本文のstatusを見ないため、挙動がずれます。生成側は同じ値を返す義務があります。- スタックトレースのような実装の内部情報は、エラー本文に出さない方針が示されています。
この記事は、すでに動いているAPIのエラー本文を、この形に寄せ直す作業を扱います。例はNode.js(Express、TypeScript)で書きますが、grepの観点と契約テストの考え方は他の言語でも通用します。
統一の前に、今のエラーボディを棚卸しする
寄せ先を決める前に、現状のばらつきを数えます。ハンドラごとに{ error: "..." }、{ message: "..." }、{ code: 1001, msg: "..." }と別々に書かれているAPIでは、まず「何種類あるか」が分かるだけで作業の見通しが立ちます。
エラー応答の書き方は、プロジェクトの流儀に合わせて正規表現を調整します。次は、Expressでres.status(4xx/5xx).json(...)と書かれている場合の例です。
# ステータスコードを指定しているエラー応答の位置を一覧にする
grep -rnE "status\((4|5)[0-9]{2}\)" src --include=*.ts
# ボディの先頭キーでおおまかに型を分ける
grep -rnoE "json\(\{ *[a-zA-Z_]+" src --include=*.ts \
| sed -E 's/.*json\(\{ *//' | sort | uniq -c | sort -rn2本目のコマンドは、json({の直後のキー名ごとに件数を集計します。errorが40件、messageが25件、codeが8件といった分布が出れば、それがそのまま移行作業の規模です。ミドルウェアが例外を握ってまとめて整形している場合は、app.use((err, req, res, next)を探してそちらも対象に加えます。
grepで拾えない箇所もあります。フレームワークが自動で返すエラー(JSONパースの失敗、404のルート未一致、ボディサイズ超過など)は、ハンドラのコードに現れません。こちらは実際にリクエストを投げて、返ってくるContent-Typeとボディを見ます。
# ルート未一致、不正なJSON、未認証を実際に叩いて形式を確認する
curl -si http://localhost:3000/no-such-path
curl -si -X POST http://localhost:3000/orders \
-H "Content-Type: application/json" -d '{broken'
curl -si http://localhost:3000/ordersgrepの結果と実測の結果を並べ、どのエンドポイントがどの形で返しているかを表にしておくと、後でテストケースの一覧にそのまま使えます。
棚卸しは読み取り専用のサブエージェントに任せる
件数が多いプロジェクトでは、棚卸しの出力がメインの会話を圧迫します。公式の説明では、テスト実行やログ処理のように出力の多い作業をサブエージェントに任せると、詳細はサブエージェント側のコンテキストに残り、要約だけがメインに戻ります。棚卸しはこの用途に合います。
編集系のツールを持たせない定義にしておけば、調べるだけの役として動きます。次は、公式のフロントマター仕様(name・description・tools・model)に沿った例です。
---
name: error-inventory
description: HTTPハンドラのエラー応答を洗い出して表にする。読み取り専用。エラー形式の調査で使う
tools: Read, Grep, Glob
model: sonnet
---
指定されたディレクトリのHTTPエラー応答を調べ、次の列の表で返す。
ファイル:行 / HTTPステータス / ボディの形(キー名の並び) / Content-Type / 備考
コードは変更しない。表の末尾に、ボディの形ごとの件数を付ける。.claude/agents/に置いたファイルは、追加や編集が数秒で検知されます。ただし、そのセッションで初めて作るagentsディレクトリだけは、再起動しないと読み込まれません。
寄せ先の設計は、書き換えの前に1か所で決める
大量のハンドラを書き換えさせる前に、寄せ先のルールを人間が決めます。ここがぶれたまま分担すると、サブエージェントごとに別々のtypeが生まれ、統一の意味がなくなります。次の表は、棚卸しで見つかりがちな形と、寄せ先の対応の一例です。
| 現状のボディ | 寄せ先 | 判断のポイント |
|---|---|---|
{ "error": "not found" } | 寄せ先typeはabout:blank、titleはNot Found、statusは404 | 判断のポイントステータスコード以上の意味がなければabout:blankで足りる |
{ "message": "Invalid email" } | 寄せ先typeは.../validation-error、detailにメッセージ、errorsに違反箇所 | 判断のポイント文面を解析させず、違反箇所は拡張メンバーで返す |
{ "code": 1001, "msg": "..." } | 寄せ先typeにコード対応のURI、codeは拡張として残す選択もあり | 判断のポイント既存クライアントがcodeを見ているなら残す |
本文なしの500 | 寄せ先typeはabout:blank、statusは500 | 判断のポイント内部の例外メッセージはdetailに入れない |
typeは絶対URIで書き、問題の種類ごとに1つ決めます。相対URIは解決の基準によって別のリソースを指してしまうため、絶対URIが推奨されています。解決できる(参照先にドキュメントがある)URIを使う案が勧められており、後から解決可能なURIに変えると別のIDを作ることになって破壊的変更になるためです。
about:blankをtypeに使うと、問題にHTTPステータス以上の意味がないことを表せます。このときのtitleは、404ならNot Foundのように、ステータスの標準の語句に揃えるのが決まりです。すべてのエラーに独自のtypeを作る必要はなく、独自の意味を持つものだけに絞れば、type一覧の保守が軽くなります。
複数の問題が同時に起きたときは、最も関連が強い、または緊急の1件を返す形が推奨されています。種類の違う問題を1つの「バッチ」型にまとめる設計は、HTTPの意味づけに合いません。入力検証のように同じ種類の問題が複数あるときは、errors配列で並べます。
共通ヘルパーを先に作って、書き換えの出口を1つにする
寄せ先が決まったら、エラー応答を作る関数を1か所に用意します。サブエージェントへの指示は「res.status(...).json(...)を、このヘルパーの呼び出しに置き換える」と書けるようになり、依頼の精度が上がります。
// src/http/problem.ts
import { randomUUID } from "node:crypto";
import type { Response } from "express";
const BASE = "https://api.example.com/problems";
export const problems = {
validation: { type: `${BASE}/validation-error`, title: "Your request is not valid.", status: 422 },
notFound: { type: "about:blank", title: "Not Found", status: 404 },
unauthorized: { type: "about:blank", title: "Unauthorized", status: 401 },
internal: { type: "about:blank", title: "Internal Server Error", status: 500 },
} as const;
type ProblemDef = { type: string; title: string; status: number };
export function sendProblem(
res: Response,
def: ProblemDef,
extra: { detail?: string; [member: string]: unknown } = {},
) {
const body = { ...def, instance: `urn:uuid:${randomUUID()}`, ...extra };
res
.status(def.status)
.set("Content-Type", "application/problem+json")
.send(JSON.stringify(body));
}ホスト名のapi.example.comは例です。自分のAPIが管理するドメインに置き換えます。statusにはHTTPレスポンスと同じ値を入れ、instanceは発生ごとの識別子としてログにも同じ値を出すと、問い合わせの突き合わせに使えます。
ヘルパーを使った書き換えの前後は、次のようになります。
// 前: ハンドラごとに形が違う
if (!order) return res.status(404).json({ error: "order not found" });
// 後: 出口がヘルパーに揃う
if (!order) return sendProblem(res, problems.notFound, { detail: `Order ${id} does not exist.` });detailには、クライアントが直すのに役立つ情報を入れます。例外メッセージやSQLをそのまま渡すと、実装の内部が漏れます。
書き換えをサブエージェントで分担させる
ヘルパーができたら、ディレクトリ(orders、users、billingなど)ごとに書き換えを分担させます。書き換え役は、編集系のツールを持たせた別の定義にします。
---
name: problem-migrator
description: 指定ディレクトリのエラー応答を sendProblem に置き換える。寄せ先の表に従う
tools: Read, Grep, Glob, Edit, Bash
isolation: worktree
maxTurns: 30
---
渡されたディレクトリだけを対象にする。
- res.status(4xx/5xx).json/send の呼び出しを sendProblem(res, problems.X, {...}) に置き換える
- 該当する problems の定義が無いときは、勝手に type を作らず、一覧にして報告する
- 例外メッセージ・スタックトレースを detail に入れない
- 終わったら変更したファイルと、判断に迷った箇所を返す書き換え役に決めさせないのは、typeの新設です。足りない定義を報告させ、人間がproblemsに追加してから次の周に進めます。
worktreeの分岐元に注意する
isolation: worktreeを付けると、サブエージェントは一時的なgit worktreeで、リポジトリの分離したコピーを使います。公式の説明では、分岐元は親セッションのHEADではなく、リポジトリのデフォルトブランチです。つまり、ヘルパーsrc/http/problem.tsがまだコミット・マージされていないと、書き換え役のworktreeにはヘルパーが存在しません。
対処は2つあります。
- ヘルパーをデフォルトブランチに取り込んでから、書き換え役を走らせる
- 設定の
worktree.baseRefを"head"にして、手元の未pushのコミットを含めて分岐させる
2を選ぶときの設定は次のとおりです。"head"は、進行中の作業を前提にサブエージェントを隔離したい場合に使う値と説明されています。
{
"worktree": {
"baseRef": "head"
}
}ヘルパーの変更はコミットしておく必要があります。"head"が引き継ぐのは現在のHEADであり、未コミットの変更はそこに含まれません。
並列数と報告の量
並列で動かすときの上限も把握しておきます。同時に動いているサブエージェントが20個に達した状態で新しく起動すると、Concurrent subagent limit reachedで失敗します。環境変数CLAUDE_CODE_MAX_CONCURRENT_SUBAGENTSで変更できますが、分担するディレクトリが20個を超えないなら、既定のままで収まります。
メインの会話から起動したサブエージェントは、何も設定しなければバックグラウンドで動きます。許可が必要なツール呼び出しに当たると、メインのセッションに確認が出て、どのサブエージェントが求めているかが表示されます。報告が長くなると、メインのコンテキストを圧迫する点にも注意が必要です。依頼文に「変更したファイル名と判断に迷った箇所だけを返す」と書いておくと、戻ってくる量を抑えられます。
読み取り専用に絞ったサブエージェントの作り方は、Claude Codeのサブエージェントをhookで読み取り専用のDB分析役にするにも例があります。
application/problem+jsonの契約テストを書く
書き換えが終わったら、形式が守られていることをテストで固定します。エンドポイントごとに個別のアサーションを書くより、ケースの一覧を作って同じ検査を当てるほうが、後から増えたエンドポイントにも広げやすくなります。棚卸しで作った表が、そのままケース一覧になります。
次はVitestとsupertestを使った例です。
// test/problem-contract.test.ts
import request from "supertest";
import { describe, it, expect } from "vitest";
import { app } from "../src/app";
const cases = [
{ name: "未認証", send: () => request(app).get("/orders"), status: 401 },
{ name: "存在しない注文", send: () => request(app).get("/orders/999999"), status: 404 },
{ name: "不正な本文", send: () => request(app).post("/orders").send({ quantity: -1 }), status: 422 },
{ name: "ルート未一致", send: () => request(app).get("/no-such-path"), status: 404 },
];
describe.each(cases)("problem+json: $name", ({ send, status }) => {
it("RFC 9457 の形で返る", async () => {
const res = await send();
expect(res.status).toBe(status);
expect(res.headers["content-type"]).toMatch(/^application\/problem\+json/);
expect(res.body.status).toBe(res.status);
expect(typeof res.body.title).toBe("string");
expect(() => new URL(res.body.type)).not.toThrow();
expect(JSON.stringify(res.body)).not.toMatch(/node_modules|\.ts:\d+/);
});
});検査の要点は4つです。
- ステータスコード:本文の
statusが、実際のHTTPステータスと一致している - メディアタイプ:
Content-Typeがapplication/problem+jsonで始まる type:about:blankも含めてURIとして解釈できる- 内部情報:スタックトレースやファイルパスらしい文字列が本文にない
「ルート未一致」のケースは、ハンドラのコードに現れないフレームワーク既定のエラーを拾うためのものです。このケースを通すには、アプリ全体の最後に置くエラーミドルウェアと、404用のミドルウェアの両方をsendProblem経由にします。
テストに加えて、元の書き方が再び入り込むことを防ぐ検査も置いておきます。
# ヘルパー以外で res.status(4xx/5xx).json/send を使っていたら失敗させる
! grep -rnE "status\((4|5)[0-9]{2}\)\.(json|send)\(" src --include=*.ts \
| grep -v "src/http/problem.ts"マッチがあればgrepが成功を返し、先頭の!で全体が失敗になります。CIの1ステップに入れておけば、新しいハンドラで旧形式が書かれた時点で気づけます。
テストの実行も、Claude Codeに回させられます。「テストを実行して失敗したケースを直す」という依頼で、失敗の出力を読んでヘルパーの呼び出しを直す往復が成立します。出力が長くなる場合は、棚卸しと同じようにサブエージェントに任せ、失敗したケースの要約だけを受け取る形にします。
移行で踏みやすい落とし穴
既存クライアントが旧形式に依存している場合、一斉に切り替えると壊れます。RFCは、既存のHTTP APIにProblem Detailsを後から足す方法として、HTTPのコンテンツネゴシエーション(Acceptヘッダー)を挙げています。クライアントがapplication/problem+jsonを受け入れると示したときだけ新形式を返し、それ以外は旧形式を維持する、という移行が組めます。
detailをクライアントが解析する実装も要注意です。RFCは、detailの文面から情報を取り出すことを勧めておらず、拡張メンバーのほうが誤りにくいとしています。旧形式のmessageを解析しているクライアントがあるなら、先に該当の情報を拡張メンバーとして用意します。
ステータスコードだけで十分に伝わる問題を、独自のtypeにしすぎないことも大切です。RFCは、たとえば「PUTに書き込み権限がない」という問題は、403で自明なので専用の問題型は不要という考え方を示しています。
最後に、他のAPIのエラーを自分のAPIで包み直す場面です。呼び出し先のAPIが独自形式で返してきたエラーを、そのままdetailに流し込むと、外部の内部情報が自分のAPIから漏れます。たとえばClaude APIはtypeとmessageを持つ独自のJSON形式で返すため(Claude APIのエラー形式とSDK例外クラスの言語別対応表に整理があります)、中継するAPIでは、その内容を読んで自分のtypeに対応づけてから返す設計になります。
まとめ
統一の作業は、順序で決まります。棚卸しで規模をつかみ、寄せ先の表とヘルパーを人間が決め、機械的な書き換えだけをサブエージェントに分担し、契約テストと旧形式の検出で固定する流れです。書き換えをサブエージェントに任せる前に、ヘルパーがworktreeの分岐元に含まれているかを確認するのが、最初の落とし穴を避ける一番簡単な方法です。統合テストでAPI全体を検証する進め方は、Claude CodeでASP.NET Core Web APIを開発するも参考になります。
参照した一次ソース
この記事の記述は、次の一次ソースと照合しています。更新時に参照先が変わった場合は一覧も更新します。