refactor: roll out phase 7 server error contract

This commit is contained in:
Richard R 2026-05-27 07:24:07 -06:00
parent 3da13c2970
commit 00ce39ed74
41 changed files with 869 additions and 161 deletions

View file

@ -17,6 +17,8 @@ const DYNAMIC_LOGGER_CALL_SELECTOR =
const LOGGER_RECEIVER_SELECTOR =
":matches([callee.object.name=/^(logger|serverLogger)$/],[callee.object.property.name='logger'])";
const SERVER_LOGGER_CALL_SELECTOR = `:matches(${STATIC_LOGGER_CALL_SELECTOR}${LOGGER_RECEIVER_SELECTOR},${DYNAMIC_LOGGER_CALL_SELECTOR}${LOGGER_RECEIVER_SELECTOR})`;
const NEXT_RESPONSE_ERROR_JSON_SELECTOR =
"CallExpression[callee.type='MemberExpression'][callee.object.name='NextResponse'][callee.property.name='json'][arguments.0.type='ObjectExpression']:has(Property[key.name='error'])";
const eslintConfig = [
...compat.extends("next/core-web-vitals", "next/typescript"),
@ -87,6 +89,11 @@ const eslintConfig = [
message:
"Do not use top-level `detail` in server logger context; keep throwable text under `error.message`.",
},
{
selector: `${SERVER_LOGGER_CALL_SELECTOR} > ObjectExpression:first-child > Property[key.name='errorCode']`,
message:
"Do not use top-level `errorCode` in server logger context; classify failures under nested `error.code`.",
},
{
selector: `${SERVER_LOGGER_CALL_SELECTOR} > ObjectExpression:first-child > Property[key.name='err']`,
message:
@ -102,6 +109,11 @@ const eslintConfig = [
message:
"Server logger `error` must be a structured object (prefer `errorToLog(...)`), not template text.",
},
{
selector: `CatchClause ReturnStatement > ${NEXT_RESPONSE_ERROR_JSON_SELECTOR}[arguments.1.type='ObjectExpression']:has(Property[key.name='status'][value.value=500])`,
message:
"Use shared error response helpers (e.g. errorResponse(...)) for terminal 500 route failures instead of direct NextResponse.json({ error }).",
},
],
},
},

View file

@ -25,7 +25,8 @@
"compute:worker:dev": "pnpm --filter @openreader/compute-worker dev",
"compute:worker:start": "pnpm --filter @openreader/compute-worker start",
"compute:dev:compose": "docker compose --env-file compute/worker/.env -f compute/worker/docker-compose.yml up --build",
"compute:dev:watch": "docker compose --env-file compute/worker/.env -f compute/worker/docker-compose.yml up --watch"
"compute:dev:watch": "docker compose --env-file compute/worker/.env -f compute/worker/docker-compose.yml up --watch",
"lint:route-errors": "node scripts/check-route-error-responses.mjs"
},
"dependencies": {
"@aws-sdk/client-s3": "^3.1045.0",

View file

@ -0,0 +1,54 @@
#!/usr/bin/env node
import fs from 'node:fs/promises';
import path from 'node:path';
const ROOT = process.cwd();
const API_DIR = path.join(ROOT, 'src', 'app', 'api');
async function walk(dir) {
const entries = await fs.readdir(dir, { withFileTypes: true });
const files = [];
for (const entry of entries) {
const full = path.join(dir, entry.name);
if (entry.isDirectory()) {
files.push(...await walk(full));
continue;
}
if (entry.isFile() && full.endsWith('.ts')) files.push(full);
}
return files;
}
function findViolations(source) {
const violations = [];
const catchWithDirectJson500 =
/catch\s*\(\s*error\s*\)\s*\{[\s\S]{0,1200}?NextResponse\.json\s*\(\s*\{\s*error:[\s\S]{0,240}status:\s*500/g;
let match;
while ((match = catchWithDirectJson500.exec(source)) !== null) {
const index = match.index;
const line = source.slice(0, index).split('\n').length;
violations.push(line);
}
return violations;
}
const files = await walk(API_DIR);
const failures = [];
for (const file of files) {
const source = await fs.readFile(file, 'utf8');
const lines = findViolations(source);
if (lines.length > 0) {
failures.push({ file, lines });
}
}
if (failures.length > 0) {
for (const failure of failures) {
for (const line of failure.lines) {
console.error(`${path.relative(ROOT, failure.file)}:${line} direct NextResponse.json({ error }) in catch`);
}
}
process.exit(1);
}
console.log('Route error response check passed.');

View file

@ -5,6 +5,7 @@ import { isAuthEnabled } from '@/lib/server/auth/config';
import { getOpenReaderTestNamespace } from '@/lib/server/testing/test-namespace';
import { deleteUserStorageData } from '@/lib/server/user/data-cleanup';
import { errorToLog, hashForLog, serverLogger } from '@/lib/server/logger';
import { errorResponse } from '@/lib/server/errors/next-response';
export async function DELETE() {
if (!isAuthEnabled() || !auth) {
@ -51,9 +52,9 @@ export async function DELETE() {
event: 'account.delete.failed',
error: errorToLog(error),
}, 'Failed to delete account');
return NextResponse.json(
{ error: 'Failed to delete account' },
{ status: 500 }
);
return errorResponse(error, {
apiErrorMessage: 'Failed to delete account',
normalize: { code: 'ACCOUNT_DELETE_FAILED', errorClass: 'db' },
});
}
}

View file

@ -1,6 +1,7 @@
import { NextRequest, NextResponse } from 'next/server';
import { requireAdminContext } from '@/lib/server/auth/admin';
import { errorToLog, serverLogger } from '@/lib/server/logger';
import { errorResponse } from '@/lib/server/errors/next-response';
import {
AdminProviderError,
deleteAdminProvider,
@ -47,7 +48,10 @@ export async function PUT(
providerId: id,
error: errorToLog(error),
}, 'Admin provider update failed');
return NextResponse.json({ error: 'Internal error' }, { status: 500 });
return errorResponse(error, {
apiErrorMessage: 'Internal error',
normalize: { code: 'ADMIN_PROVIDERS_UPDATE_FAILED', errorClass: 'db' },
});
}
}
@ -71,6 +75,9 @@ export async function DELETE(
providerId: id,
error: errorToLog(error),
}, 'Admin provider delete failed');
return NextResponse.json({ error: 'Internal error' }, { status: 500 });
return errorResponse(error, {
apiErrorMessage: 'Internal error',
normalize: { code: 'ADMIN_PROVIDERS_DELETE_FAILED', errorClass: 'db' },
});
}
}

View file

@ -1,6 +1,7 @@
import { NextRequest, NextResponse } from 'next/server';
import { requireAdminContext } from '@/lib/server/auth/admin';
import { errorToLog, serverLogger } from '@/lib/server/logger';
import { errorResponse } from '@/lib/server/errors/next-response';
import {
AdminProviderError,
createAdminProvider,
@ -51,6 +52,9 @@ export async function POST(req: NextRequest) {
event: 'admin.providers.create.failed',
error: errorToLog(error),
}, 'Admin provider create failed');
return NextResponse.json({ error: 'Internal error' }, { status: 500 });
return errorResponse(error, {
apiErrorMessage: 'Internal error',
normalize: { code: 'ADMIN_PROVIDERS_CREATE_FAILED', errorClass: 'db' },
});
}
}

View file

