From c648d2bf6a755ba3b1823c267d0f32f682eba4e9 Mon Sep 17 00:00:00 2001 From: Richard R Date: Tue, 12 May 2026 18:40:46 -0600 Subject: [PATCH] refactor(api,tts,types): remove legacy /api/tts endpoint and update error handling Eliminate the deprecated `/api/tts` route and its client usage, consolidating TTS generation logic under chapter and segment endpoints. Update client API utilities to remove direct TTS buffer fetching and improve error propagation for chapter generation failures. Extend TTS segment error metadata to include upstream rate limit and retry details, and standardize error typing in shared types. Adjust tests and helpers to reflect the new TTS mocking approach and endpoint structure. This streamlines TTS API surface and enhances error reporting consistency. --- src/app/api/audiobook/chapter/route.ts | 34 ++++ src/app/api/tts/route.ts | 218 ----------------------- src/app/api/tts/segments/ensure/route.ts | 24 ++- src/lib/client/api/audiobooks.ts | 77 +++----- src/types/client.ts | 23 +-- src/types/tts.ts | 17 +- src/types/user-state.ts | 2 +- tests/export.spec.ts | 3 +- tests/helpers.ts | 24 --- 9 files changed, 91 insertions(+), 331 deletions(-) delete mode 100644 src/app/api/tts/route.ts diff --git a/src/app/api/audiobook/chapter/route.ts b/src/app/api/audiobook/chapter/route.ts index 426b0ff..ca1db5d 100644 --- a/src/app/api/audiobook/chapter/route.ts +++ b/src/app/api/audiobook/chapter/route.ts @@ -29,6 +29,7 @@ import { getOpenReaderTestNamespace, getUnclaimedUserIdForNamespace } from '@/li import { buildAllowedAudiobookUserIds, pickAudiobookOwner } from '@/lib/server/audiobooks/user-scope'; import { getFFmpegPath } from '@/lib/server/audiobooks/ffmpeg-bin'; import { generateTTSBuffer } from '@/lib/server/tts/generate'; +import { getUpstreamRetryAfterSeconds, getUpstreamStatus } from '@/lib/server/tts/upstream-response'; import { supportsNativeModelSpeed } from '@/lib/shared/tts-provider-catalog'; import type { AudiobookGenerationSettings } from '@/types/client'; import type { TTSAudiobookFormat } from '@/types/tts'; @@ -54,6 +55,7 @@ type ChapterObject = { const SAFE_ID_REGEX = /^[a-zA-Z0-9._-]{1,128}$/; const PROBLEM_TYPES = { dailyQuotaExceeded: 'https://openreader.app/problems/daily-quota-exceeded', + upstreamRateLimited: 'https://openreader.app/problems/upstream-rate-limited', } as const; type ProblemDetails = { @@ -273,6 +275,7 @@ export async function POST(request: NextRequest) { let workDir: string | null = null; let didCreateDeviceIdCookie = false; let deviceIdToSet: string | null = null; + let providerForError: string | null = null; try { if (!isS3Configured()) return s3NotConfiguredResponse(); @@ -405,6 +408,7 @@ export async function POST(request: NextRequest) { const provider = request.headers.get('x-tts-provider') || mergedSettings?.ttsProvider || 'openai'; + providerForError = provider; const openApiKey = request.headers.get('x-openai-key') || process.env.API_KEY || 'none'; const openApiBaseUrl = request.headers.get('x-openai-base-url') || process.env.API_BASE; const model = mergedSettings?.ttsModel; @@ -595,6 +599,36 @@ export async function POST(request: NextRequest) { attachDeviceIdCookie(response, deviceIdToSet, didCreateDeviceIdCookie); return response; } + + const upstreamStatus = getUpstreamStatus(error); + if (upstreamStatus === 429) { + const retryAfterSeconds = getUpstreamRetryAfterSeconds(error); + const problem: ProblemDetails = { + type: PROBLEM_TYPES.upstreamRateLimited, + title: 'Upstream rate limited', + status: 429, + detail: retryAfterSeconds + ? `The TTS provider is rate limiting requests. Please retry in about ${retryAfterSeconds}s.` + : 'The TTS provider is rate limiting requests. Please try again shortly.', + code: 'UPSTREAM_RATE_LIMIT', + provider: providerForError ?? undefined, + upstreamStatus, + retryAfterSeconds, + instance: request.nextUrl.pathname, + }; + + const response = new NextResponse(JSON.stringify(problem), { + status: 429, + headers: { + 'Content-Type': 'application/problem+json', + ...(retryAfterSeconds ? { 'Retry-After': String(retryAfterSeconds) } : {}), + }, + }); + + attachDeviceIdCookie(response, deviceIdToSet, didCreateDeviceIdCookie); + return response; + } + console.error('Error processing audio chapter:', error); const response = NextResponse.json({ error: 'Failed to process audio chapter' }, { status: 500 }); attachDeviceIdCookie(response, deviceIdToSet, didCreateDeviceIdCookie); diff --git a/src/app/api/tts/route.ts b/src/app/api/tts/route.ts deleted file mode 100644 index 64bd12b..0000000 --- a/src/app/api/tts/route.ts +++ /dev/null @@ -1,218 +0,0 @@ -import { NextRequest, NextResponse } from 'next/server'; -import type { TTSRequestPayload } from '@/types/client'; -import type { TTSError } from '@/types/tts'; -import { headers } from 'next/headers'; -import { auth } from '@/lib/server/auth/auth'; -import { rateLimiter, isTtsRateLimitEnabled } from '@/lib/server/rate-limit/rate-limiter'; -import { isAuthEnabled } from '@/lib/server/auth/config'; -import { getClientIp } from '@/lib/server/rate-limit/request-ip'; -import { getOrCreateDeviceId, setDeviceIdCookie } from '@/lib/server/rate-limit/device-id'; -import { buildDailyQuotaExceededResponse } from '@/lib/server/rate-limit/problem-response'; -import { getOpenReaderTestNamespace } from '@/lib/server/testing/test-namespace'; -import { - buildTTSCacheKey, - generateTTSBuffer, - getCachedTTSBuffer, - getTTSContentType, -} from '@/lib/server/tts/generate'; -import { getUpstreamRetryAfterSeconds, getUpstreamStatus } from '@/lib/server/tts/upstream-response'; - -export const runtime = 'nodejs'; -export const maxDuration = 60; - -function attachDeviceIdCookie(response: NextResponse, deviceId: string | null, didCreate: boolean) { - if (didCreate && deviceId) { - setDeviceIdCookie(response, deviceId); - } -} - -const PROBLEM_TYPES = { - upstreamRateLimited: 'https://openreader.app/problems/upstream-rate-limited', -} as const; - -type ProblemDetails = { - type: string; - title: string; - status: number; - detail?: string; - instance?: string; - code?: string; - [key: string]: unknown; -}; - -export async function POST(req: NextRequest) { - let providerForError: string | null = null; - let didCreateDeviceIdCookie = false; - let deviceIdToSet: string | null = null; - - try { - const body = (await req.json()) as TTSRequestPayload; - const { text, voice, speed, format, model: reqModel, instructions } = body; - - if (!text || !voice || !speed) { - const errorBody: TTSError = { - code: 'MISSING_PARAMETERS', - message: 'Missing required parameters', - }; - return NextResponse.json(errorBody, { status: 400 }); - } - - if (isAuthEnabled() && auth) { - const session = await auth.api.getSession({ - headers: await headers(), - }); - - if (!session?.user) { - return NextResponse.json( - { code: 'UNAUTHORIZED', message: 'Authentication required' }, - { status: 401 } - ); - } - - const isAnonymous = Boolean(session.user.isAnonymous); - if (isTtsRateLimitEnabled()) { - const charCount = text.length; - const ip = getClientIp(req); - const device = isAnonymous ? getOrCreateDeviceId(req) : null; - if (device?.didCreate) { - didCreateDeviceIdCookie = true; - deviceIdToSet = device.deviceId; - } - - const rateLimitResult = await rateLimiter.checkAndIncrementLimit( - { id: session.user.id, isAnonymous }, - charCount, - { - deviceId: device?.deviceId ?? null, - ip, - } - ); - - if (!rateLimitResult.allowed) { - const response = buildDailyQuotaExceededResponse({ - rateLimitResult, - isAnonymousUser: isAnonymous, - pathname: req.nextUrl.pathname, - }); - - attachDeviceIdCookie(response, deviceIdToSet, didCreateDeviceIdCookie); - return response; - } - } - } - - const openApiKey = req.headers.get('x-openai-key') || process.env.API_KEY || 'none'; - const openApiBaseUrl = req.headers.get('x-openai-base-url') || process.env.API_BASE; - const provider = req.headers.get('x-tts-provider') || 'openai'; - const testNamespace = getOpenReaderTestNamespace(req.headers); - providerForError = provider; - const varyHeader = 'x-tts-provider, x-openai-key, x-openai-base-url, x-openreader-test-namespace'; - - const ttsRequest = { - text, - voice, - speed, - format, - model: reqModel, - instructions, - provider, - apiKey: openApiKey, - baseUrl: openApiBaseUrl, - testNamespace, - }; - - const contentType = getTTSContentType(format); - const cacheKey = buildTTSCacheKey(ttsRequest); - const etag = `W/"${cacheKey}"`; - const ifNoneMatch = req.headers.get('if-none-match'); - - const cachedBuffer = getCachedTTSBuffer(cacheKey); - if (cachedBuffer) { - if (ifNoneMatch && (ifNoneMatch.includes(cacheKey) || ifNoneMatch.includes(etag))) { - const response = new NextResponse(null, { - status: 304, - headers: { - ETag: etag, - 'Cache-Control': 'private, max-age=1800', - Vary: varyHeader, - }, - }); - - attachDeviceIdCookie(response, deviceIdToSet, didCreateDeviceIdCookie); - return response; - } - - const response = new NextResponse(new Uint8Array(cachedBuffer), { - headers: { - 'Content-Type': contentType, - 'X-Cache': 'HIT', - ETag: etag, - 'Content-Length': String(cachedBuffer.byteLength), - 'Cache-Control': 'private, max-age=1800', - Vary: varyHeader, - }, - }); - - attachDeviceIdCookie(response, deviceIdToSet, didCreateDeviceIdCookie); - return response; - } - - const buffer = await generateTTSBuffer(ttsRequest, req.signal); - - const response = new NextResponse(new Uint8Array(buffer), { - headers: { - 'Content-Type': contentType, - 'X-Cache': 'MISS', - ETag: etag, - 'Content-Length': String(buffer.byteLength), - 'Cache-Control': 'private, max-age=1800', - Vary: varyHeader, - }, - }); - - attachDeviceIdCookie(response, deviceIdToSet, didCreateDeviceIdCookie); - return response; - } catch (error) { - if (error instanceof Error && error.name === 'AbortError') { - return new NextResponse(null, { status: 499 }); - } - - const upstreamStatus = getUpstreamStatus(error); - if (upstreamStatus === 429) { - const retryAfterSeconds = getUpstreamRetryAfterSeconds(error); - const problem: ProblemDetails = { - type: PROBLEM_TYPES.upstreamRateLimited, - title: 'Upstream rate limited', - status: 429, - detail: retryAfterSeconds - ? `The TTS provider is rate limiting requests. Please retry in about ${retryAfterSeconds}s.` - : 'The TTS provider is rate limiting requests. Please try again shortly.', - code: 'UPSTREAM_RATE_LIMIT', - provider: providerForError ?? undefined, - upstreamStatus, - retryAfterSeconds, - instance: req.nextUrl.pathname, - }; - - return new NextResponse(JSON.stringify(problem), { - status: 429, - headers: { - 'Content-Type': 'application/problem+json', - ...(retryAfterSeconds ? { 'Retry-After': String(retryAfterSeconds) } : {}), - }, - }); - } - - const statusHint = getUpstreamStatus(error); - const errorMessage = error instanceof Error ? error.message : String(error); - console.warn( - `Error generating TTS${typeof statusHint === 'number' ? ` (upstream ${statusHint})` : ''}: ${errorMessage}`, - ); - const errorBody: TTSError = { - code: 'TTS_GENERATION_FAILED', - message: 'Failed to generate audio', - details: process.env.NODE_ENV !== 'production' ? String(error) : undefined, - }; - return NextResponse.json(errorBody, { status: 500 }); - } -} diff --git a/src/app/api/tts/segments/ensure/route.ts b/src/app/api/tts/segments/ensure/route.ts index 495ece3..50accd7 100644 --- a/src/app/api/tts/segments/ensure/route.ts +++ b/src/app/api/tts/segments/ensure/route.ts @@ -26,6 +26,7 @@ import { rateLimiter, isTtsRateLimitEnabled } from '@/lib/server/rate-limit/rate import { getClientIp } from '@/lib/server/rate-limit/request-ip'; import { getOrCreateDeviceId, setDeviceIdCookie } from '@/lib/server/rate-limit/device-id'; import { buildDailyQuotaExceededResponse } from '@/lib/server/rate-limit/problem-response'; +import { getUpstreamRetryAfterSeconds, getUpstreamStatus } from '@/lib/server/tts/upstream-response'; import { alignAudioWithText } from '@/lib/server/whisper/alignment'; import type { TTSSegmentInput, @@ -520,11 +521,24 @@ export async function POST(request: NextRequest) { } catch (error) { const message = error instanceof Error ? error.message : 'Failed to generate segment'; const aborted = isAbortLikeError(error); + const upstreamStatus = getUpstreamStatus(error); + const retryAfterSeconds = upstreamStatus === 429 + ? getUpstreamRetryAfterSeconds(error) + : undefined; + const errorCode = upstreamStatus === 429 + ? 'UPSTREAM_RATE_LIMIT' + : upstreamStatus && upstreamStatus >= 500 + ? 'UPSTREAM_TTS_ERROR' + : 'TTS_SEGMENT_GENERATION_FAILED'; await db .update(ttsSegmentVariants) .set({ status: aborted ? 'pending' : 'error', - error: aborted ? null : message, + error: aborted ? null : ( + upstreamStatus + ? `${errorCode}${retryAfterSeconds ? ` (retry after ${retryAfterSeconds}s)` : ''}: ${message}` + : message + ), updatedAt: Date.now(), }) .where(and( @@ -542,6 +556,14 @@ export async function POST(request: NextRequest) { alignment: null, locator: segment.locator, status: aborted ? 'pending' : 'error', + error: aborted + ? null + : { + code: errorCode, + detail: message, + ...(typeof upstreamStatus === 'number' ? { upstreamStatus } : {}), + ...(typeof retryAfterSeconds === 'number' ? { retryAfterSeconds } : {}), + }, }); } } diff --git a/src/lib/client/api/audiobooks.ts b/src/lib/client/api/audiobooks.ts index 7fa63e2..d737815 100644 --- a/src/lib/client/api/audiobooks.ts +++ b/src/lib/client/api/audiobooks.ts @@ -1,5 +1,4 @@ import type { - TTSRequestPayload, TTSRequestHeaders, TTSRetryOptions, TTSRequestError, @@ -11,7 +10,7 @@ import type { TTSSegmentsEnsureRequest, TTSSegmentsEnsureResponse, } from '@/types/client'; -import type { TTSAudiobookChapter, TTSAudioBuffer } from '@/types/tts'; +import type { TTSAudiobookChapter } from '@/types/tts'; /** * Executes a function with exponential backoff retry logic @@ -69,8 +68,8 @@ export const withRetry = async ( } // Narrow client retries to transport-level failures only. - // If we got an HTTP status from /api/tts, do not retry from the client. - // Server-side /api/tts already applies upstream retry logic. + // If we got an HTTP status from a server route, do not retry from the client. + // Server-side routes already apply upstream retry logic where needed. const message = lastError.message.toLowerCase(); const isTransportFailure = status === undefined && @@ -144,8 +143,25 @@ export const createAudiobookChapter = async ( } if (!response.ok) { - const data = await response.json().catch(() => null) as { error?: string } | null; - throw new Error(data?.error || 'Failed to convert audio chapter'); + let problem: unknown = undefined; + const contentType = response.headers.get('content-type') || ''; + if (contentType.includes('application/problem+json') || contentType.includes('application/json')) { + problem = await response.json().catch(() => null); + } + + const err = new Error(`Audiobook chapter generation failed with status ${response.status}`) as TTSRequestError; + err.status = response.status; + if (typeof problem === 'object' && problem !== null) { + const rec = problem as Record; + if (typeof rec.code === 'string') err.code = rec.code; + if (typeof rec.type === 'string') err.type = rec.type; + if (typeof rec.title === 'string') err.title = rec.title; + if (typeof rec.detail === 'string') err.detail = rec.detail; + } else { + err.detail = 'Failed to convert audio chapter'; + } + + throw err; } return await response.json(); @@ -192,55 +208,6 @@ export const getVoices = async (headers: HeadersInit): Promise = return await response.json(); }; -export const generateTTS = async ( - payload: TTSRequestPayload, - headers: TTSRequestHeaders, - signal?: AbortSignal -): Promise => { - const response = await fetch('/api/tts', { - method: 'POST', - headers: headers as HeadersInit, - body: JSON.stringify(payload), - signal - }); - - if (!response.ok) { - let problem: unknown = undefined; - const contentType = response.headers.get('content-type') || ''; - if (contentType.includes('application/problem+json') || contentType.includes('application/json')) { - try { - problem = await response.json(); - } catch { - // ignore JSON parse errors - } - } - - const err = new Error(`TTS processing failed with status ${response.status}`) as TTSRequestError; - err.status = response.status; - - if (typeof problem === 'object' && problem !== null) { - const rec = problem as Record; - if (typeof rec.code === 'string') err.code = rec.code; - if (typeof rec.type === 'string') err.type = rec.type; - if (typeof rec.title === 'string') err.title = rec.title; - if (typeof rec.detail === 'string') err.detail = rec.detail; - } - - // Avoid noisy logs for expected user quota failures - if (!(err.status === 429 && err.code === 'USER_DAILY_QUOTA_EXCEEDED')) { - console.error(`TTS request failed: ${response.status}`, err.code ? { code: err.code } : undefined); - } - - throw err; - } - - const buffer = await response.arrayBuffer(); - if (buffer.byteLength === 0) { - throw new Error('Received empty audio buffer from TTS'); - } - return buffer; -}; - // --- Whisper API --- diff --git a/src/types/client.ts b/src/types/client.ts index bf2a623..f8b9755 100644 --- a/src/types/client.ts +++ b/src/types/client.ts @@ -7,20 +7,7 @@ import type { // --- TTS Client Request Types --- -// Supported output formats for the TTS endpoint -export type TTSRequestFormat = 'mp3'; - -// JSON payload accepted by the /api/tts endpoint -export interface TTSRequestPayload { - text: string; - voice: string; - speed: number; - model?: string | null; - format?: TTSRequestFormat; - instructions?: string; -} - -// Headers used when calling the /api/tts endpoint from the client +// Headers used when calling TTS-related endpoints from the client. export type TTSRequestHeaders = Record; // Options for retrying TTS requests on failure in withRetry @@ -94,7 +81,7 @@ export interface TTSSegmentSettings { ttsInstructions?: string; } -export type TTSReaderType = 'pdf' | 'epub' | 'html'; +type TTSReaderType = 'pdf' | 'epub' | 'html'; /** * Locator describing where a TTS segment came from inside a document. @@ -191,6 +178,12 @@ export interface TTSSegmentManifestItem { alignment: TTSSentenceAlignment | null; locator: TTSSegmentLocator | null; status: 'pending' | 'completed' | 'error'; + error?: { + code: string; + detail?: string; + upstreamStatus?: number; + retryAfterSeconds?: number; + } | null; } export interface TTSSegmentsEnsureResponse { diff --git a/src/types/tts.ts b/src/types/tts.ts index b716396..0ed05cf 100644 --- a/src/types/tts.ts +++ b/src/types/tts.ts @@ -6,21 +6,6 @@ export type TTSLocation = string | number; export type TTSAudioBuffer = ArrayBuffer; export type TTSAudioBytes = number[]; // JSON-safe representation (Array.from(new Uint8Array(buffer))) -// Standardized error codes for the TTS API -export type TTSErrorCode = - | 'MISSING_PARAMETERS' - | 'INVALID_REQUEST' - | 'TTS_GENERATION_FAILED' - | 'ABORTED' - | 'INTERNAL_ERROR'; - -// Structured error object returned by the TTS API -export interface TTSError { - code: TTSErrorCode; - message: string; - details?: unknown; -} - // Core playback state exposed by the TTS context export interface TTSPlaybackState { isPlaying: boolean; @@ -55,7 +40,7 @@ export interface TTSSentenceAlignment { words: TTSSentenceWord[]; } -export interface EpubRenderedLocationWalkItem { +interface EpubRenderedLocationWalkItem { /** Page-start CFI from the rendition — best-effort jump hint only. */ cfi: string; /** Plain text content of the rendered page chunk. */ diff --git a/src/types/user-state.ts b/src/types/user-state.ts index cb69b9e..9100bf1 100644 --- a/src/types/user-state.ts +++ b/src/types/user-state.ts @@ -26,7 +26,7 @@ export const SYNCED_PREFERENCE_KEYS = [ ] as const; export type SyncedPreferenceKey = (typeof SYNCED_PREFERENCE_KEYS)[number]; -export type SyncedPreferences = Pick; +type SyncedPreferences = Pick; export type SyncedPreferencesPatch = Partial; export type ReaderType = 'pdf' | 'epub' | 'html'; diff --git a/tests/export.spec.ts b/tests/export.spec.ts index d049769..d8137a0 100644 --- a/tests/export.spec.ts +++ b/tests/export.spec.ts @@ -224,7 +224,8 @@ test('exports full MP3 audiobook for PDF using mocked 10s TTS sample', async ({ // Choose MP3 so we can validate MP3 duration end-to-end. await setContainerFormatToMP3(page); - // Start generation; this will call the mocked /api/tts which returns a 10s sample.mp3 per page + // Start generation; in test namespace mode the server returns tests/files/sample.mp3 + // for each generated chapter via generateTTSBuffer's test mock path. await startGeneration(page); // Wait for chapters list to appear and populate at least two items (Pages 1 and 2) diff --git a/tests/helpers.ts b/tests/helpers.ts index 9c6ccd0..a29032a 100644 --- a/tests/helpers.ts +++ b/tests/helpers.ts @@ -4,32 +4,11 @@ import path from 'path'; import { createHash } from 'crypto'; const DIR = './tests/files/'; -const TTS_MOCK_PATH = path.join(__dirname, 'files', 'sample.mp3'); -let ttsMockBuffer: Buffer | null = null; function isAuthEnabledForTests() { return Boolean(process.env.AUTH_SECRET && process.env.BASE_URL); } -async function ensureTtsRouteMock(page: Page) { - if (!ttsMockBuffer) { - ttsMockBuffer = fs.readFileSync(TTS_MOCK_PATH); - } - - await page.route('**/api/tts', async (route) => { - // Only mock the POST TTS generation calls; let anything else pass through. - if (route.request().method().toUpperCase() !== 'POST') { - return route.continue(); - } - - await route.fulfill({ - status: 200, - contentType: 'audio/mpeg', - body: ttsMockBuffer as Buffer, - }); - }); -} - // Small util to safely use filenames inside regex patterns function escapeRegExp(input: string) { return input.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); @@ -256,9 +235,6 @@ export async function setupTest(page: Page, testInfo?: TestInfo) { } } - // Mock the TTS API so tests don't hit the real TTS service. - await ensureTtsRouteMock(page); - // Pre-seed consent to prevent the cookie banner from blocking interactions. await page.addInitScript(() => { try {