Claude Media
RFC 9457でAPIのエラー形式を統一する — Claude Codeで棚卸しと契約テスト

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 -rn

2本目のコマンドは、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/orders

grepの結果と実測の結果を並べ、どのエンドポイントがどの形で返しているかを表にしておくと、後でテストケースの一覧にそのまま使えます。

棚卸しは読み取り専用のサブエージェントに任せる

件数が多いプロジェクトでは、棚卸しの出力がメインの会話を圧迫します。公式の説明では、テスト実行やログ処理のように出力の多い作業をサブエージェントに任せると、詳細はサブエージェント側のコンテキストに残り、要約だけがメインに戻ります。棚卸しはこの用途に合います。

編集系のツールを持たせない定義にしておけば、調べるだけの役として動きます。次は、公式のフロントマター仕様(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つあります。

  1. ヘルパーをデフォルトブランチに取り込んでから、書き換え役を走らせる
  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を開発するも参考になります。

参照した一次ソース

この記事の記述は、次の一次ソースと照合しています。更新時に参照先が変わった場合は一覧も更新します。

  1. rfc-editor.org/rfc/rfc9457
  2. code.claude.com/docs/en/sub-agents
  3. code.claude.com/docs/en/worktrees
この記事を共有:XはてブLinkedIn