@ -44,6 +44,7 @@ import {
coerceAudiobookGenerationSettings,
type SharedProviderPolicyEntry,
} from '@/lib/server/audiobooks/settings';
import { errorResponse } from '@/lib/server/errors/next-response';
export const dynamic = 'force-dynamic';
@ -340,7 +341,10 @@ export async function POST(request: NextRequest) {
message: 'Invalid audiobook.meta.json settings payload',
},
}, 'Invalid audiobook.meta.json settings payload');
return NextResponse.json({ error: 'Invalid audiobook metadata settings' }, { status: 500 });
return errorResponse(new Error('Invalid audiobook metadata settings payload'), {
apiErrorMessage: 'Invalid audiobook metadata settings',
normalize: { code: 'AUDIOBOOK_CHAPTER_META_SETTINGS_INVALID', errorClass: 'validation', httpStatus: 500 },
});
}
normalizedExistingSettings = normalizeNativeSpeedForSettings(existingResult.settings);
existingSettingsNeedsMigration = existingResult.migrated;
@ -419,7 +423,7 @@ export async function POST(request: NextRequest) {
step: 'persist_migrated_settings',
bookId,
storageUserId,
error: error instanceof Error ? error.message : String(error),
error: errorToLog(error),
}, 'Failed to persist migrated audiobook metadata settings');
}
}
@ -503,7 +507,10 @@ export async function POST(request: NextRequest) {
}
const provider = credResolved.provider;
if (!isBuiltInTtsProviderId(provider)) {
return NextResponse.json({ error: `Unsupported TTS provider type: ${provider}` }, { status: 500 });
return errorResponse(new Error(`Unsupported TTS provider type: ${provider}`), {
apiErrorMessage: `Unsupported TTS provider type: ${provider}`,
normalize: { code: 'AUDIOBOOK_CHAPTER_UNSUPPORTED_PROVIDER', errorClass: 'validation', httpStatus: 500 },
});
}
const openApiKey = credResolved.apiKey || 'none';
const openApiBaseUrl = credResolved.baseUrl;
@ -754,7 +761,10 @@ export async function POST(request: NextRequest) {
event: 'audiobook.chapter.process.failed',
error: errorToLog(error),
}, 'Failed to process audio chapter');
const response = NextResponse.json({ error: 'Failed to process audio chapter' }, { status: 500 });
const response = errorResponse(error, {
apiErrorMessage: 'Failed to process audio chapter',
normalize: { code: 'AUDIOBOOK_CHAPTER_PROCESS_FAILED', errorClass: 'upstream' },
});
attachDeviceIdCookie(response, deviceIdToSet, didCreateDeviceIdCookie);
return response;
} finally {
@ -852,7 +862,10 @@ export async function GET(request: NextRequest) {
event: 'audiobook.chapter.download.failed',
error: errorToLog(error),
}, 'Failed to download chapter');
return NextResponse.json({ error: 'Failed to download chapter' }, { status: 500 });
return errorResponse(error, {
apiErrorMessage: 'Failed to download chapter',
normalize: { code: 'AUDIOBOOK_CHAPTER_DOWNLOAD_FAILED', errorClass: 'storage' },
});
}
}
@ -923,6 +936,9 @@ export async function DELETE(request: NextRequest) {
event: 'audiobook.chapter.delete.failed',
error: errorToLog(error),
}, 'Failed to delete chapter');
return NextResponse.json({ error: 'Failed to delete chapter' }, { status: 500 });
return errorResponse(error, {
apiErrorMessage: 'Failed to delete chapter',
normalize: { code: 'AUDIOBOOK_CHAPTER_DELETE_FAILED', errorClass: 'db' },
});
}
}

View file

@ -8,6 +8,7 @@ import { db } from '@/db';
import { audiobooks, audiobookChapters } from '@/db/schema';
import { requireAuthContext } from '@/lib/server/auth/auth';
import { errorToLog, serverLogger } from '@/lib/server/logger';
import { errorResponse } from '@/lib/server/errors/next-response';
import {
audiobookPrefix,
deleteAudiobookObject,
@ -380,7 +381,10 @@ export async function GET(request: NextRequest) {
event: 'audiobook.create.failed',
error: errorToLog(error),
}, 'Failed to create full audiobook');
return NextResponse.json({ error: 'Failed to create full audiobook file' }, { status: 500 });
return errorResponse(error, {
apiErrorMessage: 'Failed to create full audiobook file',
normalize: { code: 'AUDIOBOOK_CREATE_FAILED', errorClass: 'upstream' },
});
} finally {
if (workDir) await rm(workDir, { recursive: true, force: true }).catch(() => {});
}
@ -431,6 +435,9 @@ export async function DELETE(request: NextRequest) {
event: 'audiobook.reset.failed',
error: errorToLog(error),
}, 'Failed to reset audiobook');
return NextResponse.json({ error: 'Failed to reset audiobook' }, { status: 500 });
return errorResponse(error, {
apiErrorMessage: 'Failed to reset audiobook',
normalize: { code: 'AUDIOBOOK_RESET_FAILED', errorClass: 'db' },
});
}
}

View file

@ -12,6 +12,7 @@ import { buildAllowedAudiobookUserIds, pickAudiobookOwner } from '@/lib/server/a
import type { AudiobookGenerationSettings } from '@/types/client';
import type { TTSAudiobookChapter, TTSAudiobookFormat } from '@/types/tts';
import { errorToLog, serverLogger } from '@/lib/server/logger';
import { errorResponse } from '@/lib/server/errors/next-response';
export const dynamic = 'force-dynamic';
@ -160,6 +161,9 @@ export async function GET(request: NextRequest) {
event: 'audiobook.status.fetch.failed',
error: errorToLog(error),
}, 'Failed to fetch audiobook chapters');
return NextResponse.json({ error: 'Failed to fetch chapters' }, { status: 500 });
return errorResponse(error, {
apiErrorMessage: 'Failed to fetch chapters',
normalize: { code: 'AUDIOBOOK_STATUS_FETCH_FAILED', errorClass: 'db' },
});
}
}

View file

