Claude Codeで学ぶエラー処理パターン: 境界で分類して復旧まで設計する
Claude Codeでエラー処理を境界ごとに分類し、復旧方法まで設計する実践ガイド。API検証、外部API、バッチ失敗をTypeScriptで解説。
エラーハンドリングは「try-catchを増やすこと」ではありません。Claude Codeに修正を任せるときも、失敗をどこで分類し、どこで復旧し、どこで人間に知らせるかが決まっていないと、動くけれど原因が追えないコードになりがちです。
この記事で扱うエラー処理パターンは、初心者向けに言うと「境界で失敗を分類して、復旧方法を明示する設計」です。境界とは、API入力、外部サービス、ジョブ/バッチ、画面表示、DB保存のように、アプリの内側と外側が接する場所です。境界で失敗の種類を分けておくと、Claude Codeにも「ここは400で返す」「ここはリトライする」「ここは再実行キューに積む」と具体的に依頼できます。
Masaの運用では、Claude Codeに丸投げしていた頃ほど、エラーの扱いが散らばりました。入力ミスも外部APIの停止も全部500になり、ログを見てもユーザーが悪いのか、連携先が落ちているのか、ジョブの再実行で直るのか分かりませんでした。そこから、エラーを境界ごとに分類する小さな型を先に作るように変えたところ、レビュー依頼も修正依頼もかなり安定しました。
まず失敗を3種類に分ける
最初に決めるのは、エラー名ではなく復旧方法です。復旧方法から逆算すると、どんな情報を持たせるべきかが自然に決まります。
| 境界 | 典型例 | 返し方 | 復旧方法 |
|---|---|---|---|
| API入力検証 | メール形式が違う、数値範囲外 | 400 | ユーザーに直してもらう |
| 外部API失敗 | 決済、CRM、メール配信の停止 | 502または503 | リトライ、後続処理を止める |
| ジョブ/バッチ失敗 | 夜間集計、CSV取込、通知送信 | 500または内部記録 | 再実行、失敗キュー、通知 |
ここで大事なのは、messageだけで頑張らないことです。人間向けの説明文は便利ですが、プログラムが分岐するには弱いです。kind、code、retryable、statusのような機械が読める情報を持たせると、Claude Codeの修正も人間のレビューも楽になります。
Result型は、そのための素朴な型です。Result型とは、成功なら値、失敗ならエラーを返す入れ物です。例外を全部禁止するという意味ではありません。API境界やジョブ境界など、復旧判断を明示したい場所では、例外を握りつぶすよりResultで返した方が読みやすくなります。
コピペで動くTypeScriptサンプル
次のコードは外部ライブラリなしで動きます。Node.js 18以上を想定し、TypeScriptを直接実行するためにtsxだけ使います。実際の外部APIにはアクセスせず、テストしやすいようにfetcherを注入しています。
npm install -D tsx typescript
npx tsx error-patterns-demo.ts
error-patterns-demo.tsとして保存してください。
type ErrorKind = "validation" | "external" | "job" | "unexpected";
type AppError = {
kind: ErrorKind;
code: string;
message: string;
retryable: boolean;
status: number;
details?: Record<string, unknown>;
cause?: unknown;
};
type Result<T> =
| { ok: true; value: T }
| { ok: false; error: AppError };
const ok = <T>(value: T): Result<T> => ({ ok: true, value });
const fail = <T>(error: AppError): Result<T> => ({ ok: false, error });
function validationError(code: string, message: string, details?: Record<string, unknown>): AppError {
return { kind: "validation", code, message, retryable: false, status: 400, details };
}
function externalError(
code: string,
message: string,
retryable: boolean,
details?: Record<string, unknown>,
cause?: unknown,
): AppError {
return { kind: "external", code, message, retryable, status: retryable ? 503 : 502, details, cause };
}
function jobError(code: string, message: string, details?: Record<string, unknown>, cause?: unknown): AppError {
return { kind: "job", code, message, retryable: true, status: 500, details, cause };
}
type CreateUserInput = {
email: string;
age: number;
plan: "free" | "pro";
};
export function parseCreateUserInput(body: unknown): Result<CreateUserInput> {
if (typeof body !== "object" || body === null) {
return fail(validationError("BODY_REQUIRED", "Request body must be an object"));
}
const record = body as Record<string, unknown>;
const email = record.email;
const age = record.age;
const plan = record.plan ?? "free";
if (typeof email !== "string" || !/^[^@\s]+@[^@\s]+\.[^@\s]+$/.test(email)) {
return fail(validationError("EMAIL_INVALID", "Email address is invalid", { field: "email" }));
}
if (typeof age !== "number" || !Number.isInteger(age) || age < 13) {
return fail(validationError("AGE_INVALID", "Age must be an integer of 13 or greater", { field: "age" }));
}
if (plan !== "free" && plan !== "pro") {
return fail(validationError("PLAN_INVALID", "Plan must be free or pro", { field: "plan" }));
}
return ok({ email, age, plan });
}
type HttpResponse = { status: number; body: unknown };
export function toHttpResponse<T>(result: Result<T>): HttpResponse {
if (result.ok) return { status: 200, body: { data: result.value } };
const { code, message, retryable, details } = result.error;
return {
status: result.error.status,
body: { error: { code, message, retryable, details } },
};
}
type FetchLike = (url: string, init?: { signal?: AbortSignal }) => Promise<Response>;
export async function callBillingApi(
userId: string,
fetcher: FetchLike = fetch,
): Promise<Result<{ customerId: string }>> {
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), 3000);
try {
const response = await fetcher(`https://billing.example.test/customers/${userId}`, {
signal: controller.signal,
});
if (!response.ok) {
return fail(
externalError("BILLING_HTTP_ERROR", `Billing API returned ${response.status}`, response.status >= 500, {
status: response.status,
}),
);
}
const payload = (await response.json()) as { customerId?: unknown };
if (typeof payload.customerId !== "string") {
return fail(externalError("BILLING_BAD_PAYLOAD", "Billing API payload was invalid", false, { payload }));
}
return ok({ customerId: payload.customerId });
} catch (cause) {
return fail(externalError("BILLING_UNREACHABLE", "Billing API could not be reached", true, undefined, cause));
} finally {
clearTimeout(timer);
}
}
export async function runJob<T>(
name: string,
work: () => Promise<T>,
options: { retries: number; delayMs: number } = { retries: 2, delayMs: 100 },
): Promise<Result<T>> {
for (let attempt = 1; attempt <= options.retries + 1; attempt += 1) {
try {
return ok(await work());
} catch (cause) {
if (attempt <= options.retries) {
await sleep(options.delayMs);
continue;
}
return fail(jobError("JOB_FAILED", `${name} failed after ${attempt} attempts`, { attempt }, cause));
}
}
return fail(jobError("JOB_FAILED", `${name} failed unexpectedly`));
}
function sleep(ms: number) {
return new Promise((resolve) => setTimeout(resolve, ms));
}
async function demo() {
console.log("validation:", toHttpResponse(parseCreateUserInput({ email: "bad", age: 10 })));
const billing = await callBillingApi("user_123", async () => new Response("Service unavailable", { status: 503 }));
console.log("external:", billing);
let count = 0;
const job = await runJob("daily-report", async () => {
count += 1;
if (count < 2) throw new Error("temporary lock");
return { exportedRows: 42 };
});
console.log("job:", job);
}
demo().catch((error) => {
console.error(error);
process.exitCode = 1;
});
実例1: API入力検証はユーザーに直してもらう
API入力検証の失敗は、基本的にユーザーが修正できる失敗です。メールアドレスが不正、年齢が範囲外、プラン名が存在しない、JSONの形が違う、こうしたケースで500を返すと、運用者も利用者も何を直せばよいか分かりません。
上のparseCreateUserInputでは、入力を受け取った直後にvalidationへ分類しています。statusは400、retryableはfalseです。これは「同じ内容で再送しても成功しない。入力を直してから再送してほしい」という意味です。
Claude Codeへ依頼するときは「バリデーションを追加して」だけでは足りません。次のように、どの境界で、どのHTTPステータスで、どのエラーコードを返すかまで渡します。
API入力検証のエラー処理をレビューしてください。
- 入力境界で失敗をvalidationに分類する
- ユーザーが直せる失敗は400にする
- codeはEMAIL_INVALIDのように機械可読にする
- messageに内部実装やSQL名を出さない
- 同じ形式を既存APIにも横展開できるか確認する
入力検証を深掘りするなら、Claude CodeでAPIテストを自動化する実践ガイドとClaude Codeテスト戦略完全ガイドも合わせて読むと、テスト観点までつながります。
実例2: 外部API失敗はリトライ可否を分ける
外部APIの失敗は、利用者の入力ミスとは違います。決済API、メール配信、CRM、Google Sheets、Slackなどは、こちらのコードが正しくても落ちます。ここで大切なのは、すべてを「外部連携失敗」とまとめず、リトライしてよい失敗か、リトライしてはいけない失敗かを分けることです。
上のcallBillingApiでは、503のような一時的な失敗はretryable: trueにし、想定外のレスポンス形はretryable: falseにしています。レスポンス形が違うのにリトライを続けると、同じ失敗を繰り返してログとキューを汚します。逆に一時的な通信失敗を即失敗にすると、少し待てば成功した処理まで落としてしまいます。
外部APIでは、タイムアウトも必ず決めます。無期限に待つ処理は、ユーザーの画面、ワーカー、サーバーレス関数の実行枠を詰まらせます。MDNのResponse.okのような公式情報でレスポンス判定を確認し、Node.jsや実行環境で使えるAbortControllerも合わせて確認してください。
実例3: ジョブ/バッチ失敗は再実行できる形で残す
ジョブやバッチは、画面のAPIよりも失敗の見え方が遅れます。夜間の集計、CSV取込、請求書生成、メール送信、DMクロールのような処理では、ユーザーが画面で待っていない代わりに、失敗が放置されやすくなります。
runJobでは、失敗した処理を指定回数だけ再試行し、最後にjobとして分類します。実運用では、この結果をログ、通知、失敗キュー、管理画面へ流します。attemptのような詳細情報を残すと、Claude Codeに「なぜ2回までは失敗し、3回目で成功したのか」を調べさせやすくなります。
ジョブの失敗は「再実行できるか」が最重要です。同じCSVをもう一度取り込んでも二重登録にならないか、メールを二重送信しないか、課金処理を再実行しても安全か。ここを決めずにリトライだけ入れると、障害対応のつもりが事故の増幅になります。
よくある落とし穴
一つ目は、catch (error) { return null; }で失敗を消すことです。失敗が消えると、Claude Codeも人間も原因を追えません。少なくともcode、kind、retryable、causeのどれかを残します。
二つ目は、ユーザー向けメッセージに内部情報を出すことです。SQL、環境変数名、APIキーの一部、スタックトレースをレスポンスに混ぜると、セキュリティ事故につながります。詳しくはClaude Codeでセキュリティ監査を自動化するも確認してください。
三つ目は、リトライを万能薬にすることです。バリデーションエラーや不正なレスポンス形をリトライしても直りません。リトライするのは、一時的なネットワーク失敗、レート制限、ロック競合のような「時間で回復しうる失敗」だけです。
四つ目は、型だけ作ってレビュー観点を残さないことです。TypeScriptの判別可能なUnionや型の絞り込みは強力ですが、チームのルールがなければ別の人がすぐにthrow new Error("failed")へ戻してしまいます。公式のTypeScript Narrowingを参照しつつ、プロジェクトのAGENTS.mdやCLAUDE.mdに「境界で分類する」と書いておくのが効きます。
Claude Codeにレビューさせるプロンプト
実装後は、次のプロンプトでClaude Codeにレビューさせます。ポイントは、コードの美しさではなく、運用時に復旧できるかを聞くことです。
このPRのエラー処理をレビューしてください。
観点:
1. API入力、外部API、ジョブ/バッチの境界で失敗を分類できているか
2. ユーザーが直せる失敗を500にしていないか
3. リトライしてよい失敗と、してはいけない失敗を分けているか
4. レスポンスにスタックトレース、SQL、秘密情報を出していないか
5. ログには調査に必要なcode、kind、attempt、causeが残っているか
6. 既存テストに、正常系だけでなく失敗系が3つ以上あるか
不足があれば、最小差分の修正案とテスト案を出してください。
テスト駆動で進める場合は、Claude CodeでTDDを実践する方法と相性がよいです。エラーを直した後の調査手順はClaude Codeデバッグテクニック完全ガイドに寄せると、記事同士の導線も自然になります。
公式情報で確認するポイント
Expressを使う場合は、エラーハンドリング用ミドルウェアを最後に置く設計が基本です。詳しくはExpress error handlingを確認してください。Node.js標準のテストで失敗系を固定するならNode.js test runnerが参考になります。Claude Code側の設定やメモリ運用はClaude Code overviewを起点に確認すると安全です。
公式リンクを記事に入れる理由は、読者に安心してもらうためだけではありません。Claude Codeへレビューを依頼するときにも「この公式仕様を前提に確認して」と言えるため、古いブログ記事や曖昧な記憶に引っ張られにくくなります。
CTA: エラー処理は研修と教材に向いている
エラー処理は、Claude Code活用の中でも研修価値が高いテーマです。成功系の実装はデモで見せやすい一方、失敗系、ログ、リトライ、セキュリティ、テストは現場ごとの癖が出ます。ここをテンプレート化できると、チーム全体のレビュー品質が上がります。
まず自分で試すなら無料チートシートから始めてください。プロンプト、レビュー観点、AGENTS.mdの書き方までまとめて整えたい場合は商品一覧が近道です。既存リポジトリのエラー処理をClaude Code前提で直したい場合は、Claude Code研修・導入相談で、API、外部連携、ジョブの3境界を一緒に棚卸しできます。
この記事の内容を実際に試した結果
Masaの手元では、入力検証、外部API、バッチ失敗を同じAppError系の形に寄せたことで、Claude Codeへの依頼がかなり短くなりました。以前は「このエラーをいい感じに直して」と書いていましたが、今は「validationは400、externalはretryableを見て再試行、jobはattemptを残す」と指定できます。結果として、修正後のレビューで見るべき場所がレスポンス、ログ、テストの3点に絞られました。まだ万能ではありませんが、少なくとも500だらけの状態より、運用で追えるコードに近づきました。
無料PDF: Claude Code はじめてのチートシート
まずは無料PDFで基本コマンドと最初の使い方をまとめて確認してください。登録後はそのままテンプレート集や導入相談にも進めます。
スパムは送りません。登録情報は厳重に管理します。
Claude Codeを仕事で使える形にしませんか?
無料PDFで基礎を固めたあと、すぐ使えるテンプレート集で試し、必要なら業務自動化や導入相談まで進められます。
この記事を書いた人
Masa
Claude Codeの実務活用、導入設計、収益導線改善を検証しているエンジニア。10言語の技術メディアを運営中。
関連書籍・参考図書
この記事のテーマに関連する書籍を楽天ブックスで探せます。
※ 当サイトは楽天市場のアフィリエイトプログラムに参加しています。上記リンクから商品をご購入いただくと、運営者に紹介料が支払われる場合があります。
関連記事
Claude Code Permission Receipt Pattern: 許可、証拠、ロールバックを残す運用
Claude Codeの権限運用を安全にする permission receipt。許可範囲、承認待ち、検証コマンド、CTA導線を記録します。
Claude CodeとCodex、結局どっち?事故らない“併用”の現実解
OpenAIのCodexとClaude Code、どっちが得意でどっちに任せる?両方を安全に併用する作業分担と権限・検証のワークフローを、僕の失敗談つきで解説します。
Claude Codeサブエージェント実装ガイド: 記事・コード作業を安全に並列委譲する方法
Claude Codeサブエージェントで記事・コード作業を安全に並列化する実装ガイド。委譲基準、プロンプト、失敗例を解説。