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 is already working on this.
Since Sep 21, 2026.
Assessment
This issue has not been assessed yet.
Description
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.
- Dominant language
- TypeScript
- Stars
- 8.7k
- Forks
- 1.9k
- Avg merge
- 1d 18h
- Merged PRs (30d)
- 562
Contributor guide
First steps
- Read the whole issue, then the project's contributing guide.
- Comment on the issue to say you are picking it up — it saves two people doing the same work.
- Fork the repository and make your change on a branch.
- Open a pull request that references the issue number.
More from getsentry/sentry-javascript
-
Browser Waiting for: Product Owner
Difficulty 2/5 1-3 hours Newbie friendliness 85/100
getsentry/sentry-javascript#24577 · 1 comment ·
-
Task
Difficulty 2/5 1-3 hours Newbie friendliness 78/100
getsentry/sentry-javascript#24558 · 1 comment ·
-
Task
Difficulty 1/5 Under an hour Newbie friendliness 90/100
getsentry/sentry-javascript#24557 · 1 comment ·
-
Task
Difficulty 2/5 1-3 hours Newbie friendliness 84/100
getsentry/sentry-javascript#24556 · 1 comment ·
-
Task
Difficulty 1/5 1-3 hours Newbie friendliness 90/100
getsentry/sentry-javascript#24555 · 1 comment ·
All issues in getsentry/sentry-javascript
Similar issues
-
Difficulty 2/5 1-3 hours Newbie friendliness 65/100
-
Difficulty 2/5 1-3 hours Newbie friendliness 75/100
-
bug v2
Difficulty 2/5 1-3 hours Newbie friendliness 75/100
modelcontextprotocol/inspector#2458 · 1 comment ·
-
Difficulty 1/5 Under an hour Newbie friendliness 75/100
railmapgen/rmp-gallery#4068 ·
-
Mend: dependency security vulnerability status: needs triage 🕵️♀️
Difficulty 2/5 1-3 hours Newbie friendliness 70/100
carbon-design-system/ibm-products#9907 ·