fix(api,rate-limit): centralize daily quota exceeded response and update imports

Move daily quota exceeded problem response logic to a dedicated utility in `lib/server/rate-limit/problem-response`. Refactor API routes to use the new builder and remove duplicated formatting code. Update all relevant imports for consistency. This improves maintainability and ensures uniform error responses across TTS endpoints.
This commit is contained in:
Richard R 2026-05-12 18:24:39 -06:00
parent 08d9218ce4
commit d7e6a7b7ba
6 changed files with 141 additions and 133 deletions

View file

@ -3,10 +3,11 @@ 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, RATE_LIMITS, isTtsRateLimitEnabled } from '@/lib/server/rate-limit/rate-limiter';
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,
@ -26,7 +27,6 @@ function attachDeviceIdCookie(response: NextResponse, deviceId: string | null, d
}
const PROBLEM_TYPES = {
dailyQuotaExceeded: 'https://openreader.app/problems/daily-quota-exceeded',
upstreamRateLimited: 'https://openreader.app/problems/upstream-rate-limited',
} as const;
@ -40,16 +40,6 @@ type ProblemDetails = {
[key: string]: unknown;
};
function formatLimitForHint(limit: number): string {
if (!Number.isFinite(limit) || limit <= 0) return String(limit);
if (limit >= 1_000_000) {
const m = limit / 1_000_000;
return `${m % 1 === 0 ? m.toFixed(0) : m.toFixed(1)}M`;
}
if (limit >= 1_000) return `${Math.round(limit / 1_000)}K`;
return String(limit);
}
export async function POST(req: NextRequest) {
let providerForError: string | null = null;
let didCreateDeviceIdCookie = false;
@ -99,35 +89,10 @@ export async function POST(req: NextRequest) {
);
if (!rateLimitResult.allowed) {
const resetTimeMs = rateLimitResult.resetTimeMs;
const retryAfterSeconds = Math.max(
0,
Math.ceil((resetTimeMs - Date.now()) / 1000)
);
const problem: ProblemDetails = {
type: PROBLEM_TYPES.dailyQuotaExceeded,
title: 'Daily quota exceeded',
status: 429,
detail: 'Daily character limit exceeded',
code: 'USER_DAILY_QUOTA_EXCEEDED',
currentCount: rateLimitResult.currentCount,
limit: rateLimitResult.limit,
remainingChars: rateLimitResult.remainingChars,
resetTimeMs,
userType: isAnonymous ? 'anonymous' : 'authenticated',
upgradeHint: isAnonymous
? `Sign up to increase your limit from ${formatLimitForHint(RATE_LIMITS.ANONYMOUS)} to ${formatLimitForHint(RATE_LIMITS.AUTHENTICATED)} characters per day`
: undefined,
instance: req.nextUrl.pathname,
};
const response = new NextResponse(JSON.stringify(problem), {
status: 429,
headers: {
'Content-Type': 'application/problem+json',
'Retry-After': String(retryAfterSeconds),
},
const response = buildDailyQuotaExceededResponse({
rateLimitResult,
isAnonymousUser: isAnonymous,
pathname: req.nextUrl.pathname,
});
attachDeviceIdCookie(response, deviceIdToSet, didCreateDeviceIdCookie);

View file

@ -22,9 +22,10 @@ import {
probeAudioDurationMsFromBuffer,
} from '@/lib/server/tts/segments';
import { resolveSegmentDocumentScope } from '@/lib/server/tts/segments-auth';
import { rateLimiter, RATE_LIMITS, isTtsRateLimitEnabled } from '@/lib/server/rate-limit/rate-limiter';
import { rateLimiter, isTtsRateLimitEnabled } from '@/lib/server/rate-limit/rate-limiter';
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 { alignAudioWithText } from '@/lib/server/whisper/alignment';
import type {
TTSSegmentInput,
@ -42,16 +43,6 @@ function attachDeviceIdCookie(response: NextResponse, deviceId: string | null, d
}
}
function formatLimitForHint(limit: number): string {
if (!Number.isFinite(limit) || limit <= 0) return String(limit);
if (limit >= 1_000_000) {
const m = limit / 1_000_000;
return `${m % 1 === 0 ? m.toFixed(0) : m.toFixed(1)}M`;
}
if (limit >= 1_000) return `${Math.round(limit / 1_000)}K`;
return String(limit);
}
function parseSettings(value: unknown): TTSSegmentSettings | null {
if (!value || typeof value !== 'object') return null;
const rec = value as Record<string, unknown>;
@ -246,6 +237,44 @@ export async function POST(request: NextRequest) {
const existingById = new Map(rows.map((row) => [row.segmentId, row]));
const manifest: TTSSegmentManifestItem[] = [];
const upsertSegmentEntry = async (input: {
segmentEntryId: string;
segmentIndex: number;
segmentKey: string | null;
locatorProjection: ReturnType<typeof projectSegmentLocator>;
textHash: string;
textLength: number;
}): Promise<void> => {
await db
.insert(ttsSegmentEntries)
.values({
segmentEntryId: input.segmentEntryId,
userId: scope.storageUserId,
documentId: parsed.documentId,
readerType: scope.readerType,
documentVersion: scope.documentVersion,
segmentIndex: input.segmentIndex,
segmentKey: input.segmentKey,
...input.locatorProjection,
textHash: input.textHash,
textLength: input.textLength,
updatedAt: nowMs,
})
.onConflictDoUpdate({
target: [ttsSegmentEntries.segmentEntryId, ttsSegmentEntries.userId],
set: {
documentId: parsed.documentId,
readerType: scope.readerType,
documentVersion: scope.documentVersion,
segmentIndex: input.segmentIndex,
segmentKey: input.segmentKey,
...input.locatorProjection,
textHash: input.textHash,
textLength: input.textLength,
updatedAt: nowMs,
},
});
};
for (const segment of normalized) {
const locatorProjection = projectSegmentLocator(segment.locator);
@ -261,42 +290,21 @@ export async function POST(request: NextRequest) {
textHash: segment.textHash,
});
await db
.insert(ttsSegmentEntries)
.values({
segmentEntryId,
userId: scope.storageUserId,
documentId: parsed.documentId,
readerType: scope.readerType,
documentVersion: scope.documentVersion,
segmentIndex: segment.original.segmentIndex,
segmentKey: segmentKeyForRow,
...locatorProjection,
textHash: segment.textHash,
textLength: segment.text.length,
updatedAt: nowMs,
})
.onConflictDoUpdate({
target: [ttsSegmentEntries.segmentEntryId, ttsSegmentEntries.userId],
set: {
documentId: parsed.documentId,
readerType: scope.readerType,
documentVersion: scope.documentVersion,
segmentIndex: segment.original.segmentIndex,
segmentKey: segmentKeyForRow,
...locatorProjection,
textHash: segment.textHash,
textLength: segment.text.length,
updatedAt: nowMs,
},
});
const existing = existingById.get(segment.segmentId);
const movedFromEntryId = existing && existing.segmentEntryId !== segmentEntryId
? existing.segmentEntryId
: null;
if (existing?.status === 'completed' && existing.audioKey) {
await upsertSegmentEntry({
segmentEntryId,
segmentIndex: segment.original.segmentIndex,
segmentKey: segmentKeyForRow,
locatorProjection,
textHash: segment.textHash,
textLength: segment.text.length,
});
if (movedFromEntryId) {
await db
.update(ttsSegmentVariants)
@ -375,6 +383,44 @@ export async function POST(request: NextRequest) {
segmentId: segment.segmentId,
});
if (scope.authEnabled && scope.userId && isTtsRateLimitEnabled()) {
const charCount = segment.text.length;
const ip = getClientIp(request);
const device = scope.isAnonymousUser ? getOrCreateDeviceId(request) : null;
if (device?.didCreate) {
didCreateDeviceIdCookie = true;
deviceIdToSet = device.deviceId;
}
const rateLimitResult = await rateLimiter.checkAndIncrementLimit(
{ id: scope.userId, isAnonymous: scope.isAnonymousUser },
charCount,
{
deviceId: device?.deviceId ?? null,
ip,
},
);
if (!rateLimitResult.allowed) {
const response = buildDailyQuotaExceededResponse({
rateLimitResult,
isAnonymousUser: scope.isAnonymousUser,
pathname: request.nextUrl.pathname,
});
attachDeviceIdCookie(response, deviceIdToSet, didCreateDeviceIdCookie);
return response;
}
}
await upsertSegmentEntry({
segmentEntryId,
segmentIndex: segment.original.segmentIndex,
segmentKey: segmentKeyForRow,
locatorProjection,
textHash: segment.textHash,
textLength: segment.text.length,
});
await db
.insert(ttsSegmentVariants)
.values({
@ -408,54 +454,6 @@ export async function POST(request: NextRequest) {
}
try {
if (scope.authEnabled && scope.userId && isTtsRateLimitEnabled()) {
const charCount = segment.text.length;
const ip = getClientIp(request);
const device = scope.isAnonymousUser ? getOrCreateDeviceId(request) : null;
if (device?.didCreate) {
didCreateDeviceIdCookie = true;
deviceIdToSet = device.deviceId;
}
const rateLimitResult = await rateLimiter.checkAndIncrementLimit(
{ id: scope.userId, isAnonymous: scope.isAnonymousUser },
charCount,
{
deviceId: device?.deviceId ?? null,
ip,
},
);
if (!rateLimitResult.allowed) {
const resetTimeMs = rateLimitResult.resetTimeMs;
const retryAfterSeconds = Math.max(0, Math.ceil((resetTimeMs - Date.now()) / 1000));
const response = new NextResponse(JSON.stringify({
type: 'https://openreader.app/problems/daily-quota-exceeded',
title: 'Daily quota exceeded',
status: 429,
detail: 'Daily character limit exceeded',
code: 'USER_DAILY_QUOTA_EXCEEDED',
currentCount: rateLimitResult.currentCount,
limit: rateLimitResult.limit,
remainingChars: rateLimitResult.remainingChars,
resetTimeMs,
userType: scope.isAnonymousUser ? 'anonymous' : 'authenticated',
upgradeHint: scope.isAnonymousUser
? `Sign up to increase your limit from ${formatLimitForHint(RATE_LIMITS.ANONYMOUS)} to ${formatLimitForHint(RATE_LIMITS.AUTHENTICATED)} characters per day`
: undefined,
instance: request.nextUrl.pathname,
}), {
status: 429,
headers: {
'Content-Type': 'application/problem+json',
'Retry-After': String(retryAfterSeconds),
},
});
attachDeviceIdCookie(response, deviceIdToSet, didCreateDeviceIdCookie);
return response;
}
}
const ttsBuffer = await generateTTSBuffer({
text: segment.text,
voice: parsed.settings.voice,

View file

@ -0,0 +1,45 @@
import { NextResponse } from 'next/server';
import { RATE_LIMITS, type RateLimitResult } from '@/lib/server/rate-limit/rate-limiter';
function formatLimitForHint(limit: number): string {
if (!Number.isFinite(limit) || limit <= 0) return String(limit);
if (limit >= 1_000_000) {
const m = limit / 1_000_000;
return `${m % 1 === 0 ? m.toFixed(0) : m.toFixed(1)}M`;
}
if (limit >= 1_000) return `${Math.round(limit / 1_000)}K`;
return String(limit);
}
export function buildDailyQuotaExceededResponse(input: {
rateLimitResult: RateLimitResult;
isAnonymousUser: boolean;
pathname: string;
}): NextResponse {
const { rateLimitResult, isAnonymousUser, pathname } = input;
const resetTimeMs = rateLimitResult.resetTimeMs;
const retryAfterSeconds = Math.max(0, Math.ceil((resetTimeMs - Date.now()) / 1000));
return new NextResponse(JSON.stringify({
type: 'https://openreader.app/problems/daily-quota-exceeded',
title: 'Daily quota exceeded',
status: 429,
detail: 'Daily character limit exceeded',
code: 'USER_DAILY_QUOTA_EXCEEDED',
currentCount: rateLimitResult.currentCount,
limit: rateLimitResult.limit,
remainingChars: rateLimitResult.remainingChars,
resetTimeMs,
userType: isAnonymousUser ? 'anonymous' : 'authenticated',
upgradeHint: isAnonymousUser
? `Sign up to increase your limit from ${formatLimitForHint(RATE_LIMITS.ANONYMOUS)} to ${formatLimitForHint(RATE_LIMITS.AUTHENTICATED)} characters per day`
: undefined,
instance: pathname,
}), {
status: 429,
headers: {
'Content-Type': 'application/problem+json',
'Retry-After': String(retryAfterSeconds),
},
});
}

View file

@ -3,7 +3,7 @@ import { expect, test } from '@playwright/test';
import {
buildMonotonicWordToTokenMap,
tokenizeCanonicalSegment,
} from '../../src/lib/shared/epub-word-highlight';
} from '../../src/lib/client/epub/epub-word-highlight';
import type { CanonicalTtsSegment } from '../../src/lib/shared/tts-segment-plan';
import type { TTSSentenceAlignment } from '../../src/types/tts';

View file

@ -5,7 +5,7 @@ import {
resolveEpubBoundaryHandoffStartIndex,
resolveEpubReplaySuppressionAction,
shouldSuppressCompletedEpubBoundaryReplay,
} from '../../src/lib/shared/tts-epub-handoff';
} from '../../src/lib/client/epub/tts-epub-handoff';
import type { CanonicalTtsSegment } from '../../src/lib/shared/tts-segment-plan';
const segment = (

View file

@ -4,7 +4,7 @@ import type { CanonicalTtsSourceUnit } from '../../src/lib/shared/tts-segment-pl
import {
buildWalkerPlanningSourceUnits,
selectUpcomingWalkerItems,
} from '../../src/lib/shared/tts-epub-preload';
} from '../../src/lib/client/epub/tts-epub-preload';
test.describe('EPUB walker preload helpers', () => {
test('selectUpcomingWalkerItems honors depth as current + (depth-1) upcoming', () => {