fix: improve streaming error propagation and forwarded response logging (#5317) * Fix: Improve streaming error handling and forwarded response logging * Fix: fix ESLint error Strings must use singlequote quotes * fix: preserve and log forwarded stream errors * chore: narrow forwarded stream error fix scope * fix: make forwardFetchResponse awaitable and forward upstream error text * Restore original happy path handling * Remove redundant checks in forwardFetchResponse function * Don't send anything on parsing error end --------- Co-authored-by: Cohee <18619528+Cohee1207@users.noreply.github.com>

c9c652eece5f0329ace61319f87e896a65e2e68d

KKTsN <162718958+dz114879@users.noreply.github.com>

Signed
8 files changed, +96 -25Ignore whitespace
src/endpoints/backends/chat-completions.js+12 -12
@@ -372,7 +372,7 @@ async function sendClaudeRequest(request, response) {
372372
373373 if (request.body.stream) {
374374 // Pipe remote SSE stream to Express response
375375 await forwardFetchResponse(generateResponse, response);
376376 } else {
377377 if (!generateResponse.ok) {
378378 const generateResponseText = await generateResponse.text();
@@ -682,7 +682,7 @@ async function sendMakerSuiteRequest(request, response) {
682682 if (stream) {
683683 try {
684684 // Pipe remote SSE stream to Express response
685685 await forwardFetchResponse(generateResponse, response);
686686 } catch (error) {
687687 console.error('Error forwarding streaming response:', error);
688688 if (!response.headersSent) {
@@ -793,7 +793,7 @@ async function sendAI21Request(request, response) {
793793 try {
794794 const generateResponse = await fetch(API_AI21 + '/chat/completions', options);
795795 if (request.body.stream) {
796796 await forwardFetchResponse(generateResponse, response);
797797 } else {
798798 if (!generateResponse.ok) {
799799 const errorText = await generateResponse.text();
@@ -883,7 +883,7 @@ async function sendMistralAIRequest(request, response) {
883883
884884 const generateResponse = await fetch(apiUrl + '/chat/completions', config);
885885 if (request.body.stream) {
886886 await forwardFetchResponse(generateResponse, response);
887887 } else {
888888 if (!generateResponse.ok) {
889889 const errorText = await generateResponse.text();
@@ -982,7 +982,7 @@ async function sendCohereRequest(request, response) {
982982
983983 if (request.body.stream) {
984984 const stream = await fetch(apiUrl, config);
985985 await forwardFetchResponse(stream, response);
986986 } else {
987987 const generateResponse = await fetch(apiUrl, config);
988988 if (!generateResponse.ok) {
@@ -1093,7 +1093,7 @@ async function sendDeepSeekRequest(request, response) {
10931093 const generateResponse = await fetch(apiUrl + '/chat/completions', config);
10941094
10951095 if (request.body.stream) {
10961096 await forwardFetchResponse(generateResponse, response);
10971097 } else {
10981098 if (!generateResponse.ok) {
10991099 const errorText = await generateResponse.text();
@@ -1199,7 +1199,7 @@ async function sendXaiRequest(request, response) {
11991199 const generateResponse = await fetch(apiUrl + '/chat/completions', config);
12001200
12011201 if (request.body.stream) {
12021202 await forwardFetchResponse(generateResponse, response);
12031203 } else {
12041204 if (!generateResponse.ok) {
12051205 const errorText = await generateResponse.text();
@@ -1304,7 +1304,7 @@ async function sendAimlapiRequest(request, response) {
13041304 const generateResponse = await fetch(apiUrl + '/chat/completions', config);
13051305
13061306 if (request.body.stream) {
13071307 await forwardFetchResponse(generateResponse, response);
13081308 } else {
13091309 if (!generateResponse.ok) {
13101310 const errorText = await generateResponse.text();
@@ -1416,7 +1416,7 @@ async function sendElectronHubRequest(request, response) {
14161416 const generateResponse = await fetch(apiUrl + '/chat/completions', config);
14171417
14181418 if (request.body.stream) {
14191419 await forwardFetchResponse(generateResponse, response);
14201420 } else {
14211421 if (!generateResponse.ok) {
14221422 const errorText = await generateResponse.text();
@@ -1517,7 +1517,7 @@ async function sendChutesRequest(request, response) {
15171517 const generateResponse = await fetch(apiUrl + '/chat/completions', config);
15181518
15191519 if (request.body.stream) {
15201520 await forwardFetchResponse(generateResponse, response);
15211521 } else {
15221522 if (!generateResponse.ok) {
15231523 const errorText = await generateResponse.text();
@@ -1612,7 +1612,7 @@ async function sendAzureOpenAIRequest(request, response) {
16121612 const fetchResponse = await fetch(endpointUrl, config);
16131613
16141614 if (request.body.stream) {
16151615 return await forwardFetchResponse(fetchResponse, response);
16161616 }
16171617
16181618 if (fetchResponse.ok) {
@@ -2411,7 +2411,7 @@ router.post('/generate', async function (request, response) {
24112411
24122412 if (request.body.stream) {
24132413 console.info('Streaming request in progress');
24142414 return await forwardFetchResponse(fetchResponse, response);
24152415 }
24162416
24172417 if (fetchResponse.ok) {
src/endpoints/backends/kobold.js+1 -1
@@ -99,7 +99,7 @@ router.post('/generate', async function (request, response_generate) {
9999
100100 if (request.body.streaming) {
101101 // Pipe remote SSE stream to Express response
102102 await forwardFetchResponse(response, response_generate);
103103 return;
104104 } else {
105105 if (!response.ok) {
src/endpoints/backends/text-completions.js+1 -1
@@ -404,7 +404,7 @@ router.post('/generate', async function (request, response) {
404404 } else if (request.body.stream) {
405405 const completionsStream = await fetch(url, args);
406406 // Pipe remote SSE stream to Express response
407407 await forwardFetchResponse(completionsStream, response);
408408 } else {
409409 const completionsReply = await fetch(url, args);
410410
src/endpoints/novelai.js+1 -1
@@ -270,7 +270,7 @@ router.post('/generate', async function (req, res) {
270270
271271 if (req.body.streaming) {
272272 // Pipe remote SSE stream to Express response
273273 await forwardFetchResponse(response, res);
274274 } else {
275275 if (!response.ok) {
276276 const text = await response.text();
src/endpoints/speech.js+2 -2
@@ -264,7 +264,7 @@ elevenlabs.post('/synthesize', async (req, res) => {
264264 }
265265
266266 res.set('Content-Type', 'audio/mpeg');
267267 await forwardFetchResponse(response, res);
268268 } catch (error) {
269269 console.error(error);
270270 return res.sendStatus(500);
@@ -328,7 +328,7 @@ elevenlabs.post('/history-audio', async (req, res) => {
328328 }
329329
330330 res.set('Content-Type', 'audio/mpeg');
331331 await forwardFetchResponse(response, res);
332332 } catch (error) {
333333 console.error(error);
334334 return res.sendStatus(500);
src/middleware/corsProxy.js+1 -1
@@ -35,7 +35,7 @@ export default async function corsProxyMiddleware(req, res) {
3535 });
3636
3737 // Copy over relevant response params to the proxy response
3838 await forwardFetchResponse(response, res);
3939 } catch (error) {
4040 res.status(500).send('Error occurred while trying to proxy to: ' + url + ' ' + error);
4141 }
src/util.js+17 -5
@@ -704,15 +704,12 @@ export function getImages(directoryPath, sortBy = 'name', type = MEDIA_REQUEST_T
704704 * Pipe a fetch() response to an Express.js Response, including status code.
705705 * @param {import('node-fetch').Response} from The Fetch API response to pipe from.
706706 * @param {import('express').Response} to The Express response to pipe to.
707+ * @returns {Promise<void>}
707708 */
708709export async function forwardFetchResponse(from, to) {
709710 let statusCode = from.status;
710711 let statusText = from.statusText;
711712
712- if (!from.ok) {
713- console.warn(`Streaming request failed with status ${statusCode} ${statusText}`);
714- }
715-
716713 // Avoid sending 401 responses as they reset the client Basic auth.
717714 // This can produce an interesting artifact as "400 Unauthorized", but it's not out of spec.
718715 // https://www.rfc-editor.org/rfc/rfc9110.html#name-overview-of-status-codes
@@ -725,6 +722,21 @@ export function forwardFetchResponse(from, to) {
725722 to.statusCode = statusCode;
726723 to.statusMessage = statusText;
727724
725+ if (!from.ok) {
726+ try {
727+ const rawErrorText = await from.text();
728+ const detail = rawErrorText || 'Unknown error occurred';
729+
730+ console.warn(`Streaming request failed with status ${from.status} ${statusText}: ${detail}`);
731+ to.end(rawErrorText, 'utf-8');
732+ } catch {
733+ console.warn(`Streaming request failed with status ${from.status} ${statusText}: Unknown error occurred`);
734+ to.end();
735+ }
736+
737+ return;
738+ }
739+
728740 if (from.body && to.socket) {
729741 from.body.pipe(to);
730742
tests/util.test.js+61 -2
@@ -1,6 +1,31 @@
11import { afterEach, describe, test, expect, jest } from '@jest/globals';
2+import { once } from 'node:events';
3+import { PassThrough } from 'node:stream';
4+import { Response } from 'node-fetch';
25import { CHAT_COMPLETION_SOURCES } from '../src/constants';
36import { flattenSchema, forwardFetchResponse } from '../src/util';
7+
8+function createMockExpressResponse() {
9+ const response = new PassThrough();
10+ response.statusCode = 200;
11+ response.statusMessage = '';
12+
13+ return response;
14+}
15+
16+async function collectResponseBody(response) {
17+ const chunks = [];
18+
19+ response.on('data', chunk => chunks.push(Buffer.from(chunk)));
20+
21+ await once(response, 'finish');
22+
23+ return Buffer.concat(chunks).toString('utf8');
24+}
25+
26+afterEach(() => {
27+ jest.restoreAllMocks();
28+});
429
530describe('flattenSchema', () => {
631 test('should return the schema if it is not an object', () => {
@@ -105,3 +130,37 @@ describe('flattenSchema', () => {
105130 expect(flattenSchema(schema, 'some-other-api')).toEqual(expected);
106131 });
107132});
133+
134+describe('forwardFetchResponse', () => {
135+ test('should log JSON error bodies and return the original body for non-2xx streaming responses', async () => {
136+ const warnSpy = jest.spyOn(console, 'warn').mockImplementation(() => undefined);
137+ const body = JSON.stringify({ error: { message: 'Forbidden by upstream policy' }, detail: 'policy_denied' });
138+ const response = createMockExpressResponse();
139+ const bodyPromise = collectResponseBody(response);
140+
141+ await forwardFetchResponse(new Response(body, {
142+ status: 403,
143+ statusText: 'Forbidden',
144+ }), response);
145+
146+ expect(await bodyPromise).toBe(body);
147+ expect(response.statusCode).toBe(403);
148+ expect(warnSpy).toHaveBeenCalledWith(`Streaming request failed with status 403 Forbidden: ${body}`);
149+ });
150+
151+ test('should log plain text error bodies and return the original body for non-2xx streaming responses', async () => {
152+ const warnSpy = jest.spyOn(console, 'warn').mockImplementation(() => undefined);
153+ const body = 'Plain text upstream failure';
154+ const response = createMockExpressResponse();
155+ const bodyPromise = collectResponseBody(response);
156+
157+ await forwardFetchResponse(new Response(body, {
158+ status: 502,
159+ statusText: 'Bad Gateway',
160+ }), response);
161+
162+ expect(await bodyPromise).toBe(body);
163+ expect(response.statusCode).toBe(502);
164+ expect(warnSpy).toHaveBeenCalledWith(`Streaming request failed with status 502 Bad Gateway: ${body}`);
165+ });
166+});