結論
AI API を「主 → 予備」の順に呼ぶフォールバックを作ると、それぞれのタイムアウトが足し算になります。 Claude 60 秒+OpenAI 120 秒で、最悪 180 秒。処理全体の上限(150 秒)を超えてしまい、失敗したときの後始末が走らない恐れがありました。
対策は、呼び出し全体で 1 つの締め切り(AbortSignal)を共有することです。
- 最初に
AbortSignal.timeout(110_000)で締め切りを 1 つ作る - 各プロバイダの呼び出しでは、
AbortSignal.any()で「全体の締め切り」と「そのプロバイダ単体のタイムアウト」を合成する - 締め切りを過ぎていたら、予備は呼ばずに失敗として確定する
これで、どんな順番で失敗しても、上限より前に必ず「失敗」と確定できるようになりました。
背景
個人で作っている ReqLite という Web サービス(Next.js / App Router)で、曖昧な開発依頼の文章から要件整理シートを AI で生成しています。生成は次のような非同期ジョブです。
-
POST /api/generateが生成回数を予約して、ジョブを作る - 内部 API
/api/generate/processを fire-and-forget で呼び、AI を呼ぶ - 画面はジョブの状態をポーリングする
- AI が全滅したら、ジョブを
failedにして予約した回数を返す
4 の「失敗を確定して回数を返す」処理が大事で、これが走らないと、ユーザーは生成できなかったのに回数だけ減ることになります(二重に返さない仕組みは前に書きました)。
何が問題だったか
もともとは Promise.any([callClaude(input), callOpenAI(input)]) で、毎回 2 社を同時に呼んで速い方を採る作りでした。これだと毎回 2 社分の料金がかかるので、「主 → 予備」の順に 1 社ずつ呼ぶ形に直しました(簡略化)。
const CLAUDE_TIMEOUT_MS = 60_000;
const OPENAI_TIMEOUT_MS = 120_000;
try {
return await callClaude(input); // 内部で AbortSignal.timeout(60_000)
} catch {
return await callOpenAI(input); // 内部で AbortSignal.timeout(120_000)
}
1 つずつ見ると、どちらにもタイムアウトがあって安全そうです。ところが順番に呼ぶので、待ち時間は足し算になります。同時に呼んでいたときは「遅い方の時間」で済んでいたものが、順番にしたことで伸びたわけです。
| ケース | 最悪の待ち時間 |
|---|---|
| Claude が即エラー → OpenAI がタイムアウト | 約 120 秒 |
| Claude がタイムアウト → OpenAI が成功(遅い) | 60+α 秒 |
| Claude がタイムアウト → OpenAI もタイムアウト | 180 秒 |
ルートの上限は maxDuration = 150 です。いちばん悪いケースでは、AI の呼び出しが終わる前に処理全体の上限が来てしまい、catch 節にたどり着けません。 ジョブは processing のまま残り、回数も返りません。
さらに、AI のあとには「JSON が壊れていたら直す」repair 呼び出し(最大 30 秒)と DB への保存もあります。AI の呼び出しだけで 150 秒を使い切ってはいけない、ということです。
この問題は、実装をレビューに出したときに指摘されて気づきました。個別のタイムアウトだけを見ていると見落としやすいところです。
対策: 締め切りを 1 つ作って共有する
順番に呼ぶ側
export async function runProvidersInOrder<T>(
order: readonly AIProvider[],
run: (provider: AIProvider, model: string, deadline: AbortSignal | undefined) => Promise<T>,
env: Env,
options: { totalTimeoutMs?: number } = {},
): Promise<ProviderRunResult<T>> {
const attempts: ProviderAttempt[] = [];
// 全体の締め切りは最初に 1 回だけ作る
const deadline = options.totalTimeoutMs ? AbortSignal.timeout(options.totalTimeoutMs) : undefined;
for (const provider of order) {
const model = resolveModel(provider, env);
// 締め切りを過ぎていたら、予備は呼ばない
if (deadline?.aborted) {
attempts.push({ provider, model, error: new Error("AI generation timeout: overall deadline exceeded") });
break;
}
try {
const result = await run(provider, model, deadline);
return { result, provider, model, attempts };
} catch (error) {
attempts.push({ provider, model, error });
}
}
throw new AllProvidersFailedError(attempts);
}
ポイントは、deadline をループの外で 1 回だけ作ることです。ループの中で作ると、プロバイダごとに 110 秒が与えられてしまい、元の足し算に戻ります。
1 つのプロバイダを呼ぶ側
export async function callClaude(input: FormInput, model: string, signal?: AbortSignal) {
// 全体の締め切り と 単体のタイムアウト の、早い方で止まる
const requestSignal = signal
? AbortSignal.any([signal, AbortSignal.timeout(CLAUDE_TIMEOUT_MS)])
: AbortSignal.timeout(CLAUDE_TIMEOUT_MS);
const res = await fetch("https://api.anthropic.com/v1/messages", {
method: "POST",
headers: { /* 省略 */ },
body: JSON.stringify({ /* 省略 */ }),
signal: requestSignal,
});
// ...
}
AbortSignal.any() は、渡したシグナルのどれか 1 つが abort したら abort するシグナルを作ります。これで、
- Claude が単体で 60 秒かかったら、単体のタイムアウトで止まる
- 予備の OpenAI に回ったとき、残りが 50 秒しかなければ、OpenAI 単体の 120 秒を待たずに全体の締め切りで止まる
という動きになります。単体のタイムアウトは「1 つのプロバイダにかける最大時間」、全体の締め切りは「全員で使える持ち時間」と役割が分かれます。
呼び出し元
const outcome = await runProvidersInOrder(
order,
async (provider, model, deadline) =>
provider === "claude" ? callClaude(input, model, deadline) : callOpenAI(input, model, deadline),
process.env,
{ totalTimeoutMs: AI_GENERATION_TOTAL_TIMEOUT_MS },
);
110 秒の決め方
締め切りは、処理全体の上限から後ろに続く処理の時間を引いて決めました。
// 主 → 予備を順番に呼ぶときの合計の締め切り。/api/generate/process の maxDuration(150 秒)より前に
// 失敗として確定できるよう、repair(70 秒以内に始まり最大 30 秒)と保存の時間を残して 110 秒にする。
export const AI_GENERATION_TOTAL_TIMEOUT_MS = 110000;
- 処理全体の上限: 150 秒
- repair 呼び出し: 最大 30 秒(AI 呼び出しに 70 秒以上かかったら repair 自体をやめる)
- DB への保存・失敗の確定・回数の返却: 残りの約 10 秒
「AI に何秒かけられるか」ではなく、「失敗の後始末に何秒残すか」から逆算するのがコツです。
テスト
Node 22 の node --test で、外部ライブラリなしにテストしています。
test('合計の締め切りを過ぎたら予備を呼ばずに失敗する(maxDuration 内で失敗を確定できる)', async () => {
const calls = [];
await assert.rejects(
runProvidersInOrder(['claude', 'openai'], async (provider, _model, deadline) => {
calls.push(provider);
await new Promise((resolve, reject) => {
deadline.addEventListener('abort', () => reject(new Error('aborted')), { once: true });
setTimeout(resolve, 1000);
});
return 'late';
}, {}, { totalTimeoutMs: 30 }),
(error) => {
assert.ok(error instanceof AllProvidersFailedError);
assert.match(error.message, /timeout/);
return true;
}
);
// 予備(openai)は呼ばれていない
assert.deepEqual(calls, ['claude']);
});
test('締め切りの signal は各試行に同じものが渡る', async () => {
const signals = [];
await runProvidersInOrder(['claude', 'openai'], async (provider, _model, deadline) => {
signals.push(deadline);
if (provider === 'claude') throw new Error('down');
return 'ok';
}, {}, { totalTimeoutMs: 5000 });
assert.equal(signals.length, 2);
assert.equal(signals[0], signals[1]);
});
2 つ目のテストは、「ループの中で締め切りを作り直してしまう」退行を防ぐためのものです。同じシグナルのインスタンスが渡っていることを確かめています。
注意点
AbortSignal.any() は Node 20.3 以降
AbortSignal.timeout() は Node 17.3 以降、AbortSignal.any() は Node 20.3 以降で使えます。古いランタイムでは、AbortController を自分で作って両方の abort イベントをつなぐ必要があります。
本文の読み込みも止まる
fetch に渡した signal は、レスポンスヘッダが返ってきた後の res.json()(本文の読み込み)にも効きます。AI API は本文をゆっくり返すことがあるので、ここまで含めて締め切りに入るのは都合がいいです。
maxDuration が効くかは動かす場所しだい
maxDuration は Vercel などのプラットフォームが見る設定です。セルフホスト(Azure App Service など)では Next.js 自身はこの値で処理を打ち切りませんが、代わりにロードバランサ側のタイムアウトなど、別の上限があります。
どちらにしても、外から打ち切られる前に、自分で失敗を確定させるという考え方は同じです。外から打ち切られると catch も finally も走らないので、後始末は自分の締め切りの内側でやるしかありません。
同時に呼ぶ形に戻さない理由
「同時に呼べば足し算にならない」のはその通りです。ただ、AI API は呼んだ分だけ課金されるので、使わなかった方の出力にも毎回お金がかかります。どちらの出力になるかがその時の速さで決まり、品質が揃わないのも困ります。主がたまに落ちる程度なら、順番に呼んで締め切りを共有する方が釣り合いが取れていると判断しました。
まとめ
- フォールバックで順番に呼ぶと、個別のタイムアウトは足し算になる
- 全体の締め切りを
AbortSignal.timeout()でループの外で 1 回だけ作り、各呼び出しに渡す - 各呼び出しでは
AbortSignal.any()で「全体の締め切り」と「単体のタイムアウト」を合成する - 締め切りは「失敗の後始末に何秒残すか」から逆算する
- 「同じシグナルが渡っているか」をテストして、作り直しの退行を防ぐ
おわりに
この仕組みは、曖昧な開発依頼から「見積の前に聞くべきこと」を洗い出す個人開発のサービス「ReqLite」で使っています。生成結果の例は、ログインなしで見られます。