@ -11,7 +11,9 @@ import { normalizeParseStatus, parseDocumentParseState } from '@/lib/server/docu
import { healStaleDocumentParseState } from '@/lib/server/documents/parse-state-healing';
import { getOpenReaderTestNamespace, getUnclaimedUserIdForNamespace } from '@/lib/server/testing/test-namespace';
import { isS3Configured } from '@/lib/server/storage/s3';
import { createRequestLogger, errorToLog, hashForLog } from '@/lib/server/logger';
import { createRequestLogger, hashForLog } from '@/lib/server/logger';
import { errorResponse } from '@/lib/server/errors/next-response';
import { logDegraded, logServerError } from '@/lib/server/errors/logging';
import type { PdfParseProgress, PdfParseStatus } from '@/types/parsed-pdf';
import { parseSseEventId, parseSsePayload } from '@openreader/compute-core';
import type { PdfLayoutJobResult, WorkerOperationEvent, WorkerOperationState } from '@openreader/compute-core/api-contracts';
@ -267,15 +269,17 @@ export async function GET(req: NextRequest, ctx: { params: Promise<{ id: string
}
}).catch((error) => {
if (closed) return;
logger.warn({
logDegraded(logger, {
event: 'documents.parsed.events.db_resync_failed',
degraded: true,
msg: 'SSE DB resync failed',
step: 'db_resync',
documentId: id,
storageUserIdHash,
requestId,
error: errorToLog(error),
}, 'SSE DB resync failed');
context: {
documentId: id,
storageUserIdHash,
requestId,
},
error,
});
controller.enqueue(encoder.encode(`event: error\ndata: ${JSON.stringify({ error: String(error) })}\n\n`));
});
}, SSE_RESYNC_INTERVAL_MS);
@ -468,11 +472,16 @@ export async function GET(req: NextRequest, ctx: { params: Promise<{ id: string
void runWorkerProxy()
.catch((error) => {
logger.error({
logServerError(logger, {
event: 'documents.parsed.events.worker_proxy_crashed',
documentId: id,
error: errorToLog(error),
}, 'Worker proxy crashed while streaming parse events');
error,
msg: 'Worker proxy crashed while streaming parse events',
context: { documentId: id },
normalize: {
code: 'DOCUMENTS_PARSED_EVENTS_WORKER_PROXY_CRASHED',
errorClass: 'upstream',
},
});
if (!closed) {
controller.enqueue(encoder.encode(`event: error\ndata: ${JSON.stringify({ error: String(error) })}\n\n`));
}
@ -499,10 +508,12 @@ export async function GET(req: NextRequest, ctx: { params: Promise<{ id: string
},
});
} catch (error) {
logger.error({
return errorResponse(error, {
logger,
event: 'documents.parsed.events.route_failed',
error: errorToLog(error),
}, 'Parsed events route failed');
return NextResponse.json({ error: 'Failed to stream parsed PDF progress' }, { status: 500 });
msg: 'Parsed events route failed',
apiErrorMessage: 'Failed to stream parsed PDF progress',
normalize: { code: 'DOCUMENTS_PARSED_EVENTS_ROUTE_FAILED', errorClass: 'upstream' },
});
}
}

View file

@ -23,7 +23,9 @@ import {
import { healStaleDocumentParseState } from '@/lib/server/documents/parse-state-healing';
import { getOpenReaderTestNamespace, getUnclaimedUserIdForNamespace } from '@/lib/server/testing/test-namespace';
import { isS3Configured } from '@/lib/server/storage/s3';
import { createRequestLogger, errorToLog, hashForLog, type ServerLogger } from '@/lib/server/logger';
import { createRequestLogger, hashForLog, type ServerLogger } from '@/lib/server/logger';
import { errorResponse } from '@/lib/server/errors/next-response';
import { logDegraded } from '@/lib/server/errors/logging';
import type { ParsedPdfDocument } from '@/types/parsed-pdf';
import type { PdfLayoutJobResult, WorkerOperationState } from '@openreader/compute-core/api-contracts';
@ -138,7 +140,10 @@ async function finalizeFromWorkerState(input: {
}
if (!parsedJsonKey) {
return NextResponse.json({ error: 'Worker completed without parsed output' }, { status: 500 });
return errorResponse(new Error('Worker completed without parsed output'), {
apiErrorMessage: 'Worker completed without parsed output',
normalize: { code: 'DOCUMENTS_PARSED_WORKER_OUTPUT_MISSING', errorClass: 'upstream' },
});
}
await writeParseRowState({
@ -218,18 +223,20 @@ export async function GET(req: NextRequest, ctx: { params: Promise<{ id: string
logger,
});
}
logger.warn({
logDegraded(logger, {
event: 'documents.parsed.requested_op_unavailable',
degraded: true,
msg: 'Requested worker operation id was unavailable',
step: 'requested_op_lookup',
documentId: id,
userIdHash: hashForLog(row.userId),
opId: requestedOpId,
error: {
name: 'RequestedOperationUnavailable',
message: `Requested worker operation ${requestedOpId} was unavailable`,
context: {
documentId: id,
userIdHash: hashForLog(row.userId),
opId: requestedOpId,
error: {
name: 'RequestedOperationUnavailable',
message: `Requested worker operation ${requestedOpId} was unavailable`,
},
},
}, 'Requested worker operation id was unavailable');
});
}
let state = parseDocumentParseState(row.parseState);
@ -311,11 +318,13 @@ export async function GET(req: NextRequest, ctx: { params: Promise<{ id: string
throw error;
}
} catch (error) {
logger.error({
return errorResponse(error, {
logger,
event: 'documents.parsed.get_failed',
error: errorToLog(error),
}, 'Failed to read parsed PDF');
return NextResponse.json({ error: 'Failed to read parsed PDF' }, { status: 500 });
msg: 'Failed to read parsed PDF',
apiErrorMessage: 'Failed to read parsed PDF',
normalize: { code: 'DOCUMENTS_PARSED_GET_FAILED', errorClass: 'storage' },
});
}
}
@ -391,10 +400,12 @@ export async function POST(req: NextRequest, ctx: { params: Promise<{ id: string
opId: created.opId,
}, { status: 202 });
} catch (error) {
logger.error({
return errorResponse(error, {
logger,
event: 'documents.parsed.force_refresh_failed',
error: errorToLog(error),
}, 'Failed to force PDF refresh');
return NextResponse.json({ error: 'Failed to force PDF refresh' }, { status: 500 });
msg: 'Failed to force PDF refresh',
apiErrorMessage: 'Failed to force PDF refresh',
normalize: { code: 'DOCUMENTS_PARSED_FORCE_REFRESH_FAILED', errorClass: 'upstream' },
});
}
}

View file

@ -8,6 +8,7 @@ import { mergeDocumentSettings } from '@/lib/shared/document-settings';
import { DEFAULT_DOCUMENT_SETTINGS, type DocumentSettings } from '@/types/document-settings';
import { coerceTimestampMs, nowTimestampMs } from '@/lib/shared/timestamps';
import { errorToLog, serverLogger } from '@/lib/server/logger';
import { errorResponse } from '@/lib/server/errors/next-response';
export const dynamic = 'force-dynamic';
@ -97,7 +98,10 @@ export async function GET(req: NextRequest, ctx: { params: Promise<{ id: string
event: 'documents.settings.load.failed',
error: errorToLog(error),
}, 'Failed to load document settings');
return NextResponse.json({ error: 'Failed to load document settings' }, { status: 500 });
return errorResponse(error, {
apiErrorMessage: 'Failed to load document settings',
normalize: { code: 'DOCUMENTS_SETTINGS_LOAD_FAILED', errorClass: 'db' },
});
}
}
@ -169,6 +173,9 @@ export async function PUT(req: NextRequest, ctx: { params: Promise<{ id: string
event: 'documents.settings.update.failed',
error: errorToLog(error),
}, 'Failed to update document settings');
return NextResponse.json({ error: 'Failed to update document settings' }, { status: 500 });
return errorResponse(error, {
apiErrorMessage: 'Failed to update document settings',
normalize: { code: 'DOCUMENTS_SETTINGS_UPDATE_FAILED', errorClass: 'db' },
});
}
}

View file

@ -8,6 +8,7 @@ import { getDocumentBlob, isMissingBlobError, isValidDocumentId } from '@/lib/se
import { getOpenReaderTestNamespace, getUnclaimedUserIdForNamespace } from '@/lib/server/testing/test-namespace';
import { isS3Configured } from '@/lib/server/storage/s3';
import { errorToLog, serverLogger } from '@/lib/server/logger';
import { errorResponse } from '@/lib/server/errors/next-response';
export const dynamic = 'force-dynamic';
@ -91,6 +92,9 @@ export async function GET(req: NextRequest) {
event: 'documents.blob.get.fallback.failed',
error: errorToLog(error),
}, 'Failed to load document content fallback');
return NextResponse.json({ error: 'Failed to load document content' }, { status: 500 });
return errorResponse(error, {
apiErrorMessage: 'Failed to load document content',
normalize: { code: 'DOCUMENTS_BLOB_GET_FALLBACK_FAILED', errorClass: 'storage' },
});
}
}

View file

@ -7,6 +7,7 @@ import { isValidDocumentId, presignGet } from '@/lib/server/documents/blobstore'
import { getOpenReaderTestNamespace, getUnclaimedUserIdForNamespace } from '@/lib/server/testing/test-namespace';
import { isS3Configured } from '@/lib/server/storage/s3';
import { errorToLog, serverLogger } from '@/lib/server/logger';
import { errorResponse } from '@/lib/server/errors/next-response';
export const dynamic = 'force-dynamic';
@ -72,6 +73,9 @@ export async function GET(req: NextRequest) {
event: 'documents.blob.get.presign.failed',
error: errorToLog(error),
}, 'Failed to create document download signature');
return NextResponse.json({ error: 'Failed to prepare document download' }, { status: 500 });
return errorResponse(error, {
apiErrorMessage: 'Failed to prepare document download',
normalize: { code: 'DOCUMENTS_BLOB_GET_PRESIGN_FAILED', errorClass: 'storage' },
});
}
}

View file

@ -3,6 +3,7 @@ import { presignDocumentPreviewGet } from '@/lib/server/documents/previews-blobs
import { ensureDocumentPreview } from '@/lib/server/documents/previews';
import { validatePreviewRequest } from '../utils';
import { errorToLog, serverLogger } from '@/lib/server/logger';
import { errorResponse } from '@/lib/server/errors/next-response';
export const dynamic = 'force-dynamic';
@ -55,6 +56,9 @@ export async function GET(req: NextRequest) {
event: 'documents.preview.ensure.failed',
error: errorToLog(error),
}, 'Failed to ensure document preview');
return NextResponse.json({ error: 'Failed to ensure document preview' }, { status: 500 });
return errorResponse(error, {
apiErrorMessage: 'Failed to ensure document preview',
normalize: { code: 'DOCUMENTS_PREVIEW_ENSURE_FAILED', errorClass: 'storage' },
});
}
}

