Aborting streamObject Leaves usage as an Unhandled Rejection
※本記事にはアフィリエイトリンクを含む場合があります。内容は広告の有無に影響されません。
結論
Aborting streamObject rejects result.usage, and if the caller returns from a catch path without awaiting done, nothing observes it and Node reports an unhandled rejection.
Conclusion
Wire an AbortSignal into the Vercel AI SDK’s streamObject so it can be interrupted, and on abort result.usage — a promise that settles after the stream completes — rejects. If the caller catches the throw from elementStream and simply returns, it never awaits result.done (which wraps usage), and the rejected promise is left for nobody to observe. Node reports it as an unhandled rejection.
Symptom
To guard against a model that stops responding, the SSE handler that generates notice drafts for teachers gained a stall timer: if no element has been produced for a set period, an AbortController interrupts and the request is folded into an error (STREAM_STALL_MS).
// apps/web/lib/editor/notice-draft-sse.ts:215-245 (excerpt)
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(); // reset the stall clock on every piece of progress
// ...handle the element...
}
await result.done;
} catch {
// model/transport failure, or a stall abort (stallController.abort).
send("error", { status: 500, reason: "stream_failed", message: "応答の生成に失敗しました。" });
return;
}
When the abort (stallController.abort()) fires during the for await loop, elementStream throws and control enters catch. That path returns without reaching the final await result.done, so done — which is internally waiting on usage — is never referenced by anyone.
Cause
The streamObject side looked like this.
// packages/ai/src/model/notice-draft-stream.ts:106-124 (excerpt, pre-fix)
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 is the promise of an async function that awaits result.usage. When the stream terminates abnormally — an abort, or a transport failure — result.usage itself rejects. If the caller reaches await result.done as it does on the happy path, that rejection is caught by the surrounding try/catch. But as described, a stall abort surfaces as elementStream throwing inside the loop, and by the time the handler is in catch it returns without going through await result.done.
At that point done — waiting on an already-rejected result.usage — is a promise that nothing ever awaits or catches. Node treats unhandledRejection as an unhandled exception by default and, with no handler registered, terminates the process. The abort feature itself works correctly; the path that detects the abort simply does not wait on done, and that alone strands a rejected promise.
The fix
Attach one no-op catch to the done that stream() returns, regardless of what the caller does.
// packages/ai/src/model/notice-draft-stream.ts:132-138 (excerpt)
const done = (async () => {
const { usage } = await result.usage;
return { tokenCount: usage?.outputTokens ?? 0 };
})();
// On abort (abortSignal) or a mid-stream failure, result.usage rejects. The handler can
// exit through catch on the elementStream throw without awaiting done (stall abort, etc.),
// so attach one no-op handler to avoid an unhandledRejection. The returned done is
// unchanged and callers can still await it for the value.
done.catch(() => {});
return { elementStream: result.elementStream, done };
The important part is that done itself is still what gets returned after done.catch(() => {}). .catch() does not change the state of the original promise; it only makes the rejected state observed. On the happy path, where the caller reaches await result.done, usage is available exactly as before. The no-op only matters on paths that never await done, where it keeps a rejected promise from being reported as an unhandled rejection.
Preventing a repeat
This is the same contract that was applied when a production hang on another streaming path to Vertex (the chat assistant, #986) was fixed in three layers and review flagged the same class of unhandled rejection. Porting the stall-abort mechanism to the notice-draft SSE handler meant porting done.catch(no-op) with it, so the two paths do not diverge with the guard present on only one.
Generalised: when you add an abort path to an API that returns a promise settling after completion, attach a no-op catch on the returning side unless you can guarantee every caller awaits it. Depending on how the caller’s try/catch is shaped, an await written for the happy path can be skipped by some failure paths — and that is the kind of gap a review of the caller alone rarely catches.
よくある質問
Q1Why does result.usage reject when I abort?
usage is returned as an async promise that settles after the stream completes. Aborting via AbortSignal makes that completion path an abnormal termination rather than a normal one, so the promise waiting on usage rejects.
Q2Why does nothing catch that rejection?
On the happy path the handler reads elementStream to the end and then awaits result.done. On a stall abort, elementStream itself throws, the handler enters catch, sends an error response and returns. That path never reaches the await, so the rejected promise is left unreferenced.
Q3Does adding done.catch(() => {}) stop the caller getting usage?
No. It attaches a side-effect-free no-op handler to the same result.done object, and that object is still what gets returned. The happy path where the caller awaits result.done for usage is unchanged. It only insures against paths that never await done.
Q4Where did this pattern come from?
The same Vertex call in the chat SSE handler had a production hang fixed in three layers, and review flagged the unhandled rejection on that path at the same time. When the stall abort was ported to the notice-draft SSE handler, done.catch(no-op) was ported with it.
確認した環境
- ai (Vercel AI SDK) ^5.0.52 / @ai-sdk/google-vertex ^3.0.140
- Fixed 2026-06-21 (PR #1104 / #987). The equivalent chat-side fix (#986) was 2026-06-16
この記事の根拠
- TypeScriptファイル 106〜140行目コミット a6d437f
- TypeScriptファイル 88〜245行目コミット a6d437f
- JSONファイル 27〜29行目コミット 9f1bff4
本文の主張は、上の記録に書かれていることだけです。運用しているリポジトリは非公開のため リンクは張れませんが、どのファイルの何行目を、どのコミット時点で見て書いたかは 記事ごとに残しています。推測で書いた箇所はありません。