Skip to content

Commit 9ca8bcf

Browse files
ai-sdk-factory[bot]ai-sdk-factorygr2mmachielvdw
authored
fix: gateway cancellations incorrectly reported as retryable HTTP 500 failures (#24130)
## Background Gateway caller cancellations were misreported as retryable GatewayResponseError failures with an invented HTTP 500 status. ## Root Cause asGatewayError did not recognize AbortError or TimeoutError cancellation reasons, so they reached its fallback that fabricates a 500 response. Code inspection and the local HTTP reproduction confirmed this path. ## Summary Preserve original AbortError and TimeoutError objects before gateway error conversion. Added a gateway patch changeset and removed reproduction-only artifacts. ## Testing Added cancellation identity tests for Error and DOMException objects and coverage for doGenerate and doStream. The reported declaration-test timeout passed on focused rerun; no assertions or checks were weakened. ## End-to-end Validation - `pnpm -C examples/ai-functions exec tsx src/reproduction/issue-24056.ts` via immutable replay: exited 0, classified no-longer-reproduces; local HTTP cancellation cases preserved their original errors without fabricated status or retryability. ## Related Issues Fixes #24056 Closes #24116 --------- Co-authored-by: ai-sdk-factory <308175966+ai-sdk-factory@users.noreply.github.com> Co-authored-by: Gregor Martynus <39992+gr2m@users.noreply.github.com> Co-authored-by: machielvdw <94852967+machielvdw@users.noreply.github.com>
1 parent 44a4261 commit 9ca8bcf

7 files changed

Lines changed: 117 additions & 1 deletion
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'@ai-sdk/gateway': patch
3+
---
4+
5+
fix(gateway): Preserve AbortError and TimeoutError cancellation reasons instead of wrapping them in retryable GatewayResponseError errors with status 500. Propagate these errors from speech and transcription input streams to their output streams without leaving reads pending or causing unhandled rejections.

‎packages/gateway/src/errors/as-gateway-error.test.ts‎

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,26 @@ import {
88
} from './index';
99

1010
describe('asGatewayError', () => {
11+
describe('caller cancellation', () => {
12+
it.each(['AbortError', 'TimeoutError'])(
13+
'should rethrow a %s DOMException unchanged',
14+
async name => {
15+
const error = new DOMException('Request cancelled', name);
16+
17+
await expect(asGatewayError(error)).rejects.toBe(error);
18+
},
19+
);
20+
21+
it.each(['AbortError', 'TimeoutError'])(
22+
'should rethrow an Error named %s unchanged',
23+
async name => {
24+
const error = Object.assign(new Error('Request cancelled'), { name });
25+
26+
await expect(asGatewayError(error)).rejects.toBe(error);
27+
},
28+
);
29+
});
30+
1131
describe('timeout error detection', () => {
1232
it('should detect error with UND_ERR_HEADERS_TIMEOUT code', async () => {
1333
const error = Object.assign(new Error('Request timeout'), {

‎packages/gateway/src/errors/as-gateway-error.ts‎

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,15 @@ export async function asGatewayError(
3131
error: unknown,
3232
authMethod?: 'api-key' | 'oidc',
3333
) {
34+
// Caller cancellation is not a gateway failure and must not become a retryable 500.
35+
if (
36+
(error instanceof Error ||
37+
(typeof DOMException === 'function' && error instanceof DOMException)) &&
38+
(error.name === 'AbortError' || error.name === 'TimeoutError')
39+
) {
40+
throw error;
41+
}
42+
3443
if (GatewayError.isInstance(error)) {
3544
return error;
3645
}

‎packages/gateway/src/gateway-language-model.test.ts‎

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,30 @@ describe('GatewayLanguageModel', () => {
5454
});
5555
});
5656

57+
describe.each(['doGenerate', 'doStream'] as const)(
58+
'%s cancellation',
59+
method => {
60+
it.each(['AbortError', 'TimeoutError'])(
61+
'should preserve the original %s from fetch',
62+
async name => {
63+
const error = new DOMException('Request cancelled', name);
64+
const controller = new AbortController();
65+
controller.abort(error);
66+
const model = createTestModel({
67+
fetch: vi.fn().mockRejectedValue(error),
68+
});
69+
70+
await expect(
71+
model[method]({
72+
prompt: TEST_PROMPT,
73+
abortSignal: controller.signal,
74+
}),
75+
).rejects.toBe(error);
76+
},
77+
);
78+
},
79+
);
80+
5781
describe('doGenerate', () => {
5882
function prepareJsonResponse({
5983
content = { type: 'text', text: '' },

‎packages/gateway/src/gateway-speech-model-stream.test.ts‎

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -295,6 +295,33 @@ describe('GatewaySpeechModel.doStream', () => {
295295
);
296296
});
297297

298+
it.each(['AbortError', 'TimeoutError'])(
299+
'should propagate an upstream text %s unchanged',
300+
async name => {
301+
const error = new DOMException('Text stream cancelled', name);
302+
let textController!: ReadableStreamDefaultController<SpeechModelV4StreamTextPart>;
303+
const result = await createStreamingTestModel().doStream({
304+
text: new ReadableStream<SpeechModelV4StreamTextPart>({
305+
start(controller) {
306+
textController = controller;
307+
},
308+
}),
309+
});
310+
const socket = lastSocket();
311+
socket.open();
312+
await flush();
313+
314+
const reader = result.stream.getReader();
315+
const rejection = expect(reader.read()).rejects.toBe(error);
316+
textController.error(error);
317+
318+
await rejection;
319+
await expect(reader.read()).rejects.toBe(error);
320+
await flush();
321+
expect(socket.close).toHaveBeenCalled();
322+
},
323+
);
324+
298325
it('should send an empty text part that carries a voice or provider options', async () => {
299326
await createStreamingTestModel().doStream({
300327
text: convertArrayToReadableStream<SpeechModelV4StreamTextPart>([

‎packages/gateway/src/gateway-stream-errors.ts‎

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,10 @@ export async function errorControllerWithGatewayError<PART>(
1010
error: unknown,
1111
authMethod: 'api-key' | 'oidc' | undefined,
1212
): Promise<void> {
13-
controller.error(await asGatewayError(error, authMethod));
13+
// Cancellation errors are rethrown by asGatewayError and must still reach the stream.
14+
controller.error(
15+
await asGatewayError(error, authMethod).catch(error => error),
16+
);
1417
}
1518

1619
/**

‎packages/gateway/src/gateway-transcription-model.test.ts‎

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -917,6 +917,34 @@ describe('GatewayTranscriptionModel', () => {
917917
expect(ws.close).toHaveBeenCalled();
918918
});
919919

920+
it.each(['AbortError', 'TimeoutError'])(
921+
'should propagate an upstream audio %s unchanged',
922+
async name => {
923+
const error = new DOMException('Audio stream cancelled', name);
924+
let audioController!: ReadableStreamDefaultController<Uint8Array>;
925+
const result = await createStreamingTestModel().doStream({
926+
audio: new ReadableStream<Uint8Array>({
927+
start(controller) {
928+
audioController = controller;
929+
},
930+
}),
931+
inputAudioFormat: { type: 'audio/pcm', rate: 16000 },
932+
});
933+
const ws = MockWebSocket.instances[0];
934+
ws.open();
935+
await flush();
936+
937+
const reader = result.stream.getReader();
938+
const rejection = expect(reader.read()).rejects.toBe(error);
939+
audioController.error(error);
940+
941+
await rejection;
942+
await expect(reader.read()).rejects.toBe(error);
943+
await flush();
944+
expect(ws.close).toHaveBeenCalled();
945+
},
946+
);
947+
920948
it('should cancel the audio stream when the connection fails before open', async () => {
921949
const model = createStreamingTestModel();
922950

0 commit comments

Comments
 (0)