View file

@ -4,6 +4,7 @@ import { db } from '@/db';
import { documents } from '@/db/schema';
import { requireAuthContext } from '@/lib/server/auth/auth';
import { errorToLog, serverLogger } from '@/lib/server/logger';
import { errorResponse } from '@/lib/server/errors/next-response';
import {
getDocumentRange,
isMissingBlobError as isMissingDocumentBlobError,
@ -183,6 +184,9 @@ export async function GET(req: NextRequest) {
event: 'documents.preview.fallback.failed',
error: errorToLog(error),
}, 'Failed to load document preview fallback');
return NextResponse.json({ error: 'Failed to load document preview' }, { status: 500 });
return errorResponse(error, {
apiErrorMessage: 'Failed to load document preview',
normalize: { code: 'DOCUMENTS_PREVIEW_FALLBACK_FAILED', errorClass: 'storage' },
});
}
}

View file

@ -3,6 +3,7 @@ import { presignDocumentPreviewGet } from '@/lib/server/documents/previews-blobs
import { ensureDocumentPreview } from '@/lib/server/documents/previews';
import { validatePreviewRequest } from '../utils';
import { errorToLog, serverLogger } from '@/lib/server/logger';
import { errorResponse } from '@/lib/server/errors/next-response';
export const dynamic = 'force-dynamic';
@ -59,6 +60,9 @@ export async function GET(req: NextRequest) {
event: 'documents.preview.presign.failed',
error: errorToLog(error),
}, 'Failed to create document preview signature');
return NextResponse.json({ error: 'Failed to prepare document preview' }, { status: 500 });
return errorResponse(error, {
apiErrorMessage: 'Failed to prepare document preview',
normalize: { code: 'DOCUMENTS_PREVIEW_PRESIGN_FAILED', errorClass: 'storage' },
});
}
}

View file

@ -4,6 +4,7 @@ import { isValidDocumentId, putDocumentBlob } from '@/lib/server/documents/blobs
import { isS3Configured } from '@/lib/server/storage/s3';
import { getOpenReaderTestNamespace } from '@/lib/server/testing/test-namespace';
import { errorToLog, serverLogger } from '@/lib/server/logger';
import { errorResponse } from '@/lib/server/errors/next-response';
export const dynamic = 'force-dynamic';
@ -58,6 +59,9 @@ export async function PUT(req: NextRequest) {
event: 'documents.blob.upload.fallback.failed',
error: errorToLog(error),
}, 'Failed to proxy-upload document blob');
return NextResponse.json({ error: 'Failed to upload document blob' }, { status: 500 });
return errorResponse(error, {
apiErrorMessage: 'Failed to upload document blob',
normalize: { code: 'DOCUMENTS_BLOB_UPLOAD_FALLBACK_FAILED', errorClass: 'storage' },
});
}
}

View file

@ -4,6 +4,7 @@ import { isValidDocumentId, presignPut } from '@/lib/server/documents/blobstore'
import { getOpenReaderTestNamespace } from '@/lib/server/testing/test-namespace';
import { isS3Configured } from '@/lib/server/storage/s3';
import { errorToLog, serverLogger } from '@/lib/server/logger';
import { errorResponse } from '@/lib/server/errors/next-response';
export const dynamic = 'force-dynamic';
@ -70,6 +71,9 @@ export async function POST(req: NextRequest) {
event: 'documents.blob.upload.presign.failed',
error: errorToLog(error),
}, 'Failed to create document upload signatures');
return NextResponse.json({ error: 'Failed to presign uploads' }, { status: 500 });
return errorResponse(error, {
apiErrorMessage: 'Failed to presign uploads',
normalize: { code: 'DOCUMENTS_BLOB_UPLOAD_PRESIGN_FAILED', errorClass: 'storage' },
});
}
}

View file

@ -16,6 +16,7 @@ import { getOpenReaderTestNamespace, getUnclaimedUserIdForNamespace } from '@/li
import { isS3Configured } from '@/lib/server/storage/s3';
import { putDocumentBlob } from '@/lib/server/documents/blobstore';
import { errorToLog, serverLogger } from '@/lib/server/logger';
import { errorResponse } from '@/lib/server/errors/next-response';
const DOCSTORE_DIR = path.join(process.cwd(), 'docstore');
const TEMP_DIR = path.join(DOCSTORE_DIR, 'tmp');
@ -195,6 +196,9 @@ export async function POST(req: NextRequest) {
event: 'documents.docx.convert_upload.failed',
error: errorToLog(error),
}, 'Failed converting/uploading DOCX');
return NextResponse.json({ error: 'Failed to convert document' }, { status: 500 });
return errorResponse(error, {
apiErrorMessage: 'Failed to convert document',
normalize: { code: 'DOCUMENTS_DOCX_CONVERT_UPLOAD_FAILED', errorClass: 'upstream' },
});
}
}

View file

@ -6,6 +6,7 @@ import { parseLibraryRoots } from '@/lib/server/storage/library-mount';
import type { DocumentType } from '@/types/documents';
import { auth } from '@/lib/server/auth/auth';
import { errorToLog, serverLogger } from '@/lib/server/logger';
import { errorResponse } from '@/lib/server/errors/next-response';
export const dynamic = 'force-dynamic';
@ -116,7 +117,10 @@ export async function GET(req: NextRequest) {
event: 'documents.library.auth_check.failed',
error: errorToLog(error),
}, 'Failed to check auth for library route');
return NextResponse.json({ error: 'Error checking auth' }, { status: 500 });
return errorResponse(error, {
apiErrorMessage: 'Error checking auth',
normalize: { code: 'DOCUMENTS_LIBRARY_AUTH_CHECK_FAILED', errorClass: 'auth' },
});
}
const url = new URL(req.url);

View file

@ -5,6 +5,7 @@ import { documents } from '@/db/schema';
import { requireAuthContext } from '@/lib/server/auth/auth';
import { safeDocumentName, toDocumentTypeFromName } from '@/lib/server/documents/utils';
import { errorToLog, serverLogger } from '@/lib/server/logger';
import { errorResponse } from '@/lib/server/errors/next-response';
import {
cleanupDocumentPreviewArtifacts,
deleteDocumentPreviewRows,
@ -197,7 +198,10 @@ export async function POST(req: NextRequest) {
event: 'documents.register.failed',
error: errorToLog(error),
}, 'Failed to register documents');
return NextResponse.json({ error: 'Failed to register documents' }, { status: 500 });
return errorResponse(error, {
apiErrorMessage: 'Failed to register documents',
normalize: { code: 'DOCUMENTS_REGISTER_FAILED', errorClass: 'db' },
});
}
}
@ -276,7 +280,10 @@ export async function GET(req: NextRequest) {
event: 'documents.list.failed',
error: errorToLog(error),
}, 'Failed to load document metadata');
return NextResponse.json({ error: 'Failed to load documents' }, { status: 500 });
return errorResponse(error, {
apiErrorMessage: 'Failed to load documents',
normalize: { code: 'DOCUMENTS_LIST_FAILED', errorClass: 'db' },
});
}
}
@ -391,6 +398,9 @@ export async function DELETE(req: NextRequest) {
event: 'documents.delete.failed',
error: errorToLog(error),
}, 'Failed to delete documents');
return NextResponse.json({ error: 'Failed to delete documents' }, { status: 500 });
return errorResponse(error, {
apiErrorMessage: 'Failed to delete documents',
normalize: { code: 'DOCUMENTS_DELETE_FAILED', errorClass: 'db' },
});
}
}

View file

@ -7,6 +7,7 @@ import { getClientIp } from '@/lib/server/rate-limit/request-ip';
import { getOrCreateDeviceId, setDeviceIdCookie } from '@/lib/server/rate-limit/device-id';
import { nextUtcMidnightTimestampMs } from '@/lib/shared/timestamps';
import { errorToLog, serverLogger } from '@/lib/server/logger';
import { errorResponse } from '@/lib/server/errors/next-response';
export const dynamic = 'force-dynamic';
@ -89,9 +90,9 @@ export async function GET(req: NextRequest) {
event: 'rate_limit.status.get.failed',
error: errorToLog(error),
}, 'Failed to get rate limit status');
return NextResponse.json(
{ error: 'Failed to get rate limit status' },
{ status: 500 }
);
return errorResponse(error, {
apiErrorMessage: 'Failed to get rate limit status',
normalize: { code: 'RATE_LIMIT_STATUS_GET_FAILED', errorClass: 'db' },
});
}
}

