Skip to content

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) #24532

Description

@bebzz06

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

  1. 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.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Labels

Projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions