2014 年から個人開発でアプリを公開し続け、累計 5,000 万ダウンロードに育った頃から、私自身のワークフローの裏側でも自動化を増やしてきました。直近の 5 ヶ月は、Dolice Labs の 4 つの技術ブログと、別フォルダの Blog 系 2 サイトを合わせた 6 サイトを Cowork のスケジュールタスク経由で並行運用しています。中心にあるのは Claude API、外側にあるのは GitHub と Cloudflare Workers です。
並行運用を始めて少し経った頃、ある時間帯に限ってタスクの完了時刻が想定より大きくずれることに気づきました。02:00 JST に走らせている記事生成タスクが、ある夜だけ 02:38 まで終わらない。ログを覗いてみると、API そのものは応答していたのに、リトライが想定外の回数まで膨らんでいました。これが、本記事で扱う「スケジュール運用における tail latency と、それが引き起こすリトライストーム」の入口です。
6 サイト並行運用で気づいた違和感
最初の数週間は、p50(中央値)だけを見て満足していました。1 タスクあたりの平均完了時間は安定していて、グラフだけ見れば異常はない。私の現代美術の制作と同じで、宮大工だった祖父から受け継いだ「手を動かして確かめる」という感覚を信じてログを掘り始めたのは、p50 ではなく p95、p99、そして「単発で 60 秒を超えた瞬間」のサンプルを並べてからでした。
ここから見えてきたのは、Claude API のレスポンス自体が遅いというより、リトライ機構が「遅いレスポンス」を「失敗」と誤認して、追加の負荷をこちらから生んでいるという構造でした。AdMob の収益記事を書く合間に 50 万 DL のアプリ運用で見てきた「自分が踏んだ罠の一番多いパターン」と、ほぼ同じ。クライアント側のリトライ設計が遅延の主因になっている、というやつです。
14 日分のログから見えた p95/p99 の輪郭
観測の前提を揃えるために、まず生のデータ点を共有しておきます。下記は 6 サイト合計の 14 日分(2026-05-12 〜 2026-05-25)、Claude API 呼び出し 1,842 件を集計した数字です。すべて単発呼び出し(モデルは claude-sonnet と claude-haiku の混在、ストリーミングなし)。
| 指標 | 値 |
| 成功呼び出し数 | 1,789(97.1%) |
| 失敗呼び出し数 | 53(2.9%) |
| 完了時間 p50 | 7.4 秒 |
| 完了時間 p95 | 19.8 秒 |
| 完了時間 p99 | 41.2 秒 |
| 完了時間 最大 | 187 秒(外れ値) |
| 平均リトライ回数(成功時) | 0.18 回 |
| 平均リトライ回数(失敗時) | 4.6 回 |
p99 が 41 秒というのは、単発の呼び出しとしては十分許容範囲です。問題は、失敗時の平均リトライ回数が 4.6 回まで膨らんでいたこと。1 回の API 失敗あたり、5 回近く同じリクエストを投げ直していたことになります。これが私の言う「リトライストーム」です。
tail latency が爆発する 3 つのパターン
14 日分のログを並べると、p99 を超えるサンプルは大きく 3 種類に分類できました。これは私の運用環境固有のものではなく、Claude API を本番のスケジュール駆動ワークロードで使う場面で再現性のあるパターンです。
- モデル側のジッタが大きい時間帯:JST の朝 7-9 時と夕方 18-23 時に集中。グローバルなピーク帯と重なります。レスポンス自体は返ってくるが、レイテンシのばらつきが普段の 3〜4 倍になります。
- クライアント側のリトライ衝突:同一エンドポイントへ複数のスケジュールタスクが近接時刻でぶつかり、たまたま 429/529 を受けた瞬間に「全タスクが揃ってリトライ」という共振が起きる。私のケースでは 02:00 / 02:45 / 04:00 / 04:45 に集中していたとき、04:00 周辺で再現しました。
- 長い system prompt + 大きい入力の組み合わせ:プロンプトキャッシュが効かない初回呼び出しのとき、入力トークン数が一定値を超えると tail が伸びる。私の経験では、入力 12,000 トークンを超えたあたりからカーブが折れ曲がります。
ガードレール 1: ジッタ付き指数バックオフ
リトライストームを止めるための最初の手は、リトライ間隔に明示的なジッタを入れることです。私が現在運用している実装はこうなっています。
// retry.ts
type RetryConfig = {
maxAttempts: number;
baseDelayMs: number;
maxDelayMs: number;
jitterRatio: number; // 0.0 〜 1.0
};
const DEFAULT: RetryConfig = {
maxAttempts: 3, // 4.6 回 → 3 回上限に絞る
baseDelayMs: 1_000,
maxDelayMs: 30_000,
jitterRatio: 0.5,
};
export async function retryWithJitter<T>(
fn: (attempt: number) => Promise<T>,
cfg: Partial<RetryConfig> = {},
): Promise<T> {
const c = { ...DEFAULT, ...cfg };
let lastErr: unknown;
for (let attempt = 1; attempt <= c.maxAttempts; attempt++) {
try {
return await fn(attempt);
} catch (err) {
lastErr = err;
if (attempt === c.maxAttempts) break;
if (!isRetryable(err)) break;
const expo = Math.min(
c.baseDelayMs * Math.pow(2, attempt - 1),
c.maxDelayMs,
);
const jitter = expo * c.jitterRatio * Math.random();
const wait = expo + jitter - (expo * c.jitterRatio) / 2;
await sleep(wait);
}
}
throw lastErr;
}
function isRetryable(err: unknown): boolean {
if (!err || typeof err !== "object") return false;
const status = (err as { status?: number }).status;
return status === 429 || status === 529 || (status !== undefined && status >= 500);
}
const sleep = (ms: number) =>
new Promise((resolve) => setTimeout(resolve, ms));
ポイントは 3 つあります。一つ目はリトライ上限を 3 回に絞ったこと。4.6 回まで膨らんでいた背景には「とりあえず 5 回」「とりあえず 10 回」という安全側の設定が積み重なっていたことがあり、最大値の引き下げは想像以上に効果がありました。二つ目はジッタを「期待値中心の対称揺らぎ」にしたこと。一般的な full jitter は便利ですが、同一プロセス内で複数の呼び出しが続くと、全体としてリトライ間隔が短い方向に寄ることがあります。三つ目はリトライ対象を 429 / 529 / 5xx に厳格化したこと。400 系のリクエストエラーまで投げ直していた古い実装が、無駄なリトライの主因の一つでした。
ガードレール 2: バジェット制御で「投げ続けない」設計に
リトライ上限だけでは、同時並行で走る別タスクが同じ API を叩き続ける状況には対応しきれません。1 タスクあたりのリトライは抑えても、6 サイト分が同時に再試行を始めれば、結局システム全体としてはストームになります。そこでもう 1 層、プロセス全体でのバジェット(呼び出し総量)を見張る仕組みを入れました。
// budget.ts
type BudgetState = {
windowStartMs: number;
windowSizeMs: number;
callCount: number;
maxCalls: number;
};
const STATE: Map<string, BudgetState> = new Map();
export function tryConsume(
key: string,
maxCalls: number,
windowSizeMs = 60_000,
): boolean {
const now = Date.now();
const s = STATE.get(key) ?? {
windowStartMs: now,
windowSizeMs,
callCount: 0,
maxCalls,
};
if (now - s.windowStartMs > s.windowSizeMs) {
s.windowStartMs = now;
s.callCount = 0;
}
if (s.callCount >= s.maxCalls) {
STATE.set(key, s);
return false;
}
s.callCount += 1;
STATE.set(key, s);
return true;
}
export async function withBudget<T>(
key: string,
maxCalls: number,
fn: () => Promise<T>,
): Promise<T | null> {
if (!tryConsume(key, maxCalls)) return null;
return fn();
}
1 分あたり 30 呼び出しを上限にしておき、超えたら null を返してその呼び出しを諦める設計です。記事生成のような「失敗してもまた次の機会がある」タスクには非常に有効で、無理に粘って後続のタスクまで巻き込むよりも、潔く諦めて翌スロットへ譲るほうがシステム全体の SLA は上がります。私のケースでは、ここを入れてから「特定の時間帯に全タスクが連鎖的に落ちる」という事象がきれいに消えました。
ガードレール 3: サーキットブレーカーで連鎖を断つ
ジッタとバジェットで「自分が暴走しない」設計はできましたが、API 側に明らかな不調が出ているときには、もう 1 段階強い遮断が必要です。サーキットブレーカーを薄く実装して、直近の失敗率が一定値を超えたらしばらく試行自体を止める仕組みを置きました。
// breaker.ts
type BreakerState = {
failures: number;
successes: number;
openedAt: number | null;
cooldownMs: number;
failureRatio: number; // 例: 0.5
minSamples: number; // 例: 10
};
const BREAKERS: Map<string, BreakerState> = new Map();
export function shouldAttempt(key: string): boolean {
const b = BREAKERS.get(key);
if (!b || b.openedAt === null) return true;
if (Date.now() - b.openedAt > b.cooldownMs) {
b.openedAt = null;
b.failures = 0;
b.successes = 0;
return true;
}
return false;
}
export function recordResult(key: string, ok: boolean) {
const b = BREAKERS.get(key) ?? {
failures: 0,
successes: 0,
openedAt: null,
cooldownMs: 60_000,
failureRatio: 0.5,
minSamples: 10,
};
if (ok) b.successes += 1;
else b.failures += 1;
const total = b.successes + b.failures;
if (total >= b.minSamples && b.failures / total >= b.failureRatio) {
b.openedAt = Date.now();
}
BREAKERS.set(key, b);
}
特筆すべき点は cooldownMs と minSamples のバランスです。最初は 5 サンプルで開く設定にしていたところ、たまたま続いた 5xx 連続で過剰に閉じてしまい、その後のスケジュールが軒並み空振りになる事故が起きました。今は 10 サンプルを溜めてから判定するように切り替え、cooldown も 60 秒に短くしています。閉じすぎず、開けすぎず、現場のワークロードに合わせる必要がある部分です。
スケジュール側でリトライ衝突を作らない
クライアント側のガードレールを 3 層にしても、スケジューラ自体が同時刻に大量のタスクを撃ち込むと、結局は同じ瞬間に同じ API を叩くことになります。Cowork のスケジュールタスクで運用するときは、以下のように時刻をずらすのが効果的でした。
- 各サイト・各カテゴリで分単位の余白を取る(私の場合は 02:00 / 02:45 / 04:00 / 04:45 と 45 分の段差)
- プライム帯(朝 7-9 / 夕 18-23)を意図的に避ける
- 同じ API キーを使うタスクは 15 分以上空ける
オフピーク分散の具体的なスケジュール例は本ブログでも何度か触れていて、Antigravity Lab・Gemini Lab・Rork Lab を含めた 4 サイト全体のスケジュール表は CLAUDE.md の「自動運用体制」セクションにまとめてあります。スケジュール側で衝突を作らないことは、クライアント側のリトライ設計よりも先に効く改善です。
観測 → 設計 → 比較:ガードレール導入後の数字
最初に挙げた 14 日分の集計と、ガードレール導入後の直近 14 日(2026-05-26 を含む観測)を並べると、こうなります。
| 指標 | 導入前 | 導入後 | 変化 |
| p50 | 7.4 秒 | 7.1 秒 | -4% |
| p95 | 19.8 秒 | 14.2 秒 | -28% |
| p99 | 41.2 秒 | 21.6 秒 | -48% |
| 最大値 | 187 秒 | 62 秒 | -67% |
| 失敗時の平均リトライ回数 | 4.6 回 | 1.9 回 | -59% |
| 失敗率 | 2.9% | 1.2% | -59% |
| 「諦めた」呼び出し(バジェット枯渇 + ブレーカー開) | 0 | 17 件 | (新規) |
p50 はほとんど変わっていません。これが大事なところで、ガードレールが「成功している呼び出しの速度」を犠牲にせず、tail だけを削っている証拠になります。p99 が 48% 落ちたのは、無駄なリトライが約 59% 減ったことが直接効いています。
新たに発生した「諦めた呼び出し 17 件」は、バジェット枯渇 8 件・ブレーカー開放 9 件で構成されていて、いずれも翌スロットで自然に成功しています。記事生成のようなバッチワークロードでは、これが「正しい失敗」です。今諦めることで、後ろのタスクを救う設計になっています。
個人開発で本番運用するときの取捨選択
ここまでで紹介したのは、規模の大きな SaaS の事例ではなく、一人で 6 サイトを回すための最小限のガードレールです。1997 年に独学でインターネットに触れて以来、ずっと「自分が手を動かして検証する」というスタンスでやってきましたが、本番運用で大切なのは「全部を実装する」ことではなく、「観測し、3 つに絞り、捨てるものを決める」という判断だと感じています。
私が個人開発の文脈で残したのは、ジッタ付き指数バックオフ・1 分あたりのバジェット・薄いサーキットブレーカー、そしてスケジューラ側のオフピーク分散の 4 つです。逆に捨てたのは、外部メトリクス収集サービスへの連携、ML ベースの異常検知、グローバルなトークンバケットの厳密実装。やれば確実に効きますが、保守コストが運用規模に見合いません。
もし読者の方が同じく個人開発・少人数チームで Claude API をスケジュール駆動で動かしている場合、まずは p50 ではなく p95 と p99 を可視化することを推奨します。そこから自分の環境で再現するパターンを特定して、本記事の 3 層ガードレールのうち効きそうな順に 1 層ずつ入れていくのが、現実的な道のりです。
最後までお読みいただき、ありがとうございました。今夜の 02:00 のスケジュール実行ログを眺めながら、また小さく一つ削れる箇所を探してみようと思います。