View file

@ -7,7 +7,8 @@ import {
ttsSegmentsS3NotConfiguredResponse,
} from '@/lib/server/tts/segments-audio';
import { getTtsSegmentAudioObjectStream } from '@/lib/server/tts/segments-blobstore';
import { createRequestLogger, errorToLog } from '@/lib/server/logger';
import { createRequestLogger } from '@/lib/server/logger';
import { errorResponse } from '@/lib/server/errors/next-response';
export const dynamic = 'force-dynamic';
@ -50,10 +51,12 @@ export async function GET(request: NextRequest) {
headers,
});
} catch (error) {
logger.error({
return errorResponse(error, {
logger,
event: 'tts.segments.audio_fallback_failed',
error: errorToLog(error),
}, 'Failed to load segment audio from fallback route');
return NextResponse.json({ error: 'Failed to load segment audio' }, { status: 500 });
msg: 'Failed to load segment audio from fallback route',
apiErrorMessage: 'Failed to load segment audio',
normalize: { code: 'TTS_SEGMENT_AUDIO_FALLBACK_FAILED', errorClass: 'storage' },
});
}
}

View file

@ -1,7 +1,8 @@
import { NextRequest, NextResponse } from 'next/server';
import { resolveSegmentDocumentScope } from '@/lib/server/tts/segments-auth';
import { clearTtsSegmentCache } from '@/lib/server/tts/segments-cache';
import { createRequestLogger, errorToLog } from '@/lib/server/logger';
import { createRequestLogger } from '@/lib/server/logger';
import { errorResponse } from '@/lib/server/errors/next-response';
export const runtime = 'nodejs';
export const dynamic = 'force-dynamic';
@ -39,10 +40,12 @@ export async function POST(request: NextRequest) {
...cleared,
});
} catch (error) {
logger.error({
return errorResponse(error, {
logger,
event: 'tts.segments.clear_failed',
error: errorToLog(error),
}, 'Failed to clear TTS segment cache');
return NextResponse.json({ error: 'Failed to clear TTS segment cache' }, { status: 500 });
msg: 'Failed to clear TTS segment cache',
apiErrorMessage: 'Failed to clear TTS segment cache',
normalize: { code: 'TTS_SEGMENTS_CLEAR_FAILED', errorClass: 'storage' },
});
}
}

View file

@ -35,6 +35,7 @@ import { getResolvedRuntimeConfig } from '@/lib/server/runtime-config';
import { resolveTtsModelForProvider } from '@/lib/shared/tts-provider-policy';
import { resolveSegmentAudioUrls } from '@/lib/server/tts/segment-audio-urls';
import { createRequestLogger, errorToLog } from '@/lib/server/logger';
import { errorResponse } from '@/lib/server/errors/next-response';
import type {
TTSSegmentInput,
TTSSegmentManifestItem,
@ -828,11 +829,13 @@ export async function POST(request: NextRequest) {
attachDeviceIdCookie(response, deviceIdToSet, didCreateDeviceIdCookie);
return response;
} catch (error) {
logger.error({
const response = errorResponse(error, {
logger,
event: 'tts.segments.ensure.route_failed',
error: errorToLog(error),
}, 'TTS segments ensure route failed');
const response = NextResponse.json({ error: 'Failed to ensure TTS segments' }, { status: 500 });
msg: 'TTS segments ensure route failed',
apiErrorMessage: 'Failed to ensure TTS segments',
normalize: { code: 'TTS_SEGMENTS_ENSURE_ROUTE_FAILED', errorClass: 'upstream' },
});
attachDeviceIdCookie(response, deviceIdToSet, didCreateDeviceIdCookie);
return response;
}

View file

@ -20,7 +20,8 @@ import type {
import { isTtsProviderType } from '@/lib/shared/tts-provider-catalog';
import { resolveEffectiveProviderType } from '@/lib/shared/tts-provider-policy';
import { resolveSegmentAudioUrls } from '@/lib/server/tts/segment-audio-urls';
import { createRequestLogger, errorToLog } from '@/lib/server/logger';
import { createRequestLogger } from '@/lib/server/logger';
import { errorResponse } from '@/lib/server/errors/next-response';
export const runtime = 'nodejs';
export const dynamic = 'force-dynamic';
@ -386,10 +387,12 @@ export async function GET(request: NextRequest) {
};
return NextResponse.json(response);
} catch (error) {
logger.error({
return errorResponse(error, {
logger,
event: 'tts.segments.manifest.list_failed',
error: errorToLog(error),
}, 'Failed to list TTS segments');
return NextResponse.json({ error: 'Failed to list TTS segments' }, { status: 500 });
msg: 'Failed to list TTS segments',
apiErrorMessage: 'Failed to list TTS segments',
normalize: { code: 'TTS_SEGMENTS_MANIFEST_LIST_FAILED', errorClass: 'db' },
});
}
}

View file

@ -6,6 +6,8 @@ import { resolveVoices } from '@/lib/server/tts/voice-resolution';
import { resolveTtsCredentials } from '@/lib/server/admin/resolve-credentials';
import { getResolvedRuntimeConfig } from '@/lib/server/runtime-config';
import { errorToLog, serverLogger } from '@/lib/server/logger';
import { errorResponse } from '@/lib/server/errors/next-response';
import { normalizeServerError, toApiErrorBody, toHttpStatus } from '@/lib/server/errors/contract';
export async function GET(req: NextRequest) {
try {
@ -41,7 +43,10 @@ export async function GET(req: NextRequest) {
}
if (!isBuiltInTtsProviderId(resolved.provider)) {
return NextResponse.json({ error: `Unsupported provider type: ${resolved.provider}` }, { status: 500 });
return errorResponse(new Error(`Unsupported provider type: ${resolved.provider}`), {
apiErrorMessage: `Unsupported provider type: ${resolved.provider}`,
normalize: { code: 'TTS_VOICES_UNSUPPORTED_PROVIDER', errorClass: 'validation', httpStatus: 500 },
});
}
const effectiveProviderRef = resolved.adminRecord?.slug
?? req.headers.get('x-tts-provider')
@ -69,16 +74,24 @@ export async function GET(req: NextRequest) {
const providerRef = req.headers.get('x-tts-provider') || 'openai';
const model = req.headers.get('x-tts-model') || 'tts-1';
const provider = isBuiltInTtsProviderId(providerRef) ? providerRef : 'openai';
const normalized = normalizeServerError(error, {
code: 'TTS_VOICES_RESOLVE_FAILED',
errorClass: 'upstream',
});
const fallbackVoices = resolveTtsProviderModelPolicy({
providerRef,
providerType: provider,
model,
}).defaultVoices;
return NextResponse.json(
{
error: 'Failed to resolve voices',
fallbackVoices: resolveTtsProviderModelPolicy({
providerRef,
providerType: provider,
model,
}).defaultVoices,
...toApiErrorBody(
{ ...normalized, message: 'Failed to resolve voices' },
{ includeDetails: false, includeRetryable: false },
),
fallbackVoices,
},
{ status: 500 },
{ status: toHttpStatus(normalized) },
);
}
}

View file

@ -6,6 +6,7 @@ import { audiobooks, documents, userDocumentProgress, userPreferences } from '@/
import { count, eq, ne } from 'drizzle-orm';
import { getOpenReaderTestNamespace, getUnclaimedUserIdForNamespace } from '@/lib/server/testing/test-namespace';
import { errorToLog, serverLogger } from '@/lib/server/logger';
import { errorResponse } from '@/lib/server/errors/next-response';
async function checkClaimMigrationReadiness(): Promise<NextResponse | null> {
const [legacyRows] = await db
@ -61,7 +62,10 @@ export async function GET(req: NextRequest) {
event: 'user.claim.status.failed',
error: errorToLog(error),
}, 'Failed checking claimable data');
return NextResponse.json({ error: 'Internal Server Error' }, { status: 500 });
return errorResponse(error, {
apiErrorMessage: 'Internal Server Error',
normalize: { code: 'USER_CLAIM_STATUS_FAILED', errorClass: 'db' },
});
}
}
@ -91,6 +95,9 @@ export async function POST(req: NextRequest) {
event: 'user.claim.execute.failed',
error: errorToLog(error),
}, 'Failed claiming anonymous data');
return NextResponse.json({ error: 'Internal Server Error' }, { status: 500 });
return errorResponse(error, {
apiErrorMessage: 'Internal Server Error',
normalize: { code: 'USER_CLAIM_EXECUTE_FAILED', errorClass: 'db' },
});
}
}

