Skip to content

openai-base: a caller abort from the openai client is not classified as an abort (APIUserAbortError has no name) #2096

Open

Description

@aayushbtw

TanStack AI version

@tanstack/openai-base 1.0.2 (through @tanstack/ai-openai 1.1.1), @tanstack/ai 1.2.0, openai 7.30.0. Reproduced on main at c32f641.

Framework/Library version

Node.js 24. No framework is needed.

Describe the bug and the steps to reproduce it

A caller abort that comes from the openai client is not classified as an abort. A direct adapter.chatStream() or structuredOutput() call, aborted before the response arrives, has two effects:

  • It yields RUN_ERROR with stopReason: 'error' and no code, instead of code: 'aborted' / stopReason: 'aborted'.
  • It logs openai.chatStream fatal (or openai.structuredOutput fatal) on the errors channel, which is on by default.

Through chat() this does not show, because core stops first.

Cause. On abort, the client throws new APIUserAbortError() from openai/core/error (packages/openai-base/src/client/client.ts:376). That class never sets name: in openai 7.30.0, new APIUserAbortError().name is 'Error' and constructor.name is 'APIUserAbortError'. Every abort classifier matches on name, so all of them miss it:

  • isAbortError in responses-text.ts:1272 and chat-completions-text.ts:803
  • core isAbortShapedError / toRunErrorPayload (packages/ai/src/activities/error-payload.ts:15-34): toRunErrorPayload returns { message: 'Request was aborted.' } with no code
  • utilities/activity-abort.ts:11-16, though the media activities check signal.aborted first, so they still route to onAbort

The name check landed in #527, when openai was ^6.9.1. openai 6.9.1's error also has name: 'Error', so this has never matched. A user-supplied new OpenAI() client fails the same way as createOpenAIClient(). The existing tests pass because they build a mock error with name: 'APIUserAbortError' set by hand (responses-text.test.ts:2548). @tanstack/ai-utils's own default makeAbortError names its error 'APIUserAbortError' on purpose (http/client.ts:191-193). openai-base overrides that default with the SDK class.

Related: #2090 has the same root. The client copies the SDK's types, but the adapters detect SDK shapes by SDK identity (instanceof Stream there, e.name here), and the client's output matches neither. Also related: #2092 / #2095 (the unconditional fatal log on a mid-stream abort). With only #2095's guards applied, every case below still fails, because the guard asks isAbortError, which returns false here. So the two fixes complement each other.

Expected: a caller abort before the response yields RUN_ERROR with code: 'aborted' and logs nothing on the errors channel.

Actual: stopReason: 'error', no code, and a fatal log.

Your Minimal, Reproducible Example - (Sandbox Highly Recommended)

A new file, packages/openai-base/tests/abort-before-response.test.ts. It uses the real client over a fetch that never answers, for both APIs and both client kinds. On main all 12 cases fail:

 FAIL  ... 'Chat Completions' adapter on 'createOpenAIClient()': a caller abort before the response > yields a RUN_ERROR classified as aborted
-   "code": "aborted",
-       "stopReason": "aborted",
+       "stopReason": "error",
 FAIL  ... 'Responses' adapter on 'createOpenAIClient()': a caller abort before the response > logs nothing on the errors channel
+     "❌ [tanstack-ai:errors] ❌ openai.chatStream fatal",
+       "error": { "code": undefined, "message": "Request was aborted." },
 FAIL  ... structuredOutput logs nothing on the errors channel
+     "❌ [tanstack-ai:errors] ❌ openai.structuredOutput fatal",
      Tests  12 failed (12)
Repro test
import { describe, expect, it, vi } from 'vitest'
import OpenAI from 'openai'
import { resolveDebugOption } from '@tanstack/ai/adapter-internals'
import { createOpenAIClient } from '../src/client'
import { OpenAIBaseChatCompletionsTextAdapter } from '../src/adapters/chat-completions-text'
import { OpenAIBaseResponsesTextAdapter } from '../src/adapters/responses-text'
import type { ClientOptions } from 'openai'
import type { OpenAIClientLike } from '../src/client'

type Fetch = NonNullable<ClientOptions['fetch']>

const model = 'test-model'

/** A fetch that never answers and rejects when the request aborts. */
const hangingFetch: Fetch = (_url, init) =>
  new Promise((_resolve, reject) => {
    init?.signal?.addEventListener('abort', () =>
      reject(init.signal?.reason ?? new Error('aborted')),
    )
  })

class Responses extends OpenAIBaseResponsesTextAdapter<string> {}
class Completions extends OpenAIBaseChatCompletionsTextAdapter<string> {}

