Harden audio transcription fetch path with retries and timeout

This commit is contained in:
William Valentin
2026-02-22 19:54:58 -08:00
parent abaa9be3f1
commit 487f26e36d
6 changed files with 175 additions and 4 deletions
+20
View File
@@ -440,6 +440,26 @@ describe('transcribeAudio', () => {
expect(result).toBe('[Audio message transcription failed]');
});
it('retries transient fetch failure and succeeds on a later attempt', async () => {
vi.mocked(global.fetch)
.mockRejectedValueOnce(new TypeError('fetch failed'))
.mockResolvedValueOnce({
ok: true,
json: async () => ({ text: mockTranscript }),
} as Response);
const config: AudioTranscriptionConfig = {
endpoint: 'https://api.example.com/v1/audio/transcriptions',
apiKey: 'test-key',
model: 'test-model',
};
const result = await transcribeAudio(oggAudioAttachment, config);
expect(result).toBe(mockTranscript);
expect(global.fetch).toHaveBeenCalledTimes(2);
});
// Positive: uses Whisper-1 model by default.
it('uses whisper-1 model by default', async () => {
const config: AudioTranscriptionConfig = {
+48 -1
View File
@@ -24,6 +24,53 @@ const SUPPORTED_AUDIO_TYPES = new Set([
'audio/x-m4a',
]);
const TRANSCRIPTION_FETCH_MAX_ATTEMPTS = 3;
const TRANSCRIPTION_FETCH_TIMEOUT_MS = 45_000;
const TRANSCRIPTION_FETCH_BASE_DELAY_MS = 250;
function sleep(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}
function isTransientNetworkError(error: unknown): boolean {
const message = (error instanceof Error ? error.message : String(error)).toLowerCase();
return message.includes('fetch failed')
|| message.includes('network')
|| message.includes('timeout')
|| message.includes('timed out')
|| message.includes('econnrefused')
|| message.includes('econnreset')
|| message.includes('enotfound')
|| message.includes('ehostunreach');
}
async function fetchTranscriptionWithRetry(endpoint: string, init: RequestInit): Promise<Response> {
for (let attempt = 1; attempt <= TRANSCRIPTION_FETCH_MAX_ATTEMPTS; attempt += 1) {
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), TRANSCRIPTION_FETCH_TIMEOUT_MS);
try {
return await fetch(endpoint, { ...init, signal: controller.signal });
} catch (error) {
const timedOut = error instanceof Error && error.name === 'AbortError';
const retriable = timedOut || isTransientNetworkError(error);
const normalizedMessage = timedOut
? `request timed out after ${TRANSCRIPTION_FETCH_TIMEOUT_MS}ms`
: (error instanceof Error ? error.message : String(error));
const exhausted = attempt >= TRANSCRIPTION_FETCH_MAX_ATTEMPTS;
if (!retriable || exhausted) {
throw new Error(
`Transcription request to ${endpoint} failed after ${attempt} attempt(s): ${normalizedMessage}`,
);
}
await sleep(TRANSCRIPTION_FETCH_BASE_DELAY_MS * (2 ** (attempt - 1)));
} finally {
clearTimeout(timeout);
}
}
throw new Error(`Transcription request to ${endpoint} failed after retries`);
}
/** Check whether an attachment is a supported image type. */
export function isSupportedImage(attachment: Attachment): boolean {
return SUPPORTED_IMAGE_TYPES.has(attachment.mimeType);
@@ -257,7 +304,7 @@ export async function transcribeAudio(
headers['Authorization'] = `Bearer ${config.apiKey}`;
}
const res = await fetch(config.endpoint, { method: 'POST', body: formData, headers });
const res = await fetchTranscriptionWithRetry(config.endpoint, { method: 'POST', body: formData, headers });
if (!res.ok) {
throw new Error(`Transcription failed: ${res.status} ${res.statusText}`);
}