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.
This commit is contained in:
Richard R 2026-05-12 18:40:46 -06:00
parent d7e6a7b7ba
commit c648d2bf6a
9 changed files with 91 additions and 331 deletions

View file

@ -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);

View file

@ -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 });
}
}

View file

@ -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 } : {}),
},
});
}
}

View file

@ -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 <T>(
}
// 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<string, unknown>;
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<VoicesResponse> =
return await response.json();
};
export const generateTTS = async (
payload: TTSRequestPayload,
headers: TTSRequestHeaders,
signal?: AbortSignal
): Promise<TTSAudioBuffer> => {
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<string, unknown>;
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 ---

View file

@ -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<string, string>;
// 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 {

View file

@ -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. */

View file

@ -26,7 +26,7 @@ export const SYNCED_PREFERENCE_KEYS = [
] as const;
export type SyncedPreferenceKey = (typeof SYNCED_PREFERENCE_KEYS)[number];
export type SyncedPreferences = Pick<AppConfigValues, SyncedPreferenceKey>;
type SyncedPreferences = Pick<AppConfigValues, SyncedPreferenceKey>;
export type SyncedPreferencesPatch = Partial<SyncedPreferences>;
export type ReaderType = 'pdf' | 'epub' | 'html';

View file

@ -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)

View file

@ -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 {