はじめに
Claude API を組み込んだバッチ処理やエージェントが、ある日突然エラーを返し始める。ログを深く見ずに「429か、じゃあリトライを仕込めば直るだろう」と判断する——この判断は、半分しか正しくありません。
429 (レート制限) は待てば解消しますが、見た目が似ている 403 (クレジット枯渇) はいくらリトライしても解消しません。実際に、クレジット枯渇を一時的なレート制限だと思い込んでリトライを続けた結果、バッチ処理が丸一日以上気づかれないまま止まっていた、という経験があります。エラーの type を見ずに HTTP ステータスだけで分岐していたのが原因でした。
この記事では、Claude API (TypeScript SDK) のエラーを正しく分類し、リトライすべきものと即座に諦めるべきものを分けて実装する手順をまとめます。
エラーコードの全体像
Anthropic SDK の例外は HTTP ステータスごとにクラスが分かれますが、403 と 500 系はそれぞれ2つの原因を1つのクラスで表現しているのが罠です。分岐には error.type を使う必要があります。
| HTTP | error.type |
retryable | 典型的な原因 |
|---|---|---|---|
| 400 | invalid_request_error |
✗ | パラメータ不正・messages のロール不整合 |
| 401 | authentication_error |
✗ | APIキー欠落/失効 |
| 403 | permission_error |
✗ | モデルへのアクセス権限不足 |
| 403 | billing_error |
✗ (リトライ無意味) | クレジット枯渇 |
| 404 | not_found_error |
✗ | モデルID誤り |
| 413 | request_too_large |
✗ | リクエストサイズ超過 |
| 429 | rate_limit_error |
✓ | RPM/TPM/TPD 超過 |
| 500 | api_error |
✓ | 一時的なサーバ障害 |
| 529 | overloaded_error |
✓ | 高負荷による過負荷 |
permission_error と billing_error はどちらも 403 で、SDK上では同じ PermissionDeniedError に分類されます。api_error と overloaded_error も同様に、どちらも InternalServerError (500系) に分類されます。HTTPステータスだけでは billing_error を検出できません。
Step 1: エラーを type ベースで分類する
まず、リトライすべきかどうかを判定する分類関数を書きます。
import Anthropic from "@anthropic-ai/sdk";
type ClaudeErrorCategory =
| "invalid_request"
| "auth"
| "billing_exhausted"
| "permission"
| "not_found"
| "rate_limited"
| "overloaded"
| "server_error"
| "connection"
| "unknown";
interface ClassifiedError {
category: ClaudeErrorCategory;
retryable: boolean;
retryAfterMs?: number;
requestId?: string;
}
function classifyClaudeError(error: unknown): ClassifiedError {
// レスポンスすら返らないネットワーク障害
if (error instanceof Anthropic.APIConnectionError) {
return { category: "connection", retryable: true };
}
if (!(error instanceof Anthropic.APIError)) {
return { category: "unknown", retryable: false };
}
const type = (error as Anthropic.APIError & { type?: string }).type;
const requestId = (error as Anthropic.APIError & { request_id?: string }).request_id;
const retryAfterHeader = error.headers?.get?.("retry-after");
switch (type) {
case "rate_limit_error":
return {
category: "rate_limited",
retryable: true,
retryAfterMs: retryAfterHeader ? Number(retryAfterHeader) * 1000 : undefined,
requestId,
};
case "overloaded_error":
return { category: "overloaded", retryable: true, requestId };
case "api_error":
return { category: "server_error", retryable: true, requestId };
case "billing_error":
// 403だがクレジット枯渇。リトライしても永久に失敗する
return { category: "billing_exhausted", retryable: false, requestId };
case "permission_error":
return { category: "permission", retryable: false, requestId };
case "authentication_error":
return { category: "auth", retryable: false, requestId };
case "not_found_error":
return { category: "not_found", retryable: false, requestId };
case "invalid_request_error":
return { category: "invalid_request", retryable: false, requestId };
default:
return { category: "unknown", retryable: false, requestId };
}
}
request_id を必ず拾っておくと、Anthropic サポートに問い合わせる際にそのまま使えます。エラー本文には req_011CSHoEeqs5C35K2UUqR7Fy のような形式で含まれています。
Step 2: retry-after を尊重したバックオフ
429 は retry-after ヘッダーで待機秒数が明示されることがあります。指定がない場合のみ指数バックオフ+ジッターにフォールバックします。
function computeBackoffMs(classified: ClassifiedError, attempt: number, baseMs = 1000): number {
if (classified.retryAfterMs !== undefined) {
return classified.retryAfterMs;
}
const exponential = baseMs * 2 ** attempt;
const jitter = Math.random() * 250;
return exponential + jitter;
}
SDK は既定で 429 / 5xx を max_retries: 2 で自動リトライします。自前でリトライ制御を行う場合は、二重にバックオフがかかって待ち時間が読めなくなるのを避けるため、クライアント生成時に new Anthropic({ maxRetries: 0 }) を指定して SDK 側の自動リトライを止めておくのが安全です。
Step 3: クレジット枯渇は「リトライしない」を明示する
ここが本題です。billing_exhausted を検知したら即座に例外を投げ、通常のリトライ経路から外します。
class ClaudeBillingExhaustedError extends Error {
constructor(requestId?: string) {
super(`Anthropicクレジットが枯渇しています (request_id: ${requestId ?? "unknown"})`);
this.name = "ClaudeBillingExhaustedError";
}
}
async function withClaudeRetry<T>(
fn: () => Promise<T>,
{ maxRetries = 5, baseDelayMs = 1000 }: { maxRetries?: number; baseDelayMs?: number } = {},
): Promise<T> {
for (let attempt = 0; ; attempt++) {
try {
return await fn();
} catch (error) {
const classified = classifyClaudeError(error);
if (classified.category === "billing_exhausted") {
throw new ClaudeBillingExhaustedError(classified.requestId);
}
if (!classified.retryable || attempt >= maxRetries) {
throw error;
}
const delayMs = computeBackoffMs(classified, attempt, baseDelayMs);
await new Promise((resolve) => setTimeout(resolve, delayMs));
}
}
}
retryable: false の分岐に紛れ込ませず、専用のエラークラスを用意しておくのがポイントです。呼び出し側で instanceof ClaudeBillingExhaustedError だけを別扱いにすれば、監視・アラート側の分岐もシンプルになります。
Step 4: 実運用に組み込む
最後に、監視への通知まで含めた最小構成です。
const client = new Anthropic({ maxRetries: 0 });
async function askClaude(prompt: string) {
try {
return await withClaudeRetry(() =>
client.messages.create({
model: "claude-opus-5",
max_tokens: 1024,
messages: [{ role: "user", content: prompt }],
}),
);
} catch (error) {
if (error instanceof ClaudeBillingExhaustedError) {
await alertOncall(`[緊急] ${error.message}`); // 課金画面へのリンクなどを含める
}
throw error;
}
}
async function alertOncall(message: string): Promise<void> {
// Slack Webhook や PagerDuty など、実運用の通知経路に差し替える
console.error(message);
}
429 は自動で待って復帰しますが、billing_exhausted はリトライループに乗せず即座に人間へエスカレーションする——この非対称性を実装に落とし込むことが、サイレント停止を防ぐ一番のポイントです。
まとめ
- Anthropic SDK の例外クラスは HTTP ステータス単位でしか分かれておらず、403(権限不足 / クレジット枯渇)と 500系(一時障害 / 過負荷)はそれぞれ
error.typeで見分ける必要がある - 429 は
retry-afterを尊重した指数バックオフでリトライして問題ない -
billing_errorはリトライ対象から明確に外し、専用の例外クラスで即座にアラートへつなげる - SDK の自動リトライ (既定2回) と自前のリトライを併用すると待ち時間が読めなくなるため、自前で制御する場合は
maxRetries: 0を指定する
エラーコードを一つの catch で握りつぶさず、type まで見て分岐する——これだけで「なぜか止まっている」を「すぐに気づいて対処できる」に変えられます。