From d53ff170ab58a9b8764471a4f14f5822677e6af0 Mon Sep 17 00:00:00 2001 From: Danny Avila Date: Wed, 2 Sep 2026 12:30:31 -0400 Subject: [PATCH] =?UTF-8?q?=F0=9F=8E=AF=20fix:=20Require=20an=20Abort-Shap?= =?UTF-8?q?ed=20Error=20and=20Spare=20Shared=20OAuth=20Flows?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Addresses both findings from the Codex review of b3c67082cd. An aborted run signal proves the turn is over, not that the rejection in hand was the cancellation — a permission, OAuth, or upstream failure can reject in the same tick a user presses Stop. Both quiet-log branches now require the error to look like an abort as well, so a real failure racing the Stop stays at error level and visible to operational alerts. `isAbortError` moves to `@librechat/api` and the copy in `abortMiddleware` is retired, keeping one implementation. Its spec pulls the real function through its partial package mock rather than asserting a stub. Forwarding the run signal also reached ActionService, where two of the three OAuth flows are keyed `userId:action_id` and so outlive the run that opened them: a second run for the same action joins the very same record, and the browser's OAuth callback reads its metadata to exchange the code. `monitorFlow` deletes the key when a waiter's signal aborts, so one Stop could strand a concurrent run and discard an authorization the user had already granted. Those two flows no longer take the signal; the run-scoped login flow, keyed by thread and run, still does. Every guard is covered by a test that fails when the guard is removed. --- api/server/middleware/abortMiddleware.js | 31 +--- api/server/middleware/abortMiddleware.spec.js | 6 + api/server/services/ActionService.js | 37 ++--- api/server/services/ActionService.spec.js | 82 ++++++++++ api/server/services/MCP.js | 11 +- api/server/services/MCP.spec.js | 70 ++++++++- packages/api/src/agents/handlers.spec.ts | 143 ++++++++++-------- packages/api/src/agents/handlers.ts | 19 ++- packages/api/src/utils/errors.spec.ts | 43 +++++- packages/api/src/utils/errors.ts | 36 +++++ 10 files changed, 346 insertions(+), 132 deletions(-) diff --git a/api/server/middleware/abortMiddleware.js b/api/server/middleware/abortMiddleware.js index 96db0b60d4..9add302e37 100644 --- a/api/server/middleware/abortMiddleware.js +++ b/api/server/middleware/abortMiddleware.js @@ -4,6 +4,7 @@ const { isEnabled, sendEvent, countTokens, + isAbortError, GenerationJobManager, recordCollectedUsage, getTransactionsConfig, @@ -16,36 +17,6 @@ const { sendError } = require('~/server/middleware/error'); const { abortRun } = require('./abortRun'); const db = require('~/models'); -/** - * @param {Error | unknown} error - * @returns {boolean} - */ -const isAbortError = (error) => { - const visited = new Set(); - let current = error; - - while (current && typeof current === 'object' && !visited.has(current)) { - visited.add(current); - - const errorName = current.name; - const errorCode = current.code; - const errorMessage = typeof current.message === 'string' ? current.message : ''; - - if ( - errorName === 'AbortError' || - errorCode === 'ABORT_ERR' || - errorMessage.includes('AbortError') || - /(?:operation|request|stream) was aborted/i.test(errorMessage) - ) { - return true; - } - - current = current.cause; - } - - return false; -}; - /** * Spend tokens for all models from collected usage. * This handles both sequential and parallel agent execution. diff --git a/api/server/middleware/abortMiddleware.spec.js b/api/server/middleware/abortMiddleware.spec.js index 20c4700421..276760162b 100644 --- a/api/server/middleware/abortMiddleware.spec.js +++ b/api/server/middleware/abortMiddleware.spec.js @@ -31,6 +31,9 @@ jest.mock('@librechat/data-schemas', () => ({ })); jest.mock('@librechat/api', () => ({ + /** Real implementation: these tests exist to verify abort classification + * itself, so mocking it would assert the mock rather than the behavior. */ + isAbortError: jest.requireActual('@librechat/api').isAbortError, countTokens: jest.fn().mockResolvedValue(100), isEnabled: jest.fn().mockReturnValue(false), sendEvent: jest.fn(), @@ -44,6 +47,9 @@ jest.mock('@librechat/api', () => ({ })); jest.mock('librechat-data-provider', () => ({ + /** Keep the module real: `@librechat/api` is partially un-mocked above and + * reads constants (`CacheKeys`, ...) from it at import time. */ + ...jest.requireActual('librechat-data-provider'), isAssistantsEndpoint: jest.fn().mockReturnValue(false), ErrorTypes: { INVALID_REQUEST: 'INVALID_REQUEST', NO_SYSTEM_MESSAGES: 'NO_SYSTEM_MESSAGES' }, })); diff --git a/api/server/services/ActionService.js b/api/server/services/ActionService.js index 85b3d45cdc..84f0a6dfb4 100644 --- a/api/server/services/ActionService.js +++ b/api/server/services/ActionService.js @@ -267,22 +267,24 @@ async function createActionTool({ config?.signal, ); logger.debug('Waiting for OAuth Authorization response', { action_id, identifier }); - const result = await flowManager.createFlow( - identifier, - 'oauth', - { - state: stateToken, - userId: userId, - client_url: metadata.auth.client_url, - redirect_uri: `${process.env.DOMAIN_SERVER}/api/actions/${action_id}/oauth/callback`, - token_exchange_method: metadata.auth.token_exchange_method, - allowedAddresses, - /** Encrypted values */ - encrypted_oauth_client_id: encrypted.oauth_client_id, - encrypted_oauth_client_secret: encrypted.oauth_client_secret, - }, - config?.signal, - ); + /** Deliberately unsignalled. This key is `userId:action_id`, so a + * second run for the same action joins this very flow, and the + * browser's OAuth callback reads its metadata to exchange the + * code. `monitorFlow` deletes the key when a waiter's signal + * aborts, so forwarding the run signal here would let one Stop + * strand the other run and discard an authorization the user + * already granted. Run-scoped flows below still take it. */ + const result = await flowManager.createFlow(identifier, 'oauth', { + state: stateToken, + userId: userId, + client_url: metadata.auth.client_url, + redirect_uri: `${process.env.DOMAIN_SERVER}/api/actions/${action_id}/oauth/callback`, + token_exchange_method: metadata.auth.token_exchange_method, + allowedAddresses, + /** Encrypted values */ + encrypted_oauth_client_id: encrypted.oauth_client_id, + encrypted_oauth_client_secret: encrypted.oauth_client_secret, + }); logger.debug('Received OAuth Authorization response', { action_id, identifier }); data.delta.auth = undefined; data.delta.expires_at = undefined; @@ -351,11 +353,12 @@ async function createActionTool({ ); const flowsCache = getLogStores(CacheKeys.FLOWS); const flowManager = getActionFlowStateManager(flowsCache); + /** Also shared across this user's runs for the action; see the + * authorization flow above for why it stays unsignalled. */ const refreshData = await flowManager.createFlowWithHandler( `${identifier}:refresh`, 'oauth_refresh', refreshTokens, - config?.signal, ); metadata.oauth_access_token = refreshData.access_token; if (refreshData.refresh_token) { diff --git a/api/server/services/ActionService.spec.js b/api/server/services/ActionService.spec.js index 73c4cf6a5a..8771eeb4b5 100644 --- a/api/server/services/ActionService.spec.js +++ b/api/server/services/ActionService.spec.js @@ -315,6 +315,88 @@ describe('createActionTool OAuth events', () => { }); }); +describe('createActionTool OAuth flow cancellation', () => { + /** + * `monitorFlow` deletes the flow key when a waiter's signal aborts. The + * authorization and refresh flows are keyed `userId:action_id`, so a second + * run for the same action joins the very same record and the browser's OAuth + * callback reads its metadata to exchange the code — one Stop must not strand + * either of them. Only the run-scoped login flow may carry the run signal. + */ + it('withholds the run signal from flows shared beyond this run', async () => { + const preparedExecutor = { + setAuth: jest.fn().mockResolvedValue(undefined), + execute: jest.fn().mockResolvedValue({ data: { ok: true } }), + }; + const requestBuilder = { + createExecutor: jest.fn(() => ({ + setParams: jest.fn(() => preparedExecutor), + })), + }; + mockFindToken.mockResolvedValue(null); + mockActionFlowManager.createFlowWithHandler.mockImplementation( + async (_flowId, _type, handler) => handler(), + ); + mockActionFlowManager.createFlow.mockResolvedValue({ + access_token: 'access-token', + refresh_token: 'refresh-token', + expires_in: 3600, + }); + + const actionTool = await createActionTool({ + userId: 'action-user', + res: {}, + action: { + action_id: 'action-1', + metadata: { + domain: 'https://api.example.com', + oauth_client_id: 'client-id', + auth: { + type: 'oauth', + authorization_url: 'https://auth.example.com/authorize', + client_url: 'https://auth.example.com/token', + scope: 'read', + }, + }, + }, + requestBuilder, + encrypted: { + oauth_client_id: 'encrypted-client-id', + oauth_client_secret: 'encrypted-client-secret', + }, + streamId: 'action-oauth-stream', + jobCreatedAt: 1234, + }); + + const signal = new AbortController().signal; + await actionTool._call( + {}, + { + signal, + metadata: { thread_id: 'thread-1', run_id: 'run-1' }, + toolCall: { + id: 'tool-call-1', + stepId: 'step-1', + name: 'action-tool', + type: 'tool_call', + }, + }, + ); + + const [sharedFlowId, sharedType, , sharedSignal] = + mockActionFlowManager.createFlow.mock.calls[0]; + expect(sharedFlowId).toBe('action-user:action-1'); + expect(sharedType).toBe('oauth'); + expect(sharedSignal).toBeUndefined(); + + const loginCall = mockActionFlowManager.createFlowWithHandler.mock.calls.find( + ([, type]) => type === 'oauth_login', + ); + expect(loginCall[0]).toBe('action-user:action-1:oauth_login:thread-1:run-1'); + expect(loginCall[3]).toBe(signal); + }); +}); + describe('validateAndUpdateTool', () => { const mockReq = { user: { id: 'user123' } }; diff --git a/api/server/services/MCP.js b/api/server/services/MCP.js index 444a353951..4635b4a703 100644 --- a/api/server/services/MCP.js +++ b/api/server/services/MCP.js @@ -40,6 +40,7 @@ const { containsGraphTokenPlaceholder, createAuthIdentityContext, isOAuthServer, + isAbortError, OpenIDReauthRequiredError, } = require('@librechat/api'); const { @@ -1308,10 +1309,12 @@ function createToolInstance({ } return result; } catch (error) { - /** A user Stop aborts every in-flight call at once. The rejection that - * follows is the cancellation working, so it must not reach error-level - * operational alerts; the wrapping below still reports it to the turn. */ - if (config?.signal?.aborted === true) { + /** A user Stop aborts every in-flight call at once, and that rejection is + * the cancellation working, so it must not reach error-level operational + * alerts; the wrapping below still reports it to the turn. The error has + * to look like an abort as well: a permission, OAuth, or upstream failure + * can reject in the same tick as the Stop and must stay visible. */ + if (config?.signal?.aborted === true && isAbortError(error)) { logger.debug( `[MCP][${serverName}][${toolName}][User: ${userId}] Tool call cancelled by user abort`, ); diff --git a/api/server/services/MCP.spec.js b/api/server/services/MCP.spec.js index 090088b39b..bd30fce319 100644 --- a/api/server/services/MCP.spec.js +++ b/api/server/services/MCP.spec.js @@ -1627,13 +1627,7 @@ describe('User parameter passing tests', () => { ({ options }) => new Promise((_resolve, reject) => { const signal = options?.signal; - signal?.addEventListener( - 'abort', - () => reject(new Error('This operation was aborted')), - { - once: true, - }, - ); + signal?.addEventListener('abort', () => reject(signal.reason), { once: true }); }), ), }); @@ -1680,6 +1674,68 @@ describe('User parameter passing tests', () => { ); }); + it('keeps a real failure racing the Stop at error level', async () => { + const mockUser = { id: 'race-user', role: 'USER' }; + const mockRes = { write: jest.fn(), flush: jest.fn() }; + const abortController = new AbortController(); + const { getRoleByName } = require('~/models'); + getRoleByName.mockResolvedValue({ + permissions: { + [PermissionTypes.MCP_SERVERS]: { + [Permissions.USE]: true, + }, + }, + }); + mockGetMCPManager.mockReturnValue({ + callTool: jest.fn( + ({ options }) => + new Promise((_resolve, reject) => { + options?.signal?.addEventListener( + 'abort', + () => reject(new Error('upstream 503 from the MCP server')), + { once: true }, + ); + }), + ), + }); + + const mcpTool = await createMCPTool({ + res: mockRes, + user: mockUser, + config: { url: 'https://race.example.com/mcp' }, + toolKey: `test-tool${D}test-server`, + provider: 'openai', + userMCPAuthMap: {}, + availableTools: { + [`test-tool${D}test-server`]: { + function: { + description: 'Cached tool', + parameters: { type: 'object', properties: {} }, + }, + }, + }, + }); + + const call = mcpTool.invoke( + {}, + { + signal: abortController.signal, + configurable: { user: mockUser }, + metadata: { provider: 'openai', thread_id: 'thread-1', run_id: 'run-1' }, + toolCall: {}, + }, + ); + await new Promise((resolve) => setImmediate(resolve)); + abortController.abort(); + await expect(call).rejects.toThrow(); + await new Promise((resolve) => setImmediate(resolve)); + + expect(logger.error).toHaveBeenCalledWith( + expect.stringContaining('Error calling MCP tool'), + expect.anything(), + ); + }); + it.each(['OAuth flow initiated - return early', 'Pending OAuth flow reused - return early'])( 'preserves runtime-detected OAuth for the internal signal: %s', async (oauthSignal) => { diff --git a/packages/api/src/agents/handlers.spec.ts b/packages/api/src/agents/handlers.spec.ts index 306dfacf7c..1c17b12d59 100644 --- a/packages/api/src/agents/handlers.spec.ts +++ b/packages/api/src/agents/handlers.spec.ts @@ -310,41 +310,53 @@ describe('createToolExecuteHandler', () => { }); describe('run cancellation', () => { - it('forwards the batch abort signal into foreground tool invocations', async () => { - const capturedSignals: (AbortSignal | undefined)[] = []; - const controller = new AbortController(); - const tool = { - name: 'slow_tool', - invoke: jest.fn(async (_args: unknown, config: Record) => { - capturedSignals.push(config.signal as AbortSignal | undefined); - await new Promise((resolve, reject) => { - const signal = config.signal as AbortSignal | undefined; - if (signal == null) { - setTimeout(resolve, 50); - return; - } - signal.addEventListener('abort', () => reject(signal.reason), { once: true }); - }); - return { content: 'never' }; - }), + /** Production aborts with no reason, yielding a DOMException named + * `AbortError` — the shape every cancellation check downstream keys on. */ + function abortingTool(name = 'slow_tool') { + return { + name, + invoke: jest.fn( + (_args: unknown, config: Record) => + new Promise((_resolve, reject) => { + const signal = config.signal as AbortSignal | undefined; + if (signal == null) { + setTimeout(() => reject(new Error('never aborted')), 50); + return; + } + signal.addEventListener('abort', () => reject(signal.reason), { once: true }); + }), + ), }; + } + + function runBatch( + tool: { name: string; invoke: jest.Mock }, + request: Partial, + controller: AbortController, + ): Promise { const loadTools: ToolExecuteOptions['loadTools'] = jest.fn(async () => ({ loadedTools: [tool] as never[], })); const handler = createToolExecuteHandler({ loadTools }); - - const results = await new Promise((resolve, reject) => { - const request: ToolExecuteBatchRequest = { - toolCalls: [{ id: 'call-1', name: 'slow_tool', args: {} }] as ToolCallRequest[], + return new Promise((resolve, reject) => { + handler.handle('on_tool_execute', { + toolCalls: [{ id: 'call-1', name: tool.name, args: {} }] as ToolCallRequest[], signal: controller.signal, + ...request, resolve, reject, - }; - handler.handle('on_tool_execute', request); - setTimeout(() => controller.abort(new Error('Operation aborted by user')), 10); + } as ToolExecuteBatchRequest); + setTimeout(() => controller.abort(), 10); }); + } - expect(capturedSignals[0]).toBe(controller.signal); + it('forwards the batch abort signal into foreground tool invocations', async () => { + const controller = new AbortController(); + const tool = abortingTool(); + + const results = await runBatch(tool, {}, controller); + + expect(tool.invoke.mock.calls[0][1].signal).toBe(controller.signal); expect(results).toHaveLength(1); expect(results[0].status).toBe('error'); }); @@ -352,31 +364,8 @@ describe('createToolExecuteHandler', () => { it('logs a cancelled tool call as debug rather than a tool error', async () => { const errorSpy = jest.spyOn(logger, 'error').mockReturnValue(logger); const controller = new AbortController(); - const tool = { - name: 'slow_tool', - invoke: jest.fn( - (_args: unknown, config: Record) => - new Promise((_resolve, reject) => { - const signal = config.signal as AbortSignal; - signal.addEventListener('abort', () => reject(signal.reason), { once: true }); - }), - ), - }; - const loadTools: ToolExecuteOptions['loadTools'] = jest.fn(async () => ({ - loadedTools: [tool] as never[], - })); - const handler = createToolExecuteHandler({ loadTools }); - await new Promise((resolve, reject) => { - const request: ToolExecuteBatchRequest = { - toolCalls: [{ id: 'call-1', name: 'slow_tool', args: {} }] as ToolCallRequest[], - signal: controller.signal, - resolve, - reject, - }; - handler.handle('on_tool_execute', request); - setTimeout(() => controller.abort(new Error('Operation aborted by user')), 10); - }); + await runBatch(abortingTool(), {}, controller); expect( errorSpy.mock.calls.filter(([message]) => @@ -386,9 +375,39 @@ describe('createToolExecuteHandler', () => { }); /** - * An aborted run means the turn is over, not that this particular rejection - * was the cancellation — an unrelated failure can always race the Stop. The - * quiet-log branch must therefore never become a way around output filtering. + * An aborted run says the turn is over, not that this rejection was the + * cancellation. A genuine failure that lands in the same tick as the Stop + * must stay visible to operational logging. + */ + it('keeps an unrelated failure racing the Stop at error level', async () => { + const errorSpy = jest.spyOn(logger, 'error').mockReturnValue(logger); + const controller = new AbortController(); + const tool = { + name: 'slow_tool', + invoke: jest.fn( + (_args: unknown, config: Record) => + new Promise((_resolve, reject) => { + const signal = config.signal as AbortSignal; + signal.addEventListener( + 'abort', + () => reject(new Error('upstream 503 from the tool backend')), + { once: true }, + ); + }), + ), + }; + + await runBatch(tool, {}, controller); + + expect( + errorSpy.mock.calls.filter(([message]) => + String(message).includes('[ON_TOOL_EXECUTE] Tool slow_tool error'), + ), + ).toHaveLength(1); + }); + + /** + * The quiet-log branch must never double as a way around output filtering. */ it('still filters a tool failure that rejects after the run was aborted', async () => { const protectedValue = 'PROTECTED-CANCELLED-TOOL-OUTPUT'; @@ -405,22 +424,12 @@ describe('createToolExecuteHandler', () => { }), ), }; - const loadTools: ToolExecuteOptions['loadTools'] = jest.fn(async () => ({ - loadedTools: [tool] as never[], - })); - const handler = createToolExecuteHandler({ loadTools }); - const [result] = await new Promise((resolve, reject) => { - const request: ToolExecuteBatchRequest = { - toolCalls: [{ id: 'call-1', name: 'slow_tool', args: {} }] as ToolCallRequest[], - configurable: { req: protectedToolOutputRequest() }, - signal: controller.signal, - resolve, - reject, - }; - handler.handle('on_tool_execute', request); - setTimeout(() => controller.abort(new Error('Operation aborted by user')), 10); - }); + const [result] = await runBatch( + tool, + { configurable: { req: protectedToolOutputRequest() } }, + controller, + ); expect(result.status).toBe('error'); expect(result.errorMessage).toContain('content_filter_block'); diff --git a/packages/api/src/agents/handlers.ts b/packages/api/src/agents/handlers.ts index e4be216182..8d05885f83 100644 --- a/packages/api/src/agents/handlers.ts +++ b/packages/api/src/agents/handlers.ts @@ -78,7 +78,13 @@ import { stripIntentLabelsFromToolDefinitions, INTENT_ARG, } from './intent'; -import { getSafeErrorMetadata, logAxiosError, runOutsideTracing, truncateMiddle } from '~/utils'; +import { + isAbortError, + logAxiosError, + truncateMiddle, + runOutsideTracing, + getSafeErrorMetadata, +} from '~/utils'; import { buildSkillPrimeMessage, isSkillFilePath, SKILL_FILE_PREFIX } from './skills'; import { resolveCallerCapabilityProjectionSnapshot } from './callerCapabilities'; import { createSkillContentDigest } from './compatibility'; @@ -5998,12 +6004,13 @@ export function createToolExecuteHandler(options: ToolExecuteOptions): EventHand const { message, logContext } = getSafeToolError(toolError); /** A user Stop rejects every in-flight call at once. That is * the abort working, not a fault, so it is logged at debug. - * It only ever changes the log level: an aborted run says - * the turn is over, not that this particular rejection was - * the cancellation, so an unrelated failure racing the Stop - * must still take the same filtering and result path. */ + * An aborted run says the turn is over, not that THIS + * rejection was the cancellation, so the error must look + * like one too; an unrelated failure racing the Stop stays + * at error level. Either way the level is all that changes + * — filtering and the result shape are identical. */ const logToolFailure = (context: Record): void => { - if (runSignal?.aborted === true) { + if (runSignal?.aborted === true && isAbortError(toolError)) { logger.debug( `[ON_TOOL_EXECUTE] Tool ${tc.name} cancelled by run abort`, context, diff --git a/packages/api/src/utils/errors.spec.ts b/packages/api/src/utils/errors.spec.ts index cbee85089d..63acde6738 100644 --- a/packages/api/src/utils/errors.spec.ts +++ b/packages/api/src/utils/errors.spec.ts @@ -1,4 +1,4 @@ -import { getSafeErrorMetadata } from './errors'; +import { getSafeErrorMetadata, isAbortError } from './errors'; describe('getSafeErrorMetadata', () => { it('keeps bounded diagnostic fields without serializing raw provider data', () => { @@ -51,3 +51,44 @@ describe('getSafeErrorMetadata', () => { ).toEqual({ type: 'UnknownError' }); }); }); + +describe('isAbortError', () => { + it('recognizes the DOMException a bare abort() produces', () => { + const controller = new AbortController(); + controller.abort(); + expect(isAbortError(controller.signal.reason)).toBe(true); + }); + + it('recognizes an abort rewrapped by an intermediate layer', () => { + const controller = new AbortController(); + controller.abort(); + const wrapped = new Error('[MCP][server][tool] tool call failed', { + cause: controller.signal.reason, + }); + expect(isAbortError(wrapped)).toBe(true); + }); + + it('recognizes the SDK message shape that only stringifies the reason', () => { + expect( + isAbortError(new Error('MCP error -32001: AbortError: This operation was aborted')), + ).toBe(true); + }); + + it('does not treat an unrelated failure as a cancellation', () => { + expect(isAbortError(new Error('upstream 503 from the tool backend'))).toBe(false); + expect(isAbortError(Object.assign(new Error('forbidden'), { code: 'EPERM' }))).toBe(false); + }); + + it('terminates on a cyclic cause chain', () => { + const first = new Error('first') as Error & { cause?: unknown }; + const second = new Error('second') as Error & { cause?: unknown }; + first.cause = second; + second.cause = first; + expect(isAbortError(first)).toBe(false); + }); + + it('tolerates non-error values', () => { + expect(isAbortError(undefined)).toBe(false); + expect(isAbortError('aborted')).toBe(false); + }); +}); diff --git a/packages/api/src/utils/errors.ts b/packages/api/src/utils/errors.ts index 38073c97eb..f43e0354e1 100644 --- a/packages/api/src/utils/errors.ts +++ b/packages/api/src/utils/errors.ts @@ -38,3 +38,39 @@ export function getSafeErrorMetadata(error: unknown): SafeErrorMetadata { ...(status !== undefined && { status }), }; } + +/** + * Whether a caught error is a cancellation rather than a genuine failure. + * + * An aborted signal on its own proves only that the run is over: a permission, + * OAuth, or upstream error can reject in the same tick a user presses Stop, and + * treating those as cancellations hides real faults from operational alerts. + * Callers that want to quiet a cancellation should require this as well as the + * signal state. Walks `cause` because intermediate layers rewrap the rejection. + */ +export function isAbortError(error: unknown): boolean { + const visited = new Set(); + let current: unknown = error; + + while (current != null && typeof current === 'object' && !visited.has(current)) { + visited.add(current); + + const name = readProperty(current, 'name'); + const code = readProperty(current, 'code'); + const messageValue = readProperty(current, 'message'); + const message = typeof messageValue === 'string' ? messageValue : ''; + + if ( + name === 'AbortError' || + code === 'ABORT_ERR' || + message.includes('AbortError') || + /(?:operation|request|stream) was aborted/i.test(message) + ) { + return true; + } + + current = readProperty(current, 'cause'); + } + + return false; +}