From bc6b032421661495912dfb17aed37a12528121ae Mon Sep 17 00:00:00 2001 From: Danny Avila Date: Tue, 23 Jun 2026 09:55:21 -0400 Subject: [PATCH] =?UTF-8?q?=F0=9F=9B=91=20refactor:=20Demote=20User=20Abor?= =?UTF-8?q?t=20Logs=20(#13904)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix: Demote user abort logging * fix: Handle abort causes * fix: Demote user-aborted agent completion to debug log The error users still saw originated in AgentClient's completion catch, which logged every caught error (including user aborts) at error level before checking the abort signal. Branch on abortController.signal.aborted so user-initiated aborts log at debug while real failures stay error-classified. Also give the handleAbortError it.each cases distinct titles. --- api/server/controllers/agents/client.js | 11 +-- api/server/middleware/abortMiddleware.js | 42 ++++++++++- api/server/middleware/abortMiddleware.spec.js | 75 ++++++++++++++++++- 3 files changed, 120 insertions(+), 8 deletions(-) diff --git a/api/server/controllers/agents/client.js b/api/server/controllers/agents/client.js index b6032530c9..ce89addce8 100644 --- a/api/server/controllers/agents/client.js +++ b/api/server/controllers/agents/client.js @@ -1450,11 +1450,12 @@ class AgentClient extends BaseClient { }); } } catch (err) { - logger.error( - '[api/server/controllers/agents/client.js #sendCompletion] Operation aborted', - err, - ); - if (!abortController.signal.aborted) { + if (abortController.signal.aborted) { + logger.debug( + '[api/server/controllers/agents/client.js #sendCompletion] Operation aborted by user', + { conversationId: this.conversationId, name: err?.name, code: err?.code }, + ); + } else { logger.error( '[api/server/controllers/agents/client.js #sendCompletion] Unhandled error type', err, diff --git a/api/server/middleware/abortMiddleware.js b/api/server/middleware/abortMiddleware.js index 35bd53c579..feed002b0e 100644 --- a/api/server/middleware/abortMiddleware.js +++ b/api/server/middleware/abortMiddleware.js @@ -15,6 +15,36 @@ 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. @@ -200,18 +230,26 @@ const handleAbort = function () { * @returns {Promise} */ const handleAbortError = async (res, req, error, data) => { + const { sender, conversationId, messageId, parentMessageId, userMessageId, partialText } = data; + if (error?.message?.includes('base64')) { logger.error('[handleAbortError] Error in base64 encoding', { ...error, stack: smartTruncateText(error?.stack, 1000), message: truncateText(error.message, 350), }); + } else if (isAbortError(error)) { + logger.debug('[handleAbortError] AI response aborted by user', { + conversationId, + code: error?.code, + name: error?.name, + message: truncateText(error?.message ?? 'AbortError', 350), + }); } else { logger.error('[handleAbortError] AI response error; aborting request:', error); } - const { sender, conversationId, messageId, parentMessageId, userMessageId, partialText } = data; - if (error.stack && error.stack.includes('google')) { + if (error?.stack && error.stack.includes('google')) { logger.warn( `AI Response error for conversation ${conversationId} likely caused by Google censor/filter`, ); diff --git a/api/server/middleware/abortMiddleware.spec.js b/api/server/middleware/abortMiddleware.spec.js index a4ce85674b..06e434065a 100644 --- a/api/server/middleware/abortMiddleware.spec.js +++ b/api/server/middleware/abortMiddleware.spec.js @@ -73,7 +73,18 @@ jest.mock('./abortRun', () => ({ abortRun: jest.fn(), })); -const { spendCollectedUsage } = require('./abortMiddleware'); +const { logger } = require('@librechat/data-schemas'); +const { sendError } = require('~/server/middleware/error'); +const { handleAbortError, spendCollectedUsage } = require('./abortMiddleware'); + +const buildAbortRequest = () => ({ + body: { + model: 'gpt-4', + }, + user: { + id: 'user-123', + }, +}); describe('abortMiddleware - spendCollectedUsage', () => { beforeEach(() => { @@ -237,3 +248,65 @@ describe('abortMiddleware - spendCollectedUsage', () => { }); }); }); + +describe('abortMiddleware - handleAbortError', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it.each([ + [ + 'native DOMException AbortError', + new DOMException('The operation was aborted', 'AbortError'), + 'AbortError', + ], + [ + 'wrapped AbortError message', + new Error('SSE stream disconnected: AbortError: The operation was aborted'), + 'Error', + ], + [ + 'cause-nested AbortError', + new Error('Request failed', { + cause: new DOMException('The operation was aborted', 'AbortError'), + }), + 'Error', + ], + ])('logs a %s as a debug event instead of an error', async (_label, error, name) => { + await handleAbortError({}, buildAbortRequest(), error, { + sender: 'AI', + conversationId: 'convo-123', + messageId: 'message-123', + parentMessageId: 'parent-123', + userMessageId: 'user-message-123', + }); + + expect(logger.error).not.toHaveBeenCalled(); + expect(logger.debug).toHaveBeenCalledWith('[handleAbortError] AI response aborted by user', { + conversationId: 'convo-123', + code: error.code, + name, + message: error.message, + }); + expect(sendError).toHaveBeenCalledTimes(1); + }); + + it('keeps unexpected generation errors classified as errors', async () => { + const error = new Error('Provider failed'); + + await handleAbortError({}, buildAbortRequest(), error, { + sender: 'AI', + conversationId: 'convo-123', + messageId: 'message-123', + parentMessageId: 'parent-123', + userMessageId: 'user-message-123', + }); + + expect(logger.error).toHaveBeenCalledWith( + '[handleAbortError] AI response error; aborting request:', + error, + ); + expect(logger.debug).not.toHaveBeenCalled(); + expect(sendError).toHaveBeenCalledTimes(1); + }); +});