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:
parent
08d9218ce4
commit
d7e6a7b7ba
6 changed files with 141 additions and 133 deletions
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
45
src/lib/server/rate-limit/problem-response.ts
Normal file
45
src/lib/server/rate-limit/problem-response.ts
Normal 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),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
|
@ -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';
|
||||
|
||||
|
|
|
|||
|
|
@ -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 = (
|
||||
|
|
|
|||
|
|
@ -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', () => {
|
||||
|
|
|
|||
Loading…
Reference in a new issue