vercelAIIntegration: aborted `streamText` leaves unhandled rejections when the abort reason isn't an `AbortError` (10.75.0; 7 per abort on 11.0.0-rc.0)
@Lms24 已经在做这个了。
开始于 2026年9月21日。
评估
这个 Issue 还没有评估数据。
描述
Is there an existing issue for this?
- I have checked for existing issues
- I have reviewed the documentation
- I am using the latest SDK release
Related: #17675 (fixed in 10.37.0 by #18973). That fix suppresses these rejections by name (AI_NoOutputGeneratedError, AbortError). This report covers the same path when the abort reason has a different name, for example a string or a plain Error. The underlying unhandled promise is still created.
How do you use Sentry?
Sentry SaaS (sentry.io)
Which SDK are you using?
@sentry/node (via @sentry/hono)
SDK Version
10.75.0 (latest). Also reproduced on 10.69.0 and on 11.0.0-rc.0 (see the v11 section below).
Framework Version
ai 6.0.191, Node 24.16 and 20.20. In production we run hono with @hono/node-server 2.0.4, but the repro below needs neither.
Reproduction Example/SDK Setup
Two files plus npm i @sentry/node@10.75.0 ai@6.0.191 ("type": "module"):
// instrument.mjs
import * as Sentry from '@sentry/node';
Sentry.init({
dsn: 'https://public@o0.ingest.sentry.io/0', // any DSN; nothing is sent
tracesSampleRate: 1.0, // enables the default tracing integrations, incl. VercelAI
transport: () => ({ send: async () => ({}), flush: async () => true }),
beforeSend(event) {
const ex = event.exception?.values?.[0];
console.log(`[sentry] captured: ${ex?.type}: ${ex?.value} (mechanism: ${ex?.mechanism?.type})`);
return null;
},
});
// repro.mjs
import { streamText } from 'ai';
import { MockLanguageModelV3 } from 'ai/test';
// A model whose first response never arrives before the abort, like a real
// provider still waiting on response headers. It rejects with the signal's
// reason, which is what fetch() does when its signal aborts.
const model = new MockLanguageModelV3({
doStream: ({ abortSignal }) =>
new Promise((_, reject) => {
abortSignal.addEventListener('abort', () => reject(abortSignal.reason), { once: true });
}),
});
// Abort with a non-AbortError reason. @hono/node-server does exactly this when
// the client disconnects: abortController.abort('Client connection prematurely closed.')
const controller = new AbortController();
const reason = process.argv[2] === 'no-reason' ? undefined : 'Client connection prematurely closed.';
const result = streamText({ model, prompt: 'hi', abortSignal: controller.signal });
setTimeout(() => controller.abort(reason), 50);
// Consume the stream the way a route handler would. This is fully handled:
// textStream ends quietly on abort.
let text = '';
for await (const delta of result.textStream) text += delta;
console.log(`textStream finished (${text.length} chars)`);
await new Promise((r) => setTimeout(r, 200));
Steps to Reproduce
node --import ./instrument.mjs repro.mjs
Controls, all run on 10.75.0:
| run | result |
|---|---|
node --import ./instrument.mjs repro.mjs (string reason) |
unhandled rejection, captured |
same, with abort(new Error('client went away')) |
unhandled rejection, captured |
node repro.mjs (no Sentry) |
clean |
node --import ./instrument.mjs repro.mjs no-reason (DOMException AbortError) |
clean (suppressed by the #18973 ignore list) |
same as the first run, with integrations: (d) => d.filter((i) => i.name !== 'VercelAI') |
clean |
Expected Result
Nothing is reported. The application consumes textStream, which ends normally on abort. No application promise is left unhandled.
Actual Result
textStream finished (0 chars)
This error originated either by throwing inside of an async function without a catch block, or by rejecting a promise which was not handled with .catch(). The promise rejected with the reason:
Client connection prematurely closed.
[sentry] captured: Error: Client connection prematurely closed. (mechanism: auto.node.onunhandledrejection)
In production this shows up as an unhandled, error-level event whose stack contains only Node and onunhandledrejection.js frames, with no application code. Every client that disconnects before the model's first byte produces one.
Additional Context
Cause (10.x): in @sentry/node 10.x, src/integrations/tracing/vercelai/instrumentation.ts, the streamText proxy's success callback calls processToolCallResults(result):
function processToolCallResults(result) {
if (typeof result !== 'object' || result === null || !('content' in result)) return;
const resultObj = result;
if (!Array.isArray(resultObj.content)) return; // <- reads the getter
...
}
On a DefaultStreamTextResult, content is a getter that returns a new promise each time it is read:
get steps() { this.consumeStream(); return this._steps.promise; }
get finalStep() { return this.steps.then((steps) => steps[steps.length - 1]); }
get content() { return this.finalStep.then((step) => step.content); }
The Array.isArray check reads the getter, gets a promise, returns false, and drops it. Nothing attaches a handler. If the stream is aborted before the first step completes, _steps rejects with the abort reason, and that derived promise becomes an unhandled rejection. You can confirm this without Sentry: adding void result.content; right after streamText(...) in the repro produces the identical unhandled rejection.
Users can't work around it from application code. Each read of content creates a separate promise, so result.content.catch(() => {}) doesn't handle the one Sentry created.
Suggested fix (both versions): whenever the instrumentation reads a field of an AI SDK result that may be a promise, attach a rejection handler to that promise. Better still, don't read promise-valued getters on stream results at all. For example, skip processToolCallResults when result.content isn't a plain array without triggering the getter (check the property descriptor), or handle it as a promise:
const content = resultObj.content;
if (content && typeof content.then === 'function') {
content.then(processContent, () => {}); // stream results: process when settled, never leak
return;
}
A side effect worth noting: reading content goes through the steps getter, which calls consumeStream(), so the instrumentation also changes stream consumption behaviour for every streamText call.
v11 (11.0.0-rc.0) is affected too, and more heavily. The same repro with @sentry/node@11.0.0-rc.0 and no other changes produces 7 unhandled rejections (7 captured events) per abort instead of 1. The controls behave the same way: none without Sentry, none with VercelAI filtered out, none without tracesSampleRate, and none for a no-reason AbortError. The Proxy is gone on develop, but the new subscriber (@sentry/server-utils, integrations/vercel-ai/vercel-ai-dc-subscriber) reads several fields of the result (usage, response, providerMetadata, text / toolCalls / content, finishReason). On a StreamTextResult, each of those is a getter returning a fresh promise, so each read is another unhandled rejection. A fix aimed only at v10 would therefore miss v11.
Extending the default ignore list by name wouldn't cover this. The abort reason is whatever the caller passes to AbortController.abort(). Hono passes a string; other servers pass Errors or custom errors.
- 主要语言
- TypeScript
- 星标
- 8.7k
- 派生
- 1.9k
- 平均合并
- 1 天 18 小时
- 30 天内合并 PR
- 562
贡献指南
从这里开始
- 先读完整个 Issue,再读项目的贡献指南。
- 在 Issue 下留言说明你要接手 —— 这能避免两个人做同样的事。
- Fork 仓库,在一个分支上完成修改。
- 提交 Pull Request,并在描述里引用这个 Issue 编号。
getsentry/sentry-javascript 的其他 Issue
-
Browser Waiting for: Product Owner
难度 2/5 1-3 小时 新手友好度 85/100
getsentry/sentry-javascript#24577 · 1 条评论 ·
-
Task
难度 2/5 1-3 小时 新手友好度 78/100
getsentry/sentry-javascript#24558 · 1 条评论 ·
-
Task
难度 1/5 1 小时以内 新手友好度 90/100
getsentry/sentry-javascript#24557 · 1 条评论 ·
-
Task
难度 2/5 1-3 小时 新手友好度 84/100
getsentry/sentry-javascript#24556 · 1 条评论 ·
-
Task
难度 1/5 1-3 小时 新手友好度 90/100
getsentry/sentry-javascript#24555 · 1 条评论 ·
查看 getsentry/sentry-javascript 的全部 Issue
相似的 Issue
-
blocklist removal
难度 2/5 1-3 小时 新手友好度 65/100
MetaMask/eth-phishing-detect#296544 ·
-
难度 2/5 1-3 小时 新手友好度 70/100
pastelsky/bundlephobia#1122 ·
-
难度 2/5 1-3 小时 新手友好度 70/100
-
category/development priority/P2 scope/file-operations scope/testing type/enhancement
难度 2/5 1-3 小时 新手友好度 75/100
-
难度 2/5 1-3 小时 新手友好度 75/100