View file

@ -12,6 +12,7 @@ import { isS3Configured } from '@/lib/server/storage/s3';
import { getOpenReaderTestNamespace } from '@/lib/server/testing/test-namespace';
import { nowTimestampMs } from '@/lib/shared/timestamps';
import { errorToLog, serverLogger } from '@/lib/server/logger';
import { errorResponse } from '@/lib/server/errors/next-response';
export const dynamic = 'force-dynamic';
@ -24,7 +25,10 @@ export async function GET(req: NextRequest) {
}
if (!auth) {
return NextResponse.json({ error: 'Auth not initialized' }, { status: 500 });
return errorResponse(new Error('Auth not initialized'), {
apiErrorMessage: 'Auth not initialized',
normalize: { code: 'USER_EXPORT_AUTH_NOT_INITIALIZED', errorClass: 'auth', httpStatus: 500 },
});
}
const session = await auth.api.getSession({

View file

@ -6,6 +6,7 @@ import { normalizeVersion, shouldOpenChangelogForVersionChange } from '@/lib/sha
import { nowTimestampMs } from '@/lib/shared/timestamps';
import { resolveUserStateScope } from '@/lib/server/user/resolve-state-scope';
import { errorToLog, serverLogger } from '@/lib/server/logger';
import { errorResponse } from '@/lib/server/errors/next-response';
import {
deserializeUserPreferencesPayload,
extractUserPreferencesMeta,
@ -85,6 +86,9 @@ export async function POST(req: NextRequest) {
event: 'user.changelog.version_check.failed',
error: errorToLog(error),
}, 'Failed to check changelog version');
return NextResponse.json({ error: 'Failed to check changelog version' }, { status: 500 });
return errorResponse(error, {
apiErrorMessage: 'Failed to check changelog version',
normalize: { code: 'USER_CHANGELOG_VERSION_CHECK_FAILED', errorClass: 'db' },
});
}
}

View file

@ -10,6 +10,7 @@ import { listAdminProviders } from '@/lib/server/admin/providers';
import { getResolvedRuntimeConfig } from '@/lib/server/runtime-config';
import { normalizeLegacyProviderRef, resolveProviderDefaults } from '@/lib/shared/tts-provider-policy';
import { errorToLog, serverLogger } from '@/lib/server/logger';
import { errorResponse } from '@/lib/server/errors/next-response';
import {
deserializeUserPreferencesPayload,
extractUserPreferencesMeta,
@ -246,7 +247,10 @@ export async function GET(req: NextRequest) {
event: 'user.preferences.load.failed',
error: errorToLog(error),
}, 'Failed to load user preferences');
return NextResponse.json({ error: 'Failed to load user preferences' }, { status: 500 });
return errorResponse(error, {
apiErrorMessage: 'Failed to load user preferences',
normalize: { code: 'USER_PREFERENCES_LOAD_FAILED', errorClass: 'db' },
});
}
}
@ -323,6 +327,9 @@ export async function PUT(req: NextRequest) {
event: 'user.preferences.update.failed',
error: errorToLog(error),
}, 'Failed to update user preferences');
return NextResponse.json({ error: 'Failed to update user preferences' }, { status: 500 });
return errorResponse(error, {
apiErrorMessage: 'Failed to update user preferences',
normalize: { code: 'USER_PREFERENCES_UPDATE_FAILED', errorClass: 'db' },
});
}
}

View file

@ -7,6 +7,7 @@ import { isValidDocumentId } from '@/lib/server/documents/blobstore';
import { resolveUserStateScope } from '@/lib/server/user/resolve-state-scope';
import { coerceTimestampMs, nowTimestampMs } from '@/lib/shared/timestamps';
import { errorToLog, serverLogger } from '@/lib/server/logger';
import { errorResponse } from '@/lib/server/errors/next-response';
export const dynamic = 'force-dynamic';
@ -67,7 +68,10 @@ export async function GET(req: NextRequest) {
event: 'user.progress.load.failed',
error: errorToLog(error),
}, 'Failed to load user progress');
return NextResponse.json({ error: 'Failed to load user progress' }, { status: 500 });
return errorResponse(error, {
apiErrorMessage: 'Failed to load user progress',
normalize: { code: 'USER_PROGRESS_LOAD_FAILED', errorClass: 'db' },
});
}
}
@ -180,6 +184,9 @@ export async function PUT(req: NextRequest) {
event: 'user.progress.update.failed',
error: errorToLog(error),
}, 'Failed to update user progress');
return NextResponse.json({ error: 'Failed to update user progress' }, { status: 500 });
return errorResponse(error, {
apiErrorMessage: 'Failed to update user progress',
normalize: { code: 'USER_PROGRESS_UPDATE_FAILED', errorClass: 'db' },
});
}
}

View file

