7月1日付の Claude Code の更新情報に、短い一行がありました。streaming idle watchdog が既定で有効になり、ストリームが5分間無応答ならアボートしてリトライする、というものです。
ありがたい変更のはずでした。ただ、読んだ瞬間に頭へ浮かんだのは感謝ではなく、手元のパイプラインの断面図です。SDK が再試行する。自前のラッパーも再試行する。失敗すればスケジューラが再実行する。そこへ、頼んでいないもう一枚が加わった。再試行する層が、静かに4枚になったのです。
層が増えること自体は悪ではありません。問題は、それぞれの層が互いの存在を知らないまま「良かれと思って」やり直すことです。本稿は、実行スタックの中で再試行が起きうる場所を数え上げ、最悪ケースの試行回数を機械的に見積もり、責務を一枚に畳むまでの設計を、私自身の夜間ジョブでの整理を元に残すものです。
どの層が再試行しているのかを数える
まず棚卸しです。Claude を無人で回す典型的なスタックには、再試行の火元が少なくとも4つあります。
| 層 | 再試行の対象 | 既定の挙動 | 気づきにくさ |
|---|
| 1. Anthropic SDK | 接続エラー・429・5xx | maxRetries: 2(初回含め最大3試行)が既定 | コードに書かれないため棚卸しから漏れやすい |
| 2. 自前ラッパー | アプリ都合の失敗全般 | 自分で書いた指数バックオフ(例: 3試行) | 把握済みだが SDK 側との重複を忘れがち |
| 3. streaming idle watchdog | 5分無応答のストリーム | アボート+リトライ(7/1 から既定有効) | 設定変更なしで挙動が変わった当事者 |
| 4. スケジューラ | ジョブ全体の失敗 | 失敗時の再実行・次周期での再キック | ジョブ内から見えない最外層 |
ポイントは、この4層のうち自分が書いたのは1枚だけ、ということです。1層目は SDK の既定値、3層目はプラットフォームの既定値の変更、4層目は運用設定です。リトライ設計の大半は、自分のコードの外にあります。
なお、ここでの watchdog の試行回数や間隔は、手元のバージョンのリリースノートと実際の挙動を突き合わせて確認しておくべき「前提」です。既定値は今回のように予告なく変わることがあるため、後述する前提ログに落とします。
最悪ケースは掛け算で膨らむ
各層のリトライは足し算ではなく掛け算で合成されます。外側の1試行の中で、内側は自分の全試行を使い切るからです。
- SDK: 3試行
- ラッパー: 3試行
- watchdog: 2試行(1リトライと仮定)
- スケジューラ: 2試行
この構成での最悪ケースは 3 × 3 × 2 × 2 = 36試行です。1回のつもりで書いたタスクが、障害の夜には36回 API を叩く可能性があります。
これをコストに直します。入力12,000・出力3,000トークン程度のタスクを Sonnet 5 の導入価格($2/$10 per MTok)で回すと、1試行あたり約 $0.054。36試行なら約 $1.94 です。夜間に90本のタスクを流していれば、理論上の最悪値は一晩で約 $175。単価の安さで組んだはずの予算が、増幅で簡単に食い潰されます。
| 構成 | 最悪試行回数 | 1タスク最悪コスト | 90タスク夜間バッチ |
|---|
| 4層とも既定のまま | 36 | 約 $1.94 | 約 $175 |
| single-owner(後述・4試行) | 4 | 約 $0.22 | 約 $19 |
もう一つ深刻なのは 429 との相互作用です。過負荷への応答である 429 を4層がそれぞれ律儀にリトライすると、混雑した夜に自分だけ36倍の圧力をかける「増幅器」になります。Retry-After ヘッダーに従うバックオフの整理で書いたサーバー指示の尊重も、再試行する層が一枚に決まっていて初めて機能します。
増幅を数字にする TypeScript
層構成を宣言すると、最悪ケースの試行回数と壁時計時間を機械的に出す小さな計算機です。ジョブを組む前に一度流すだけで、感覚ではなく数字で危険度を見られます。
// retry-budget.ts — 層構成から最悪ケースを見積もる
type RetryLayer = {
name: string;
attempts: number; // 初回を含む最大試行回数
perAttemptTimeoutSec: number; // この層が1試行に許す上限
backoffSec: (retryIndex: number) => number; // i回目のリトライ前の待機
};
// 配列は「外側 → 内側」の順に並べる
const layers: RetryLayer[] = [
{ name: "scheduler", attempts: 2, perAttemptTimeoutSec: 3600, backoffSec: () => 300 },
{ name: "watchdog", attempts: 2, perAttemptTimeoutSec: 900, backoffSec: () => 0 },
{ name: "wrapper", attempts: 3, perAttemptTimeoutSec: 300, backoffSec: (i) => 10 * 2 ** i },
{ name: "sdk", attempts: 3, perAttemptTimeoutSec: 120, backoffSec: (i) => 1 + i },
];
export function worstCaseAttempts(ls: RetryLayer[]): number {
return ls.reduce((acc, l) => acc * l.attempts, 1);
}
export function worstCaseWallClockSec(ls: RetryLayer[]): number {
// 内側から畳み込む: 外側の1試行 = min(内側の合計, 自層のタイムアウト)
return ls.reduceRight((innerCost, layer) => {
const perAttempt =
innerCost > 0 ? Math.min(innerCost, layer.perAttemptTimeoutSec) : layer.perAttemptTimeoutSec;
let total = 0;
for (let i = 0; i < layer.attempts; i++) {
total += perAttempt;
if (i < layer.attempts - 1) total += layer.backoffSec(i);
}
return total;
}, 0);
}
console.log(worstCaseAttempts(layers)); // => 36
console.log((worstCaseWallClockSec(layers) / 60).toFixed(1), "min"); // => 約128.0 min
手元の構成をこの形に書き出したとき、私は最悪壁時計が2時間を超えることに初めて気づきました。15分で終わる想定のタスクが、障害時には次の周期のジョブと重なる長さまで伸びる。試行回数の増幅は、コストだけでなく同時実行の前提まで壊します。
責務を一枚に畳む — single-owner リトライ
対策の原則は一つです。「業務としての一回」を知っている層にだけ、再試行を許す。
「業務としての一回」とは、冪等キーを発行でき、完了の証拠を確認できる単位のことです。内側の層はその単位を知りません。SDK にとっての失敗は HTTP リクエストの失敗であって、「今夜このタスクは既に成果物を書き込んだか」は判定できません。だから内側は fail-fast にして、判断を外へ上げます。
手順は3つです。
- 内側を止める: SDK は
maxRetries: 0 で生成し、タイムアウトは短めに明示します
- 所有者を決める: 冪等キーを持てる最外層(多くの場合ラッパーかスケジューラのどちらか一方)だけにループを書きます
- 再試行の前に完了確認を挟む: やり直す前に「実は前の試行が成功していないか」を必ず確かめます
import Anthropic from "@anthropic-ai/sdk";
// 1) 内側の再試行を止める
const client = new Anthropic({ maxRetries: 0, timeout: 120_000 });
// 2) 再試行の所有者はこの関数だけ
export async function withSingleOwnerRetry<T>(
taskId: string, // 冪等キー(例: "site-a/2026-07-03/article-x")
fn: () => Promise<T>,
): Promise<T> {
const MAX_ATTEMPTS = 4;
for (let attempt = 1; attempt <= MAX_ATTEMPTS; attempt++) {
// 3) やり直す前に、前の試行が実は完了していないかを確認
const done = await loadCompletionRecord(taskId);
if (done) return done.result as T;
try {
const result = await fn();
await saveCompletionRecord(taskId, result); // 完了の証拠を先に永続化
return result;
} catch (err) {
if (!isRetryable(err) || attempt === MAX_ATTEMPTS) throw err;
const retryAfter = retryAfterSecFrom(err); // 429 はサーバー指示を最優先
const backoff = retryAfter ?? Math.min(60, 5 * 2 ** attempt) + Math.random() * 3;
await new Promise((r) => setTimeout(r, backoff * 1000));
}
}
throw new Error("unreachable");
}
完了確認を伴う再試行の作り方そのものは、Agent SDK の冪等ツールとアウトボックスの整理で扱った考え方と地続きです。本稿の追加点は「その所有者を一枚に限定し、他の層は明示的に無効化する」ことにあります。
ここで watchdog の扱いに一つ注意があります。watchdog の「アボート+リトライ」は最初から生成し直すため、これは再開ではなくもう一つのリトライ層です。沈黙停止を検知して受信済みテキストから続ける再開型の実装(ストリーミングの沈黙停止を検知して途中から続ける運用メモ)とは役割が違います。既定の watchdog を活かすなら、その分だけ自前ラッパーの試行回数を減らして総量を保つのが実用的です。
前提ログ — 既定値の変更を事故の前に見つける
今回の件で公式のリリースノートに書かれていない、けれど運用上いちばん効いた学びは、プラットフォームの既定値は自分の設計の前提なのに、変更が自分のリポジトリに現れないことです。コードレビューにも diff にも出ません。
そこで、ジョブの先頭で「この実行が依存している前提」を毎回ログに書き出すようにしました。
const RUNTIME_ASSUMPTIONS = {
"anthropic-sdk.maxRetries": 0,
"claude-code.streaming-idle-watchdog": "default-on / 300s idle -> abort+retry",
"scheduler.rerunsOnFailure": 1,
"retry.owner": "wrapper.withSingleOwnerRetry",
} as const;
console.log(`[assumptions] ${JSON.stringify(RUNTIME_ASSUMPTIONS)}`);
派手さのない4行ですが、効果は事故のあとに現れます。どの夜のどの実行が、どの前提の下で動いていたかをログだけで再構成できる。前提が変わった日(今回なら7月1日)を境に挙動が変わったことも、ログの並びから機械的に特定できます。
チェックリストとしては次の3点です。
- 再試行し得る層を列挙し、
worstCaseAttempts に入れて数字を見る(36のような値が出たら設計を見直します)
- 所有者以外の層の再試行を明示的に止める、または止められない層(watchdog・スケジューラ)は前提ログに記録する
- リリースノートで既定値の変更を見たら、前提ログの該当行を更新してからジョブを流す
どの層に責務を置くか — 状況別の推奨
所有者をどこに置くかは、実行形態で変わります。私の使い分けは次の通りです。
| 実行形態 | 再試行の所有者 | 他layerの扱い |
|---|
| 対話的な CLI 利用 | 人間(自分) | SDK 既定のままで支障なし。人が最外層のため増幅しない |
| 自前スクリプトの headless バッチ | ラッパー(冪等キー付き) | SDK は maxRetries: 0。watchdog は前提ログへ |
| スケジュールタスク | スケジューラの再実行 | ジョブ内は fail-fast。再実行の冒頭で完了確認を必ず挟む |
個人開発で複数サイトの生成ジョブを夜間に回している私自身、6月の終わりに一度、失敗した夜間ジョブが翌朝までに同じ成果物を二度書き込みかけたことがあります。調べると、ジョブ内の自前リトライとスケジューラ側の再実行が、互いを知らずに重なって走っていました。以来、新しいジョブを組むときは「やり直してよいのは誰か」を先に一行で書き出してから層を組むようにしています。所有者が決まっていれば、watchdog のような新入りが増えても、前提ログに一行足すだけで済みます。
まとめ — 今夜のジョブの前に数字を一度見る
次のアクションは一つだけです。自分のスタックで再試行し得る層を紙に列挙し、worstCaseAttempts に入れて数字を確認してください。その数字が意図した設計なら、そのままで大丈夫です。もし36のような値が出たら、所有者を一枚決めて残りを止める。それだけで、障害の夜のコストと同時実行の見積もりが現実に戻ります。
同じ夜間ジョブを抱える方の、点検の糸口になればうれしく思います。