fix(tts): surface provider rate-limit errors as HTTP 429 (#644)

TTS providers throw a plain `Error` on any non-OK response, and the
`/api/generate/tts` route catches everything as `GENERATION_FAILED` (500),
so an upstream 429 (quota/concurrency) was indistinguishable from a real
failure — clients had no signal to retry/back off. `TTSRateLimitError`
already existed for exactly this (with a TODO noting the route collapses
everything uniformly), but only Doubao threw it and the route never mapped
it.

- Add a `RATE_LIMITED` API error code.
- Map upstream HTTP 429 to `TTSRateLimitError` at every provider's
  `!response.ok` site via a shared `throwIfTtsRateLimited` helper.
- Return `429 RATE_LIMITED` from the TTS route when a `TTSRateLimitError`
  is caught (all other errors unchanged).

Purely additive: non-429 responses keep their existing error messages.

Co-authored-by: ly-wang19 <ly-wang19@users.noreply.github.com>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-authored-by: wyuc <wang-yc24@mails.tsinghua.edu.cn>
This commit is contained in:
ly-wang19
2026-06-03 12:11:15 +08:00
committed by GitHub
co-authored by ly-wang19 Claude Opus 4.8 wyuc
parent 4da3a3a8a0
commit 2586cb5e05
4 changed files with 47 additions and 1 deletions
+4 -1
View File
@@ -8,7 +8,7 @@
*/
import { NextRequest } from 'next/server';
import { generateTTS } from '@/lib/audio/tts-providers';
import { generateTTS, TTSRateLimitError } from '@/lib/audio/tts-providers';
import {
isServerConfiguredProvider,
resolveTTSApiKey,
@@ -113,6 +113,9 @@ export async function POST(req: NextRequest) {
`TTS generation failed [provider=${ttsProviderId ?? 'unknown'}, voice=${ttsVoice ?? 'unknown'}, audioId=${audioId ?? 'unknown'}]:`,
error,
);
if (error instanceof TTSRateLimitError) {
return apiError('RATE_LIMITED', 429, error.message);
}
return apiError(
'GENERATION_FAILED',
500,
+20
View File
@@ -127,6 +127,17 @@ export class TTSRateLimitError extends Error {
}
}
/**
* Map an upstream HTTP 429 to a typed {@link TTSRateLimitError} so the API route
* can surface it as 429 instead of a generic 500. Call right after an
* `!response.ok` check, before building the provider-specific error message.
*/
export function throwIfTtsRateLimited(provider: string, status: number): void {
if (status === 429) {
throw new TTSRateLimitError(provider, `${provider} TTS rate limit exceeded (HTTP 429)`);
}
}
/**
* Generate speech using specified TTS provider
*/
@@ -205,6 +216,7 @@ async function generateOpenAITTS(
});
if (!response.ok) {
throwIfTtsRateLimited('OpenAI', response.status);
const error = await response.json().catch(() => ({ error: response.statusText }));
throw new Error(`OpenAI TTS API error: ${error.error?.message || response.statusText}`);
}
@@ -248,6 +260,7 @@ async function generateLemonadeTTS(
});
if (!response.ok) {
throwIfTtsRateLimited('Lemonade', response.status);
throw new Error(`Lemonade TTS API error: ${await readTTSApiError(response)}`);
}
@@ -314,6 +327,7 @@ async function generateVoxCPMTTS(
: await postVoxCPMVLLMOmni(baseUrl, request, config);
if (!response.ok) {
throwIfTtsRateLimited('VoxCPM', response.status);
throw new Error(`VoxCPM TTS API error: ${await readTTSApiError(response)}`);
}
@@ -559,6 +573,7 @@ async function generateAzureTTS(
});
if (!response.ok) {
throwIfTtsRateLimited('Azure', response.status);
throw new Error(`Azure TTS API error: ${response.statusText}`);
}
@@ -592,6 +607,7 @@ async function generateGLMTTS(config: TTSModelConfig, text: string): Promise<TTS
});
if (!response.ok) {
throwIfTtsRateLimited('GLM', response.status);
const errorText = await response.text().catch(() => response.statusText);
let errorMessage = `GLM TTS API error: ${errorText}`;
try {
@@ -642,6 +658,7 @@ async function generateQwenTTS(config: TTSModelConfig, text: string): Promise<TT
});
if (!response.ok) {
throwIfTtsRateLimited('Qwen', response.status);
const errorText = await response.text().catch(() => response.statusText);
throw new Error(`Qwen TTS API error: ${errorText}`);
}
@@ -708,6 +725,7 @@ async function generateMiniMaxTTS(
});
if (!response.ok) {
throwIfTtsRateLimited('MiniMax', response.status);
const errorText = await response.text().catch(() => response.statusText);
throw new Error(`MiniMax TTS API error: ${errorText}`);
}
@@ -773,6 +791,7 @@ async function generateElevenLabsTTS(
);
if (!response.ok) {
throwIfTtsRateLimited('ElevenLabs', response.status);
const errorText = await response.text().catch(() => response.statusText);
throw new Error(`ElevenLabs TTS API error: ${errorText || response.statusText}`);
}
@@ -853,6 +872,7 @@ async function generateDoubaoTTS(
});
if (!response.ok) {
throwIfTtsRateLimited('Doubao', response.status);
const errorText = await response.text().catch(() => response.statusText);
throw new Error(`Doubao TTS API error (${response.status}): ${errorText}`);
}
+1
View File
@@ -10,6 +10,7 @@ export const API_ERROR_CODES = {
TOO_MANY_REDIRECTS: 'TOO_MANY_REDIRECTS',
CONTENT_SENSITIVE: 'CONTENT_SENSITIVE',
UPSTREAM_ERROR: 'UPSTREAM_ERROR',
RATE_LIMITED: 'RATE_LIMITED',
GENERATION_FAILED: 'GENERATION_FAILED',
TRANSCRIPTION_FAILED: 'TRANSCRIPTION_FAILED',
PARSE_FAILED: 'PARSE_FAILED',
+22
View File
@@ -0,0 +1,22 @@
import { describe, it, expect } from 'vitest';
import { throwIfTtsRateLimited, TTSRateLimitError } from '@/lib/audio/tts-providers';
describe('throwIfTtsRateLimited', () => {
it('throws a typed TTSRateLimitError on HTTP 429', () => {
expect(() => throwIfTtsRateLimited('OpenAI', 429)).toThrow(TTSRateLimitError);
try {
throwIfTtsRateLimited('OpenAI', 429);
throw new Error('should have thrown');
} catch (e) {
expect(e).toBeInstanceOf(TTSRateLimitError);
expect((e as TTSRateLimitError).provider).toBe('OpenAI');
}
});
it('does not throw on non-429 statuses', () => {
expect(() => throwIfTtsRateLimited('OpenAI', 200)).not.toThrow();
expect(() => throwIfTtsRateLimited('OpenAI', 401)).not.toThrow();
expect(() => throwIfTtsRateLimited('OpenAI', 500)).not.toThrow();
expect(() => throwIfTtsRateLimited('OpenAI', 503)).not.toThrow();
});
});