@ -1,6 +1,7 @@
import { getWorkerClientConfigFromEnv } from '@/lib/server/compute/worker';
import type { WorkerOperationState } from '@openreader/compute-core/api-contracts';
import { errorToLog, serverLogger } from '@/lib/server/logger';
import { serverLogger } from '@/lib/server/logger';
import { logDegraded } from '@/lib/server/errors/logging';
const WORKER_OP_REQUEST_TIMEOUT_MS = 2_500;
@ -14,13 +15,13 @@ export async function fetchWorkerOperationState<Result>(
try {
cfg = getWorkerClientConfigFromEnv();
} catch (error) {
serverLogger.warn({
logDegraded(serverLogger, {
event: 'compute.worker_op_state.config.invalid',
opId: normalized,
degraded: true,
msg: 'Worker client env missing/invalid',
step: 'read_worker_config',
error: errorToLog(error),
}, 'Worker client env missing/invalid');
context: { opId: normalized },
error,
});
return null;
}
@ -40,39 +41,41 @@ export async function fetchWorkerOperationState<Result>(
if (!res.ok) {
const upstreamResponseBody = await res.text().catch(() => '');
serverLogger.warn({
logDegraded(serverLogger, {
event: 'compute.worker_op_state.fetch.failed',
opId: normalized,
status: res.status,
upstreamResponseBody,
degraded: true,
msg: 'Worker op request failed',
step: 'fetch_worker_op',
context: {
opId: normalized,
status: res.status,
upstreamResponseBody,
},
error: {
name: 'WorkerOpStateFetchFailed',
message: `Worker op request failed with status ${res.status}`,
},
}, 'Worker op request failed');
});
return null;
}
const parsed = await res.json() as WorkerOperationState<Result>;
if (!parsed || typeof parsed !== 'object' || parsed.opId !== normalized) {
serverLogger.warn({
logDegraded(serverLogger, {
event: 'compute.worker_op_state.response.invalid',
opId: normalized,
degraded: true,
msg: 'Worker op response invalid',
step: 'validate_worker_op_response',
}, 'Worker op response invalid');
context: { opId: normalized },
});
return null;
}
return parsed;
} catch (error) {
serverLogger.warn({
logDegraded(serverLogger, {
event: 'compute.worker_op_state.fetch.error',
opId: normalized,
degraded: true,
msg: 'Worker op request threw',
step: 'fetch_worker_op',
error: errorToLog(error),
}, 'Worker op request threw');
context: { opId: normalized },
error,
});
return null;
} finally {
clearTimeout(timeout);

View file

@ -0,0 +1,65 @@
import type { ServerErrorClass } from '@/lib/server/errors/contract';
export type ServerErrorCatalogEntry = {
code: string;
errorClass: ServerErrorClass;
httpStatus: number;
retryable: boolean;
ownerDomain: string;
description: string;
};
export const SERVER_ERROR_CATALOG: ServerErrorCatalogEntry[] = [
{
code: 'UPSTREAM_RATE_LIMIT',
errorClass: 'upstream',
httpStatus: 429,
retryable: true,
ownerDomain: 'tts',
description: 'Upstream provider rate limited the request.',
},
{
code: 'UPSTREAM_TTS_ERROR',
errorClass: 'upstream',
httpStatus: 502,
retryable: true,
ownerDomain: 'tts',
description: 'Upstream TTS provider returned a 5xx/transport failure.',
},
{
code: 'DOCUMENTS_BLOB_MISSING',
errorClass: 'storage',
httpStatus: 404,
retryable: false,
ownerDomain: 'documents',
description: 'Document blob was not found in backing storage.',
},
{
code: 'COMPUTE_WORKER_UNAVAILABLE',
errorClass: 'upstream',
httpStatus: 503,
retryable: true,
ownerDomain: 'compute',
description: 'Compute worker endpoint unavailable or misconfigured.',
},
{
code: 'AUTH_UNAUTHORIZED',
errorClass: 'auth',
httpStatus: 401,
retryable: false,
ownerDomain: 'auth',
description: 'Request requires authentication and no valid session was present.',
},
{
code: 'UNKNOWN_SERVER_ERROR',
errorClass: 'unknown',
httpStatus: 500,
retryable: false,
ownerDomain: 'server',
description: 'Fallback classification for uncategorized server exceptions.',
},
];
export const SERVER_ERROR_CATALOG_BY_CODE = new Map(
SERVER_ERROR_CATALOG.map((entry) => [entry.code, entry] as const),
);

View file

@ -0,0 +1,203 @@
export type ServerErrorClass =
| 'validation'
| 'auth'
| 'permission'
| 'upstream'
| 'storage'
| 'db'
| 'timeout'
| 'unknown';
type ServerErrorDefaults = {
httpStatus: number;
retryable: boolean;
};
const CLASS_DEFAULTS: Record<ServerErrorClass, ServerErrorDefaults> = {
validation: { httpStatus: 400, retryable: false },
auth: { httpStatus: 401, retryable: false },
permission: { httpStatus: 403, retryable: false },
upstream: { httpStatus: 502, retryable: true },
storage: { httpStatus: 503, retryable: true },
db: { httpStatus: 500, retryable: true },
timeout: { httpStatus: 504, retryable: true },
unknown: { httpStatus: 500, retryable: false },
};
const ERROR_CODE_PATTERN = /^[A-Z][A-Z0-9_]{2,}$/;
export type ServerErrorContext = {
code?: string;
message?: string;
errorClass?: ServerErrorClass;
httpStatus?: number;
retryable?: boolean;
details?: Record<string, unknown>;
};
export class ServerAppError extends Error {
readonly code: string;
readonly errorClass: ServerErrorClass;
readonly httpStatus: number;
readonly retryable: boolean;
readonly details?: Record<string, unknown>;
constructor(input: {
code: string;
message: string;
errorClass: ServerErrorClass;
httpStatus?: number;
retryable?: boolean;
details?: Record<string, unknown>;
cause?: unknown;
}) {
super(input.message, input.cause !== undefined ? { cause: input.cause } : undefined);
this.name = 'ServerAppError';
this.code = input.code;
this.errorClass = input.errorClass;
const defaults = CLASS_DEFAULTS[input.errorClass];
this.httpStatus = input.httpStatus ?? defaults.httpStatus;
this.retryable = input.retryable ?? defaults.retryable;
if (input.details) this.details = input.details;
}
}
export function isServerAppError(value: unknown): value is ServerAppError {
return value instanceof ServerAppError;
}
function normalizeFromErrorInstance(error: Error): {
message: string;
code?: string;
errorClass?: ServerErrorClass;
httpStatus?: number;
retryable?: boolean;
details?: Record<string, unknown>;
} {
const rec = error as Error & {
code?: unknown;
errorClass?: unknown;
httpStatus?: unknown;
retryable?: unknown;
details?: unknown;
};
const code = typeof rec.code === 'string' && rec.code.trim() ? rec.code.trim() : undefined;
const errorClass = typeof rec.errorClass === 'string' && rec.errorClass in CLASS_DEFAULTS
? rec.errorClass as ServerErrorClass
: undefined;
const httpStatus = typeof rec.httpStatus === 'number' && Number.isFinite(rec.httpStatus)
? Math.max(400, Math.min(599, Math.floor(rec.httpStatus)))
: undefined;
const retryable = typeof rec.retryable === 'boolean' ? rec.retryable : undefined;
const details = rec.details && typeof rec.details === 'object'
? rec.details as Record<string, unknown>
: undefined;
return {
message: error.message || 'Internal server error',
...(code ? { code } : {}),
...(errorClass ? { errorClass } : {}),
...(httpStatus ? { httpStatus } : {}),
...(typeof retryable === 'boolean' ? { retryable } : {}),
...(details ? { details } : {}),
};
}
export type NormalizedServerError = {
code: string;
message: string;
errorClass: ServerErrorClass;
httpStatus: number;
retryable: boolean;
details?: Record<string, unknown>;
cause?: unknown;
};
export function normalizeServerError(
error: unknown,
ctx: ServerErrorContext = {},
): NormalizedServerError {
if (isServerAppError(error)) {
return {
code: error.code,
message: error.message,
errorClass: error.errorClass,
httpStatus: error.httpStatus,
retryable: error.retryable,
...(error.details ? { details: error.details } : {}),
...(error.cause !== undefined ? { cause: error.cause } : {}),
};
}
const normalizedFromThrowable = error instanceof Error ? normalizeFromErrorInstance(error) : {
message: String(error),
};
const errorClass = ctx.errorClass
?? normalizedFromThrowable.errorClass
?? 'unknown';
const defaults = CLASS_DEFAULTS[errorClass];
const rawCode = ctx.code
?? normalizedFromThrowable.code
?? 'UNKNOWN_SERVER_ERROR';
const code = ERROR_CODE_PATTERN.test(rawCode) ? rawCode : 'UNKNOWN_SERVER_ERROR';
return {
code,
message: ctx.message
?? normalizedFromThrowable.message
?? 'Internal server error',
errorClass,
httpStatus: ctx.httpStatus
?? normalizedFromThrowable.httpStatus
?? defaults.httpStatus,
retryable: ctx.retryable
?? normalizedFromThrowable.retryable
?? defaults.retryable,
...(ctx.details ? { details: ctx.details } : normalizedFromThrowable.details ? { details: normalizedFromThrowable.details } : {}),
...(error instanceof Error && error.cause !== undefined ? { cause: error.cause } : {}),
};
}
export function toApiErrorBody(
input: NormalizedServerError,
options: {
includeErrorCode?: boolean;
includeRetryable?: boolean;
includeDetails?: boolean;
} = {},
): {
error: string;
errorCode?: string;
retryable?: boolean;
details?: Record<string, unknown>;
} {
const includeErrorCode = options.includeErrorCode ?? true;
const includeRetryable = options.includeRetryable ?? true;
const includeDetails = options.includeDetails ?? true;
return {
error: input.message,
...(includeErrorCode ? { errorCode: input.code } : {}),
...(includeRetryable ? { retryable: input.retryable } : {}),
...(includeDetails && input.details ? { details: input.details } : {}),
};
}
export function toHttpStatus(input: NormalizedServerError): number {
return input.httpStatus;
}
export function createServerAppError(input: {
code: string;
message: string;
errorClass: ServerErrorClass;
httpStatus?: number;
retryable?: boolean;
details?: Record<string, unknown>;
cause?: unknown;
}): ServerAppError {
if (!ERROR_CODE_PATTERN.test(input.code)) {
throw new Error(`Invalid ServerAppError code "${input.code}" (expected ${ERROR_CODE_PATTERN.toString()})`);
}
return new ServerAppError(input);
}

View file

@ -0,0 +1,48 @@
import type { ServerLogger } from '@/lib/server/logger';
import { errorToLog } from '@/lib/server/logger';
import { normalizeServerError, type NormalizedServerError, type ServerErrorContext } from '@/lib/server/errors/contract';
type LogContext = Record<string, unknown>;
export function logServerError(
logger: ServerLogger,
input: {
event: string;
error: unknown;
msg: string;
context?: LogContext;
normalize?: ServerErrorContext;
},
): NormalizedServerError {
const normalized = normalizeServerError(input.error, input.normalize);
logger.error({
event: input.event,
...(input.context || {}),
error: {
...errorToLog(input.error),
code: normalized.code,
},
}, `${input.msg}`);
return normalized;
}
export function logDegraded(
logger: ServerLogger,
input: {
event: string;
msg: string;
step?: string;
fallbackPath?: string;
context?: LogContext;
error?: unknown;
},
): void {
logger.warn({
event: input.event,
degraded: true,
...(input.step ? { step: input.step } : {}),
...(input.fallbackPath ? { fallbackPath: input.fallbackPath } : {}),
...(input.context || {}),
...(input.error !== undefined ? { error: errorToLog(input.error) } : {}),
}, `${input.msg}`);
}

View file

@ -0,0 +1,90 @@
import type { NextRequest } from 'next/server';
import { NextResponse } from 'next/server';
import type { ServerLogger } from '@/lib/server/logger';
import { normalizeServerError, toApiErrorBody, toHttpStatus, type NormalizedServerError, type ServerErrorContext } from '@/lib/server/errors/contract';
import { logServerError } from '@/lib/server/errors/logging';
export type ErrorResponseOptions = {
logger?: ServerLogger;
event?: string;
msg?: string;
normalize?: ServerErrorContext;
apiErrorMessage?: string;
includeErrorCode?: boolean;
includeRetryable?: boolean;
includeDetails?: boolean;
};
export function errorResponse(
error: unknown,
options: ErrorResponseOptions = {},
): NextResponse {
const normalized = normalizeServerError(error, options.normalize);
if (options.logger && options.event && options.msg) {
logServerError(options.logger, {
event: options.event,
error,
msg: options.msg,
context: {},
normalize: options.normalize,
});
}
const apiError = options.apiErrorMessage ?? normalized.message;
const body = toApiErrorBody(
{ ...normalized, message: apiError },
{
includeErrorCode: options.includeErrorCode ?? true,
includeRetryable: options.includeRetryable ?? true,
includeDetails: options.includeDetails ?? false,
},
);
return NextResponse.json(body, { status: toHttpStatus(normalized) });
}
export type ErrorBoundaryContext = {
route: string;
logger: ServerLogger;
event: string;
msg: string;
normalize?: ServerErrorContext;
apiErrorMessage?: string;
};
export function withErrorBoundary<Args extends unknown[]>(
handler: (...args: Args) => Promise<Response>,
ctx: ErrorBoundaryContext,
): (...args: Args) => Promise<Response> {
return async (...args: Args): Promise<Response> => {
try {
return await handler(...args);
} catch (error) {
return errorResponse(error, {
logger: ctx.logger,
event: ctx.event,
msg: ctx.msg,
normalize: ctx.normalize,
apiErrorMessage: ctx.apiErrorMessage,
});
}
};
}
export function asRouteErrorContext(input: {
request: NextRequest;
route: string;
method?: string;
requestId?: string;
}): Record<string, unknown> {
return {
route: input.route,
method: input.method ?? input.request.method,
...(input.requestId ? { requestId: input.requestId } : {}),
};
}
export function normalizeForResponseOnly(
error: unknown,
normalize?: ServerErrorContext,
): NormalizedServerError {
return normalizeServerError(error, normalize);
}

View file

@ -6,31 +6,6 @@ import pinoPretty from 'pino-pretty';
export type ServerLogger = Logger;
export type ServerLogLevel = 'error' | 'warn' | 'info';
export type ServerErrorClass =
| 'validation'
| 'auth'
| 'permission'
| 'upstream'
| 'storage'
| 'db'
| 'timeout'
| 'unknown';
export type ServerErrorContract = {
errorClass: ServerErrorClass;
retryable: boolean;
httpStatus: number;
operation: string;
degraded: boolean;
cause?: unknown;
};
export type ServerApiErrorBody = {
error: string;
retryable?: boolean;
details?: Record<string, unknown>;
};
export type LoggedError = {
name: string;
message: string;
@ -141,16 +116,6 @@ export function errorToLog(error: unknown, depth = 0): LoggedError {
};
}
export function apiErrorBody(
input: ServerApiErrorBody,
): ServerApiErrorBody {
return {
error: input.error,
...(typeof input.retryable === 'boolean' ? { retryable: input.retryable } : {}),
...(input.details ? { details: input.details } : {}),
};
}
export function hashForLog(value: string | null | undefined): string | null {
const normalized = typeof value === 'string' ? value.trim() : '';
if (!normalized) return null;

View file

@ -0,0 +1,55 @@
import { expect, test } from '@playwright/test';
import {
ServerAppError,
createServerAppError,
isServerAppError,
normalizeServerError,
toApiErrorBody,
toHttpStatus,
} from '../../src/lib/server/errors/contract';
test.describe('server error contract', () => {
test('normalizes unknown throwable to fallback shape', () => {
const normalized = normalizeServerError('boom');
expect(normalized.code).toBe('UNKNOWN_SERVER_ERROR');
expect(normalized.errorClass).toBe('unknown');
expect(normalized.httpStatus).toBe(500);
expect(normalized.retryable).toBe(false);
expect(normalized.message).toBe('boom');
});
test('preserves ServerAppError metadata', () => {
const err = createServerAppError({
code: 'USER_PROGRESS_UPDATE_FAILED',
message: 'Failed to update progress',
errorClass: 'db',
retryable: true,
httpStatus: 500,
details: { operation: 'update_progress' },
});
const normalized = normalizeServerError(err);
expect(isServerAppError(err)).toBe(true);
expect(normalized.code).toBe('USER_PROGRESS_UPDATE_FAILED');
expect(normalized.errorClass).toBe('db');
expect(normalized.httpStatus).toBe(500);
expect(normalized.retryable).toBe(true);
expect(normalized.details?.operation).toBe('update_progress');
});
test('maps normalized errors to API body + status', () => {
const normalized = normalizeServerError(
new ServerAppError({
code: 'UPSTREAM_TTS_ERROR',
message: 'Upstream failure',
errorClass: 'upstream',
}),
);
const body = toApiErrorBody(normalized, { includeDetails: false });
expect(body).toEqual({
error: 'Upstream failure',
errorCode: 'UPSTREAM_TTS_ERROR',
retryable: true,
});
expect(toHttpStatus(normalized)).toBe(502);
});
});

View file

@ -0,0 +1,43 @@
import { expect, test } from '@playwright/test';
import { errorResponse } from '../../src/lib/server/errors/next-response';
import { createServerAppError } from '../../src/lib/server/errors/contract';
test.describe('server error response helper', () => {
test('returns mapped 4xx response with explicit app code', async () => {
const response = errorResponse(
createServerAppError({
code: 'AUTH_UNAUTHORIZED',
message: 'Unauthorized',
errorClass: 'auth',
httpStatus: 401,
retryable: false,
}),
{
apiErrorMessage: 'Unauthorized',
},
);
expect(response.status).toBe(401);
await expect(response.json()).resolves.toEqual({
error: 'Unauthorized',
errorCode: 'AUTH_UNAUTHORIZED',
retryable: false,
});
});
test('normalizes unknown errors to safe 500 without stack leakage', async () => {
const response = errorResponse(new Error('sensitive internal details'), {
apiErrorMessage: 'Internal Server Error',
normalize: { code: 'UNKNOWN_SERVER_ERROR', errorClass: 'unknown' },
});
const body = await response.json();
expect(response.status).toBe(500);
expect(body).toEqual({
error: 'Internal Server Error',
errorCode: 'UNKNOWN_SERVER_ERROR',
retryable: false,
});
expect(JSON.stringify(body)).not.toContain('stack');
expect(JSON.stringify(body)).not.toContain('cause');
});
});