feat: Add request timeout handling to sendMessageStream and throw ETIMEDOUT on timeout.

This commit is contained in:
kevin-ramdass
2026-01-31 14:51:46 -08:00
parent b0f38104d7
commit d03b9b95b3
2 changed files with 82 additions and 9 deletions

View File

@@ -1012,6 +1012,57 @@ describe('GeminiChat', () => {
'prompt-id-thinking-budget',
);
});
it('should throw ETIMEDOUT when request times out', async () => {
// 1. Mock AbortSignal.timeout to return a manually controllable signal
const manualTimeoutController = new AbortController();
const timeoutSpy = vi
.spyOn(AbortSignal, 'timeout')
.mockReturnValue(manualTimeoutController.signal);
// 2. Mock generateContentStream to hang UNTIL aborted
vi.mocked(mockContentGenerator.generateContentStream).mockImplementation(
(request) => new Promise((resolve, reject) => {
const config = request?.config;
if (config?.abortSignal) {
if (config.abortSignal.aborted) {
reject(new Error('Aborted'));
return;
}
config.abortSignal.addEventListener('abort', () => {
reject(new Error('Aborted'));
});
}
}),
);
// 3. Start the request
const streamPromise = chat.sendMessageStream(
{ model: 'gemini-2.0-flash' },
'test timeout',
'prompt-id-timeout',
new AbortController().signal,
);
// 4. Simulate timeout by aborting the signal
// We need to wait a tick to ensure the promise starts executing
await new Promise((resolve) => setTimeout(resolve, 0));
manualTimeoutController.abort();
// 5. Assert it throws ETIMEDOUT
await expect(async () => {
for await (const _ of await streamPromise) {
// consume
}
}).rejects.toThrowError(
expect.objectContaining({
message: expect.stringContaining('Request timed out'),
code: 'ETIMEDOUT',
}),
);
timeoutSpy.mockRestore();
});
});
describe('addHistory', () => {

View File

@@ -87,6 +87,12 @@ const INVALID_CONTENT_RETRY_OPTIONS: ContentRetryOptions = {
export const SYNTHETIC_THOUGHT_SIGNATURE = 'skip_thought_signature_validator';
/**
* Timeout for API calls in milliseconds.
* Defaulting to 60 seconds (configurable in future).
*/
const TIMEOUT_MS = 60000;
/**
* Returns true if the response is valid, false otherwise.
*/
@@ -505,13 +511,18 @@ export class GeminiChat {
}
lastModelToUse = modelToUse;
// Create a timeout signal to prevent long hanging requests.
const timeoutSignal = AbortSignal.timeout(TIMEOUT_MS);
const combinedSignal = AbortSignal.any([abortSignal, timeoutSignal]);
const config: GenerateContentConfig = {
...currentGenerateContentConfig,
// TODO(12622): Ensure we don't overrwrite these when they are
// passed via config.
systemInstruction: this.systemInstruction,
tools: this.tools,
abortSignal,
abortSignal: combinedSignal,
};
let contentsToUse = isPreviewModel(modelToUse)
@@ -580,14 +591,25 @@ export class GeminiChat {
lastConfig = config;
lastContentsToUse = contentsToUse;
return this.config.getContentGenerator().generateContentStream(
{
model: modelToUse,
contents: contentsToUse,
config,
},
prompt_id,
);
try {
return await this.config.getContentGenerator().generateContentStream(
{
model: modelToUse,
contents: contentsToUse,
config,
},
prompt_id,
);
} catch (error) {
if (timeoutSignal.aborted) {
const timeoutError = new Error(
`Request timed out after ${TIMEOUT_MS}ms`,
);
(timeoutError as unknown as { code: string }).code = 'ETIMEDOUT';
throw timeoutError;
}
throw error;
}
};
const onPersistent429Callback = async (