const clients = [
  {
    client: 'createOpenAIClient()',
    make: () =>
      createOpenAIClient({ apiKey: 'test-key', maxRetries: 0, fetch: hangingFetch }),
  },
  {
    client: 'new OpenAI()',
    make: () => new OpenAI({ apiKey: 'test-key', maxRetries: 0, fetch: hangingFetch }),
  },
]

const apis = [
  {
    api: 'Responses',
    adapter: (client: OpenAIClientLike) =>
      new Responses(model, 'openai', client, { fetch: hangingFetch }),
  },
  {
    api: 'Chat Completions',
    adapter: (client: OpenAIClientLike) =>
      new Completions(model, 'openai', client, { fetch: hangingFetch }),
  },
]

const cases = clients.flatMap((c) => apis.map((a) => ({ ...c, ...a })))

function errorsLogger() {
  const error = vi.fn()
  const logger = resolveDebugOption({
    logger: { debug: vi.fn(), info: vi.fn(), warn: vi.fn(), error },
  })
  return { error, logger }
}

describe.each(cases)(
  '$api adapter on $client: a caller abort before the response',
  ({ make, adapter }) => {
    async function runAborted() {
      const { error, logger } = errorsLogger()
      const controller = new AbortController()
      setTimeout(() => controller.abort(), 10)
      const chunks = []
      for await (const chunk of adapter(make()).chatStream({
        threadId: 'thread-1',
        logger,
        model,
        messages: [{ role: 'user', content: 'Hi' }],
        request: { signal: controller.signal },
      })) {
        chunks.push(chunk)
      }
      return { chunks, error }
    }

    it('yields a RUN_ERROR classified as aborted', async () => {
      const { chunks } = await runAborted()
      expect(chunks.at(-1)).toMatchObject({
        type: 'RUN_ERROR',
        code: 'aborted',
        metadata: { tanstack: { stopReason: 'aborted' } },
      })
    })

    it('logs nothing on the errors channel', async () => {
      const { error } = await runAborted()
      expect(error.mock.calls).toEqual([])
    })

    it('structuredOutput logs nothing on the errors channel', async () => {
      const { error, logger } = errorsLogger()
      const controller = new AbortController()
      setTimeout(() => controller.abort(), 10)
      await expect(
        adapter(make()).structuredOutput({
          chatOptions: {
            threadId: 'thread-1',
            logger,
            model,
            messages: [{ role: 'user', content: 'Hi' }],
            request: { signal: controller.signal },
          },
          outputSchema: {
            type: 'object',
            properties: { a: { type: 'string' } },
            required: ['a'],
          },
        }),
      ).rejects.toThrow('Request was aborted.')
      expect(error.mock.calls).toEqual([])
    })
  },
)

Proposed fix

There are two parts. Both are needed: A alone passes 4 cases, B alone passes none, and A plus B passes all 12. With both, the full openai-base suite passes (477 tests) and tsc is clean.

A. Classify by class as well as name (both adapters):

+import { APIUserAbortError } from 'openai/core/error'
@@ protected runErrorPayload(error, fallbackMessage) {
+    if (this.isAbortError(error)) {
+      return { message: 'Request aborted', code: 'aborted' }
+    }
     const payload = toRunErrorPayload(error, fallbackMessage)
@@ protected isAbortError(error: unknown): boolean {
     return (
+      error instanceof APIUserAbortError ||
       e.name === 'APIUserAbortError' ||

B. Skip the fatal log for an abort in each chatStream and structuredOutput catch: chat-completions-text.ts:274 and :430, responses-text.ts:641 and :757. This is #2095's guard, applied to the catches that #2095 leaves out.

instanceof goes in the adapter, not a name fix in client.ts. Naming the error in the client would also fix core's name checks, but it would miss a user-supplied new OpenAI() client, and the repro shows that case fails too. Core can't use instanceof because it doesn't depend on openai.

The Anthropic client throws the same unnamed class (name: 'Error', checked). Its text adapter checks signal.aborted instead, so I didn't take it further here.

Do you intend to try to help solve this bug with your own PR?

Yes, I think I know how to fix it and will discuss it in the comments of this issue

Terms & Code of Conduct

  • I agree to follow this project's Code of Conduct
  • I understand that if my bug cannot be reliable reproduced in a debuggable environment, it will probably not be fixed and this issue may even be closed.

Activity

  1. added a commit that references this issue on Nov 8, 2026
    02ae7d2
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Labels

has-prAn open PR references this issuewaiting-on: maintainerThe ball is in the maintainers’ court

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions