streamObjectのabortでusage Promiseがunhandled rejectionになる
※本記事にはアフィリエイトリンクを含む場合があります。内容は広告の有無に影響されません。
結論
streamObjectをAbortSignalで中断するとresult.usageのPromiseがrejectするが、呼び出し元がcatchでdoneをawaitせずreturnする経路を通ると、そのrejectは誰にも拾われずNode.jsのunhandled rejectionになる。
結論
Vercel AI SDKのstreamObjectにAbortSignalを配線して中断できるようにすると、中断時にresult.usage(ストリーム完了後に解決するPromise)がrejectする。 呼び出し元がelementStreamのthrowをcatchしてそのままreturnする経路を通ると、result.done(usageを内包する)をawaitしないままになり、reject済みのPromiseは誰にも拾われずunhandled rejectionとして残る。
症状
無応答対策として、教員向けの連絡ドラフト生成SSEハンドラに「進捗(要素確定)が一定時間途絶えたらAbortControllerで中断してエラーに畳む」仕組み(STREAM_STALL_MS)を追加しました。
// apps/web/lib/editor/notice-draft-sse.ts:215-245 (抜粋)
const result = deps.streamClient.stream({
system: NOTICE_ASSIST_STREAM_SYSTEM,
user: buildNoticeAssistUser(masked, jstDateLabel(now), adjust),
signal: stallController.signal,
});
for await (const el of result.elementStream) {
armStall(); // 進捗があるたびストール時計をリセット。
// ...要素の処理...
}
await result.done;
} catch {
// モデル/通信障害・ストール中断(stallController.abort)。
send("error", { status: 500, reason: "stream_failed", message: "応答の生成に失敗しました。" });
return;
}
for awaitのループ中に中断(stallController.abort())が起きると、elementStream側が例外を投げてcatchに入ります。この経路では最終行のawait result.doneを通らずにreturnするため、done(内部でusageの解決を待っている)はどこからも参照されません。
原因
streamObject側の実装は次のようになっていました。
// packages/ai/src/model/notice-draft-stream.ts:106-124 (抜粋、修正前相当)
stream(req: { system: string; user: string }): NoticeDraftStreamResult {
const result = streamObject({
model, schema: noticeElementSchema,
system: req.system, prompt: req.user,
...genOptions,
});
const done = (async () => {
const { usage } = await result.usage;
return { tokenCount: usage?.outputTokens ?? 0 };
})();
return { elementStream: result.elementStream, done };
}
doneはresult.usageをawaitする非同期関数のPromiseです。中断(abortSignal)や通信障害でストリームが異常終了すると、result.usage自体がrejectします。呼び出し元(SSEハンドラ)が正常系のようにawait result.doneまで到達すれば、そのrejectはtry/catchで拾われます。しかし前述のとおり、ストール中断はfor awaitループの内側でelementStreamがthrowする形で発生し、catchに入った時点でハンドラはawait result.doneを経由せずreturnします。
この時点でdone(内部で reject 済みのresult.usageを待っている)は、生成された直後に誰からもawaitも.catch()もされないPromiseになります。Node.jsは既定でunhandledRejectionを「対処されなかった例外」として扱い、ハンドラが登録されていなければプロセスを終了させます。中断機能そのものは正しく動いていても、その中断を検知した経路がdoneを待たないだけで、reject済みのPromiseが放置されるという組み合わせです。
直し方
stream()が返すdoneに、呼び出し元の有無にかかわらず常にno-opのcatchを1つ付けました。
// packages/ai/src/model/notice-draft-stream.ts:132-138 (抜粋)
const done = (async () => {
const { usage } = await result.usage;
return { tokenCount: usage?.outputTokens ?? 0 };
})();
// 中断(abortSignal)や mid-stream 障害では result.usage が reject する。handler が elementStream の
// throw で先に catch へ抜け done を await しない経路(ストール中断等)でも unhandledRejection を出さない
// よう no-op handler を 1 つ付ける(戻り値 done は引き続き呼び出し側が await して値を取れる)。
done.catch(() => {});
return { elementStream: result.elementStream, done };
ポイントは、done.catch(() => {})を呼んだ後もそのままdone自体を戻り値として返している点です。.catch()は元のPromiseの状態を変えず、reject済みの状態を「誰かが見ている」ことにするだけの副作用です。正常系で呼び出し元がawait result.doneまで到達する経路では、これまでどおりusageを取得できます。中断などdoneをawaitしない経路が存在する場合にだけ、reject済みのPromiseがプロセスにunhandled rejectionとして残らないための保険として働きます。
再発防止
この対策は、Vertexへの別のストリーミング経路(会話AIチャット、#986)で本番のハング事故を三層で修正した際に、レビューで同種のunhandled rejectionが指摘され同時に塞がれていたものと同じ契約です。今回はストール中断の仕組みを連絡ドラフト側のSSEハンドラへ横展開するにあたり、done.catch(no-op)も一緒に移植することで、片方だけ対策が抜けた状態を避けています。
一般化すると、「完了後に解決するPromiseを返すAPI」に中断経路を追加するときは、呼び出し元がそのPromiseを必ずawaitする保証が無い限り、返す側でno-opのcatchを付けておくのが安全です。呼び出し元のtry/catchの作りによっては、正常系だけを想定して書いたawaitが異常系の一部の経路を通らないことがあり、それは呼び出し元のコードレビューだけでは気づきにくい種類の抜けです。
よくある質問
Q1なぜabortするとresult.usageがrejectするのですか?
streamObjectが返すusageは、ストリームの完了後に解決する非同期のPromiseとして実装されています。AbortSignalで中断すると、この完了経路自体が正常終了ではなく異常終了として扱われ、usageの解決を待っていたPromiseがrejectします。
Q2なぜそのrejectが誰にも拾われないのですか?
正常系ではfor-awaitでelementStreamを読み切った後にresult.doneをawaitしてusageを取得しますが、ストール検知による中断ではelementStreamの読み出し自体が例外を投げてcatchブロックに入り、そのままエラーレスポンスを送ってreturnします。doneをawaitする行を通らない経路が存在するため、reject済みのPromiseがどこからも参照されずに残ります。
Q3done.catch(() => {})を付けるだけで、呼び出し側はusageを取得できなくなりませんか?
取得できます。追加したのはresult.doneオブジェクトそのものへの副作用のないno-opハンドラで、戻り値として返すdoneは変わりません。正常系で呼び出し元がresult.doneをawaitしてusageを取得する経路はそのまま動作します。中断など、doneをawaitしない経路が存在する場合にだけ、rejectしたPromiseが放置されない保険として働きます。
Q4この対策はどこから来たのですか?
同じVertex呼び出しを持つ会話AIチャット側のSSEハンドラで、本番のハング事故(初回トークン無応答のまま固まる)を三層で修正した際に、レビューでこの経路のunhandled rejectionが指摘され同時に塞がれていました。今回はストール中断(無応答が一定時間続いたら中断する対策)を別のSSEハンドラ(連絡ドラフト)に横展開する際、同じ契約としてdone.catch(no-op)も一緒に移植しています。
確認した環境
- ai (Vercel AI SDK) ^5.0.52 / @ai-sdk/google-vertex ^3.0.140
- 2026-06-21 に対策(PR #1104 / #987)。会話AIチャット側の同型対策(#986)は2026-06-16
この記事の根拠
- TypeScriptファイル 106〜140行目コミット a6d437f
- TypeScriptファイル 88〜245行目コミット a6d437f
- JSONファイル 27〜29行目コミット 9f1bff4
本文の主張は、上の記録に書かれていることだけです。運用しているリポジトリは非公開のため リンクは張れませんが、どのファイルの何行目を、どのコミット時点で見て書いたかは 記事ごとに残しています。推測で書いた箇所はありません。