refactor: roll out phase 7 server error contract
This commit is contained in:
parent
3da13c2970
commit
00ce39ed74
41 changed files with 869 additions and 161 deletions
|
|
@ -17,6 +17,8 @@ const DYNAMIC_LOGGER_CALL_SELECTOR =
|
||||||
const LOGGER_RECEIVER_SELECTOR =
|
const LOGGER_RECEIVER_SELECTOR =
|
||||||
":matches([callee.object.name=/^(logger|serverLogger)$/],[callee.object.property.name='logger'])";
|
":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 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 = [
|
const eslintConfig = [
|
||||||
...compat.extends("next/core-web-vitals", "next/typescript"),
|
...compat.extends("next/core-web-vitals", "next/typescript"),
|
||||||
|
|
@ -87,6 +89,11 @@ const eslintConfig = [
|
||||||
message:
|
message:
|
||||||
"Do not use top-level `detail` in server logger context; keep throwable text under `error.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']`,
|
selector: `${SERVER_LOGGER_CALL_SELECTOR} > ObjectExpression:first-child > Property[key.name='err']`,
|
||||||
message:
|
message:
|
||||||
|
|
@ -102,6 +109,11 @@ const eslintConfig = [
|
||||||
message:
|
message:
|
||||||
"Server logger `error` must be a structured object (prefer `errorToLog(...)`), not template text.",
|
"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 }).",
|
||||||
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -25,7 +25,8 @@
|
||||||
"compute:worker:dev": "pnpm --filter @openreader/compute-worker dev",
|
"compute:worker:dev": "pnpm --filter @openreader/compute-worker dev",
|
||||||
"compute:worker:start": "pnpm --filter @openreader/compute-worker start",
|
"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: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": {
|
"dependencies": {
|
||||||
"@aws-sdk/client-s3": "^3.1045.0",
|
"@aws-sdk/client-s3": "^3.1045.0",
|
||||||
|
|
|
||||||
54
scripts/check-route-error-responses.mjs
Normal file
54
scripts/check-route-error-responses.mjs
Normal 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.');
|
||||||
|
|
@ -5,6 +5,7 @@ import { isAuthEnabled } from '@/lib/server/auth/config';
|
||||||
import { getOpenReaderTestNamespace } from '@/lib/server/testing/test-namespace';
|
import { getOpenReaderTestNamespace } from '@/lib/server/testing/test-namespace';
|
||||||
import { deleteUserStorageData } from '@/lib/server/user/data-cleanup';
|
import { deleteUserStorageData } from '@/lib/server/user/data-cleanup';
|
||||||
import { errorToLog, hashForLog, serverLogger } from '@/lib/server/logger';
|
import { errorToLog, hashForLog, serverLogger } from '@/lib/server/logger';
|
||||||
|
import { errorResponse } from '@/lib/server/errors/next-response';
|
||||||
|
|
||||||
export async function DELETE() {
|
export async function DELETE() {
|
||||||
if (!isAuthEnabled() || !auth) {
|
if (!isAuthEnabled() || !auth) {
|
||||||
|
|
@ -51,9 +52,9 @@ export async function DELETE() {
|
||||||
event: 'account.delete.failed',
|
event: 'account.delete.failed',
|
||||||
error: errorToLog(error),
|
error: errorToLog(error),
|
||||||
}, 'Failed to delete account');
|
}, 'Failed to delete account');
|
||||||
return NextResponse.json(
|
return errorResponse(error, {
|
||||||
{ error: 'Failed to delete account' },
|
apiErrorMessage: 'Failed to delete account',
|
||||||
{ status: 500 }
|
normalize: { code: 'ACCOUNT_DELETE_FAILED', errorClass: 'db' },
|
||||||
);
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
import { NextRequest, NextResponse } from 'next/server';
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
import { requireAdminContext } from '@/lib/server/auth/admin';
|
import { requireAdminContext } from '@/lib/server/auth/admin';
|
||||||
import { errorToLog, serverLogger } from '@/lib/server/logger';
|
import { errorToLog, serverLogger } from '@/lib/server/logger';
|
||||||
|
import { errorResponse } from '@/lib/server/errors/next-response';
|
||||||
import {
|
import {
|
||||||
AdminProviderError,
|
AdminProviderError,
|
||||||
deleteAdminProvider,
|
deleteAdminProvider,
|
||||||
|
|
@ -47,7 +48,10 @@ export async function PUT(
|
||||||
providerId: id,
|
providerId: id,
|
||||||
error: errorToLog(error),
|
error: errorToLog(error),
|
||||||
}, 'Admin provider update failed');
|
}, '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,
|
providerId: id,
|
||||||
error: errorToLog(error),
|
error: errorToLog(error),
|
||||||
}, 'Admin provider delete failed');
|
}, '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' },
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
import { NextRequest, NextResponse } from 'next/server';
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
import { requireAdminContext } from '@/lib/server/auth/admin';
|
import { requireAdminContext } from '@/lib/server/auth/admin';
|
||||||
import { errorToLog, serverLogger } from '@/lib/server/logger';
|
import { errorToLog, serverLogger } from '@/lib/server/logger';
|
||||||
|
import { errorResponse } from '@/lib/server/errors/next-response';
|
||||||
import {
|
import {
|
||||||
AdminProviderError,
|
AdminProviderError,
|
||||||
createAdminProvider,
|
createAdminProvider,
|
||||||
|
|
@ -51,6 +52,9 @@ export async function POST(req: NextRequest) {
|
||||||
event: 'admin.providers.create.failed',
|
event: 'admin.providers.create.failed',
|
||||||
error: errorToLog(error),
|
error: errorToLog(error),
|
||||||
}, 'Admin provider create failed');
|
}, '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' },
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -44,6 +44,7 @@ import {
|
||||||
coerceAudiobookGenerationSettings,
|
coerceAudiobookGenerationSettings,
|
||||||
type SharedProviderPolicyEntry,
|
type SharedProviderPolicyEntry,
|
||||||
} from '@/lib/server/audiobooks/settings';
|
} from '@/lib/server/audiobooks/settings';
|
||||||
|
import { errorResponse } from '@/lib/server/errors/next-response';
|
||||||
|
|
||||||
export const dynamic = 'force-dynamic';
|
export const dynamic = 'force-dynamic';
|
||||||
|
|
||||||
|
|
@ -340,7 +341,10 @@ export async function POST(request: NextRequest) {
|
||||||
message: 'Invalid audiobook.meta.json settings payload',
|
message: 'Invalid audiobook.meta.json settings payload',
|
||||||
},
|
},
|
||||||
}, '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);
|
normalizedExistingSettings = normalizeNativeSpeedForSettings(existingResult.settings);
|
||||||
existingSettingsNeedsMigration = existingResult.migrated;
|
existingSettingsNeedsMigration = existingResult.migrated;
|
||||||
|
|
@ -419,7 +423,7 @@ export async function POST(request: NextRequest) {
|
||||||
step: 'persist_migrated_settings',
|
step: 'persist_migrated_settings',
|
||||||
bookId,
|
bookId,
|
||||||
storageUserId,
|
storageUserId,
|
||||||
error: error instanceof Error ? error.message : String(error),
|
error: errorToLog(error),
|
||||||
}, 'Failed to persist migrated audiobook metadata settings');
|
}, 'Failed to persist migrated audiobook metadata settings');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -503,7 +507,10 @@ export async function POST(request: NextRequest) {
|
||||||
}
|
}
|
||||||
const provider = credResolved.provider;
|
const provider = credResolved.provider;
|
||||||
if (!isBuiltInTtsProviderId(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 openApiKey = credResolved.apiKey || 'none';
|
||||||
const openApiBaseUrl = credResolved.baseUrl;
|
const openApiBaseUrl = credResolved.baseUrl;
|
||||||
|
|
@ -754,7 +761,10 @@ export async function POST(request: NextRequest) {
|
||||||
event: 'audiobook.chapter.process.failed',
|
event: 'audiobook.chapter.process.failed',
|
||||||
error: errorToLog(error),
|
error: errorToLog(error),
|
||||||
}, 'Failed to process audio chapter');
|
}, '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);
|
attachDeviceIdCookie(response, deviceIdToSet, didCreateDeviceIdCookie);
|
||||||
return response;
|
return response;
|
||||||
} finally {
|
} finally {
|
||||||
|
|
@ -852,7 +862,10 @@ export async function GET(request: NextRequest) {
|
||||||
event: 'audiobook.chapter.download.failed',
|
event: 'audiobook.chapter.download.failed',
|
||||||
error: errorToLog(error),
|
error: errorToLog(error),
|
||||||
}, 'Failed to download chapter');
|
}, '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',
|
event: 'audiobook.chapter.delete.failed',
|
||||||
error: errorToLog(error),
|
error: errorToLog(error),
|
||||||
}, 'Failed to delete chapter');
|
}, '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' },
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,7 @@ import { db } from '@/db';
|
||||||
import { audiobooks, audiobookChapters } from '@/db/schema';
|
import { audiobooks, audiobookChapters } from '@/db/schema';
|
||||||
import { requireAuthContext } from '@/lib/server/auth/auth';
|
import { requireAuthContext } from '@/lib/server/auth/auth';
|
||||||
import { errorToLog, serverLogger } from '@/lib/server/logger';
|
import { errorToLog, serverLogger } from '@/lib/server/logger';
|
||||||
|
import { errorResponse } from '@/lib/server/errors/next-response';
|
||||||
import {
|
import {
|
||||||
audiobookPrefix,
|
audiobookPrefix,
|
||||||
deleteAudiobookObject,
|
deleteAudiobookObject,
|
||||||
|
|
@ -380,7 +381,10 @@ export async function GET(request: NextRequest) {
|
||||||
event: 'audiobook.create.failed',
|
event: 'audiobook.create.failed',
|
||||||
error: errorToLog(error),
|
error: errorToLog(error),
|
||||||
}, 'Failed to create full audiobook');
|
}, '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 {
|
} finally {
|
||||||
if (workDir) await rm(workDir, { recursive: true, force: true }).catch(() => {});
|
if (workDir) await rm(workDir, { recursive: true, force: true }).catch(() => {});
|
||||||
}
|
}
|
||||||
|
|
@ -431,6 +435,9 @@ export async function DELETE(request: NextRequest) {
|
||||||
event: 'audiobook.reset.failed',
|
event: 'audiobook.reset.failed',
|
||||||
error: errorToLog(error),
|
error: errorToLog(error),
|
||||||
}, 'Failed to reset audiobook');
|
}, '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' },
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -12,6 +12,7 @@ import { buildAllowedAudiobookUserIds, pickAudiobookOwner } from '@/lib/server/a
|
||||||
import type { AudiobookGenerationSettings } from '@/types/client';
|
import type { AudiobookGenerationSettings } from '@/types/client';
|
||||||
import type { TTSAudiobookChapter, TTSAudiobookFormat } from '@/types/tts';
|
import type { TTSAudiobookChapter, TTSAudiobookFormat } from '@/types/tts';
|
||||||
import { errorToLog, serverLogger } from '@/lib/server/logger';
|
import { errorToLog, serverLogger } from '@/lib/server/logger';
|
||||||
|
import { errorResponse } from '@/lib/server/errors/next-response';
|
||||||
|
|
||||||
export const dynamic = 'force-dynamic';
|
export const dynamic = 'force-dynamic';
|
||||||
|
|
||||||
|
|
@ -160,6 +161,9 @@ export async function GET(request: NextRequest) {
|
||||||
event: 'audiobook.status.fetch.failed',
|
event: 'audiobook.status.fetch.failed',
|
||||||
error: errorToLog(error),
|
error: errorToLog(error),
|
||||||
}, 'Failed to fetch audiobook chapters');
|
}, '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' },
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -11,7 +11,9 @@ import { normalizeParseStatus, parseDocumentParseState } from '@/lib/server/docu
|
||||||
import { healStaleDocumentParseState } from '@/lib/server/documents/parse-state-healing';
|
import { healStaleDocumentParseState } from '@/lib/server/documents/parse-state-healing';
|
||||||
import { getOpenReaderTestNamespace, getUnclaimedUserIdForNamespace } from '@/lib/server/testing/test-namespace';
|
import { getOpenReaderTestNamespace, getUnclaimedUserIdForNamespace } from '@/lib/server/testing/test-namespace';
|
||||||
import { isS3Configured } from '@/lib/server/storage/s3';
|
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 type { PdfParseProgress, PdfParseStatus } from '@/types/parsed-pdf';
|
||||||
import { parseSseEventId, parseSsePayload } from '@openreader/compute-core';
|
import { parseSseEventId, parseSsePayload } from '@openreader/compute-core';
|
||||||
import type { PdfLayoutJobResult, WorkerOperationEvent, WorkerOperationState } from '@openreader/compute-core/api-contracts';
|
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) => {
|
}).catch((error) => {
|
||||||
if (closed) return;
|
if (closed) return;
|
||||||
logger.warn({
|
logDegraded(logger, {
|
||||||
event: 'documents.parsed.events.db_resync_failed',
|
event: 'documents.parsed.events.db_resync_failed',
|
||||||
degraded: true,
|
msg: 'SSE DB resync failed',
|
||||||
step: 'db_resync',
|
step: 'db_resync',
|
||||||
documentId: id,
|
context: {
|
||||||
storageUserIdHash,
|
documentId: id,
|
||||||
requestId,
|
storageUserIdHash,
|
||||||
error: errorToLog(error),
|
requestId,
|
||||||
}, 'SSE DB resync failed');
|
},
|
||||||
|
error,
|
||||||
|
});
|
||||||
controller.enqueue(encoder.encode(`event: error\ndata: ${JSON.stringify({ error: String(error) })}\n\n`));
|
controller.enqueue(encoder.encode(`event: error\ndata: ${JSON.stringify({ error: String(error) })}\n\n`));
|
||||||
});
|
});
|
||||||
}, SSE_RESYNC_INTERVAL_MS);
|
}, SSE_RESYNC_INTERVAL_MS);
|
||||||
|
|
@ -468,11 +472,16 @@ export async function GET(req: NextRequest, ctx: { params: Promise<{ id: string
|
||||||
|
|
||||||
void runWorkerProxy()
|
void runWorkerProxy()
|
||||||
.catch((error) => {
|
.catch((error) => {
|
||||||
logger.error({
|
logServerError(logger, {
|
||||||
event: 'documents.parsed.events.worker_proxy_crashed',
|
event: 'documents.parsed.events.worker_proxy_crashed',
|
||||||
documentId: id,
|
error,
|
||||||
error: errorToLog(error),
|
msg: 'Worker proxy crashed while streaming parse events',
|
||||||
}, 'Worker proxy crashed while streaming parse events');
|
context: { documentId: id },
|
||||||
|
normalize: {
|
||||||
|
code: 'DOCUMENTS_PARSED_EVENTS_WORKER_PROXY_CRASHED',
|
||||||
|
errorClass: 'upstream',
|
||||||
|
},
|
||||||
|
});
|
||||||
if (!closed) {
|
if (!closed) {
|
||||||
controller.enqueue(encoder.encode(`event: error\ndata: ${JSON.stringify({ error: String(error) })}\n\n`));
|
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) {
|
} catch (error) {
|
||||||
logger.error({
|
return errorResponse(error, {
|
||||||
|
logger,
|
||||||
event: 'documents.parsed.events.route_failed',
|
event: 'documents.parsed.events.route_failed',
|
||||||
error: errorToLog(error),
|
msg: 'Parsed events route failed',
|
||||||
}, 'Parsed events route failed');
|
apiErrorMessage: 'Failed to stream parsed PDF progress',
|
||||||
return NextResponse.json({ error: 'Failed to stream parsed PDF progress' }, { status: 500 });
|
normalize: { code: 'DOCUMENTS_PARSED_EVENTS_ROUTE_FAILED', errorClass: 'upstream' },
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -23,7 +23,9 @@ import {
|
||||||
import { healStaleDocumentParseState } from '@/lib/server/documents/parse-state-healing';
|
import { healStaleDocumentParseState } from '@/lib/server/documents/parse-state-healing';
|
||||||
import { getOpenReaderTestNamespace, getUnclaimedUserIdForNamespace } from '@/lib/server/testing/test-namespace';
|
import { getOpenReaderTestNamespace, getUnclaimedUserIdForNamespace } from '@/lib/server/testing/test-namespace';
|
||||||
import { isS3Configured } from '@/lib/server/storage/s3';
|
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 { ParsedPdfDocument } from '@/types/parsed-pdf';
|
||||||
import type { PdfLayoutJobResult, WorkerOperationState } from '@openreader/compute-core/api-contracts';
|
import type { PdfLayoutJobResult, WorkerOperationState } from '@openreader/compute-core/api-contracts';
|
||||||
|
|
||||||
|
|
@ -138,7 +140,10 @@ async function finalizeFromWorkerState(input: {
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!parsedJsonKey) {
|
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({
|
await writeParseRowState({
|
||||||
|
|
@ -218,18 +223,20 @@ export async function GET(req: NextRequest, ctx: { params: Promise<{ id: string
|
||||||
logger,
|
logger,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
logger.warn({
|
logDegraded(logger, {
|
||||||
event: 'documents.parsed.requested_op_unavailable',
|
event: 'documents.parsed.requested_op_unavailable',
|
||||||
degraded: true,
|
msg: 'Requested worker operation id was unavailable',
|
||||||
step: 'requested_op_lookup',
|
step: 'requested_op_lookup',
|
||||||
documentId: id,
|
context: {
|
||||||
userIdHash: hashForLog(row.userId),
|
documentId: id,
|
||||||
opId: requestedOpId,
|
userIdHash: hashForLog(row.userId),
|
||||||
error: {
|
opId: requestedOpId,
|
||||||
name: 'RequestedOperationUnavailable',
|
error: {
|
||||||
message: `Requested worker operation ${requestedOpId} was unavailable`,
|
name: 'RequestedOperationUnavailable',
|
||||||
|
message: `Requested worker operation ${requestedOpId} was unavailable`,
|
||||||
|
},
|
||||||
},
|
},
|
||||||
}, 'Requested worker operation id was unavailable');
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
let state = parseDocumentParseState(row.parseState);
|
let state = parseDocumentParseState(row.parseState);
|
||||||
|
|
@ -311,11 +318,13 @@ export async function GET(req: NextRequest, ctx: { params: Promise<{ id: string
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error({
|
return errorResponse(error, {
|
||||||
|
logger,
|
||||||
event: 'documents.parsed.get_failed',
|
event: 'documents.parsed.get_failed',
|
||||||
error: errorToLog(error),
|
msg: 'Failed to read parsed PDF',
|
||||||
}, 'Failed to read parsed PDF');
|
apiErrorMessage: 'Failed to read parsed PDF',
|
||||||
return NextResponse.json({ error: 'Failed to read parsed PDF' }, { status: 500 });
|
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,
|
opId: created.opId,
|
||||||
}, { status: 202 });
|
}, { status: 202 });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error({
|
return errorResponse(error, {
|
||||||
|
logger,
|
||||||
event: 'documents.parsed.force_refresh_failed',
|
event: 'documents.parsed.force_refresh_failed',
|
||||||
error: errorToLog(error),
|
msg: 'Failed to force PDF refresh',
|
||||||
}, 'Failed to force PDF refresh');
|
apiErrorMessage: 'Failed to force PDF refresh',
|
||||||
return NextResponse.json({ error: 'Failed to force PDF refresh' }, { status: 500 });
|
normalize: { code: 'DOCUMENTS_PARSED_FORCE_REFRESH_FAILED', errorClass: 'upstream' },
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,7 @@ import { mergeDocumentSettings } from '@/lib/shared/document-settings';
|
||||||
import { DEFAULT_DOCUMENT_SETTINGS, type DocumentSettings } from '@/types/document-settings';
|
import { DEFAULT_DOCUMENT_SETTINGS, type DocumentSettings } from '@/types/document-settings';
|
||||||
import { coerceTimestampMs, nowTimestampMs } from '@/lib/shared/timestamps';
|
import { coerceTimestampMs, nowTimestampMs } from '@/lib/shared/timestamps';
|
||||||
import { errorToLog, serverLogger } from '@/lib/server/logger';
|
import { errorToLog, serverLogger } from '@/lib/server/logger';
|
||||||
|
import { errorResponse } from '@/lib/server/errors/next-response';
|
||||||
|
|
||||||
export const dynamic = 'force-dynamic';
|
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',
|
event: 'documents.settings.load.failed',
|
||||||
error: errorToLog(error),
|
error: errorToLog(error),
|
||||||
}, 'Failed to load document settings');
|
}, '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',
|
event: 'documents.settings.update.failed',
|
||||||
error: errorToLog(error),
|
error: errorToLog(error),
|
||||||
}, 'Failed to update document settings');
|
}, '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' },
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,7 @@ import { getDocumentBlob, isMissingBlobError, isValidDocumentId } from '@/lib/se
|
||||||
import { getOpenReaderTestNamespace, getUnclaimedUserIdForNamespace } from '@/lib/server/testing/test-namespace';
|
import { getOpenReaderTestNamespace, getUnclaimedUserIdForNamespace } from '@/lib/server/testing/test-namespace';
|
||||||
import { isS3Configured } from '@/lib/server/storage/s3';
|
import { isS3Configured } from '@/lib/server/storage/s3';
|
||||||
import { errorToLog, serverLogger } from '@/lib/server/logger';
|
import { errorToLog, serverLogger } from '@/lib/server/logger';
|
||||||
|
import { errorResponse } from '@/lib/server/errors/next-response';
|
||||||
|
|
||||||
export const dynamic = 'force-dynamic';
|
export const dynamic = 'force-dynamic';
|
||||||
|
|
||||||
|
|
@ -91,6 +92,9 @@ export async function GET(req: NextRequest) {
|
||||||
event: 'documents.blob.get.fallback.failed',
|
event: 'documents.blob.get.fallback.failed',
|
||||||
error: errorToLog(error),
|
error: errorToLog(error),
|
||||||
}, 'Failed to load document content fallback');
|
}, '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' },
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,7 @@ import { isValidDocumentId, presignGet } from '@/lib/server/documents/blobstore'
|
||||||
import { getOpenReaderTestNamespace, getUnclaimedUserIdForNamespace } from '@/lib/server/testing/test-namespace';
|
import { getOpenReaderTestNamespace, getUnclaimedUserIdForNamespace } from '@/lib/server/testing/test-namespace';
|
||||||
import { isS3Configured } from '@/lib/server/storage/s3';
|
import { isS3Configured } from '@/lib/server/storage/s3';
|
||||||
import { errorToLog, serverLogger } from '@/lib/server/logger';
|
import { errorToLog, serverLogger } from '@/lib/server/logger';
|
||||||
|
import { errorResponse } from '@/lib/server/errors/next-response';
|
||||||
|
|
||||||
export const dynamic = 'force-dynamic';
|
export const dynamic = 'force-dynamic';
|
||||||
|
|
||||||
|
|
@ -72,6 +73,9 @@ export async function GET(req: NextRequest) {
|
||||||
event: 'documents.blob.get.presign.failed',
|
event: 'documents.blob.get.presign.failed',
|
||||||
error: errorToLog(error),
|
error: errorToLog(error),
|
||||||
}, 'Failed to create document download signature');
|
}, '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' },
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,7 @@ import { presignDocumentPreviewGet } from '@/lib/server/documents/previews-blobs
|
||||||
import { ensureDocumentPreview } from '@/lib/server/documents/previews';
|
import { ensureDocumentPreview } from '@/lib/server/documents/previews';
|
||||||
import { validatePreviewRequest } from '../utils';
|
import { validatePreviewRequest } from '../utils';
|
||||||
import { errorToLog, serverLogger } from '@/lib/server/logger';
|
import { errorToLog, serverLogger } from '@/lib/server/logger';
|
||||||
|
import { errorResponse } from '@/lib/server/errors/next-response';
|
||||||
|
|
||||||
export const dynamic = 'force-dynamic';
|
export const dynamic = 'force-dynamic';
|
||||||
|
|
||||||
|
|
@ -55,6 +56,9 @@ export async function GET(req: NextRequest) {
|
||||||
event: 'documents.preview.ensure.failed',
|
event: 'documents.preview.ensure.failed',
|
||||||
error: errorToLog(error),
|
error: errorToLog(error),
|
||||||
}, 'Failed to ensure document preview');
|
}, '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' },
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,7 @@ import { db } from '@/db';
|
||||||
import { documents } from '@/db/schema';
|
import { documents } from '@/db/schema';
|
||||||
import { requireAuthContext } from '@/lib/server/auth/auth';
|
import { requireAuthContext } from '@/lib/server/auth/auth';
|
||||||
import { errorToLog, serverLogger } from '@/lib/server/logger';
|
import { errorToLog, serverLogger } from '@/lib/server/logger';
|
||||||
|
import { errorResponse } from '@/lib/server/errors/next-response';
|
||||||
import {
|
import {
|
||||||
getDocumentRange,
|
getDocumentRange,
|
||||||
isMissingBlobError as isMissingDocumentBlobError,
|
isMissingBlobError as isMissingDocumentBlobError,
|
||||||
|
|
@ -183,6 +184,9 @@ export async function GET(req: NextRequest) {
|
||||||
event: 'documents.preview.fallback.failed',
|
event: 'documents.preview.fallback.failed',
|
||||||
error: errorToLog(error),
|
error: errorToLog(error),
|
||||||
}, 'Failed to load document preview fallback');
|
}, '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' },
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,7 @@ import { presignDocumentPreviewGet } from '@/lib/server/documents/previews-blobs
|
||||||
import { ensureDocumentPreview } from '@/lib/server/documents/previews';
|
import { ensureDocumentPreview } from '@/lib/server/documents/previews';
|
||||||
import { validatePreviewRequest } from '../utils';
|
import { validatePreviewRequest } from '../utils';
|
||||||
import { errorToLog, serverLogger } from '@/lib/server/logger';
|
import { errorToLog, serverLogger } from '@/lib/server/logger';
|
||||||
|
import { errorResponse } from '@/lib/server/errors/next-response';
|
||||||
|
|
||||||
export const dynamic = 'force-dynamic';
|
export const dynamic = 'force-dynamic';
|
||||||
|
|
||||||
|
|
@ -59,6 +60,9 @@ export async function GET(req: NextRequest) {
|
||||||
event: 'documents.preview.presign.failed',
|
event: 'documents.preview.presign.failed',
|
||||||
error: errorToLog(error),
|
error: errorToLog(error),
|
||||||
}, 'Failed to create document preview signature');
|
}, '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' },
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,7 @@ import { isValidDocumentId, putDocumentBlob } from '@/lib/server/documents/blobs
|
||||||
import { isS3Configured } from '@/lib/server/storage/s3';
|
import { isS3Configured } from '@/lib/server/storage/s3';
|
||||||
import { getOpenReaderTestNamespace } from '@/lib/server/testing/test-namespace';
|
import { getOpenReaderTestNamespace } from '@/lib/server/testing/test-namespace';
|
||||||
import { errorToLog, serverLogger } from '@/lib/server/logger';
|
import { errorToLog, serverLogger } from '@/lib/server/logger';
|
||||||
|
import { errorResponse } from '@/lib/server/errors/next-response';
|
||||||
|
|
||||||
export const dynamic = 'force-dynamic';
|
export const dynamic = 'force-dynamic';
|
||||||
|
|
||||||
|
|
@ -58,6 +59,9 @@ export async function PUT(req: NextRequest) {
|
||||||
event: 'documents.blob.upload.fallback.failed',
|
event: 'documents.blob.upload.fallback.failed',
|
||||||
error: errorToLog(error),
|
error: errorToLog(error),
|
||||||
}, 'Failed to proxy-upload document blob');
|
}, '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' },
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,7 @@ import { isValidDocumentId, presignPut } from '@/lib/server/documents/blobstore'
|
||||||
import { getOpenReaderTestNamespace } from '@/lib/server/testing/test-namespace';
|
import { getOpenReaderTestNamespace } from '@/lib/server/testing/test-namespace';
|
||||||
import { isS3Configured } from '@/lib/server/storage/s3';
|
import { isS3Configured } from '@/lib/server/storage/s3';
|
||||||
import { errorToLog, serverLogger } from '@/lib/server/logger';
|
import { errorToLog, serverLogger } from '@/lib/server/logger';
|
||||||
|
import { errorResponse } from '@/lib/server/errors/next-response';
|
||||||
|
|
||||||
export const dynamic = 'force-dynamic';
|
export const dynamic = 'force-dynamic';
|
||||||
|
|
||||||
|
|
@ -70,6 +71,9 @@ export async function POST(req: NextRequest) {
|
||||||
event: 'documents.blob.upload.presign.failed',
|
event: 'documents.blob.upload.presign.failed',
|
||||||
error: errorToLog(error),
|
error: errorToLog(error),
|
||||||
}, 'Failed to create document upload signatures');
|
}, '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' },
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -16,6 +16,7 @@ import { getOpenReaderTestNamespace, getUnclaimedUserIdForNamespace } from '@/li
|
||||||
import { isS3Configured } from '@/lib/server/storage/s3';
|
import { isS3Configured } from '@/lib/server/storage/s3';
|
||||||
import { putDocumentBlob } from '@/lib/server/documents/blobstore';
|
import { putDocumentBlob } from '@/lib/server/documents/blobstore';
|
||||||
import { errorToLog, serverLogger } from '@/lib/server/logger';
|
import { errorToLog, serverLogger } from '@/lib/server/logger';
|
||||||
|
import { errorResponse } from '@/lib/server/errors/next-response';
|
||||||
|
|
||||||
const DOCSTORE_DIR = path.join(process.cwd(), 'docstore');
|
const DOCSTORE_DIR = path.join(process.cwd(), 'docstore');
|
||||||
const TEMP_DIR = path.join(DOCSTORE_DIR, 'tmp');
|
const TEMP_DIR = path.join(DOCSTORE_DIR, 'tmp');
|
||||||
|
|
@ -195,6 +196,9 @@ export async function POST(req: NextRequest) {
|
||||||
event: 'documents.docx.convert_upload.failed',
|
event: 'documents.docx.convert_upload.failed',
|
||||||
error: errorToLog(error),
|
error: errorToLog(error),
|
||||||
}, 'Failed converting/uploading DOCX');
|
}, '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' },
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,7 @@ import { parseLibraryRoots } from '@/lib/server/storage/library-mount';
|
||||||
import type { DocumentType } from '@/types/documents';
|
import type { DocumentType } from '@/types/documents';
|
||||||
import { auth } from '@/lib/server/auth/auth';
|
import { auth } from '@/lib/server/auth/auth';
|
||||||
import { errorToLog, serverLogger } from '@/lib/server/logger';
|
import { errorToLog, serverLogger } from '@/lib/server/logger';
|
||||||
|
import { errorResponse } from '@/lib/server/errors/next-response';
|
||||||
|
|
||||||
export const dynamic = 'force-dynamic';
|
export const dynamic = 'force-dynamic';
|
||||||
|
|
||||||
|
|
@ -116,7 +117,10 @@ export async function GET(req: NextRequest) {
|
||||||
event: 'documents.library.auth_check.failed',
|
event: 'documents.library.auth_check.failed',
|
||||||
error: errorToLog(error),
|
error: errorToLog(error),
|
||||||
}, 'Failed to check auth for library route');
|
}, '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);
|
const url = new URL(req.url);
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,7 @@ import { documents } from '@/db/schema';
|
||||||
import { requireAuthContext } from '@/lib/server/auth/auth';
|
import { requireAuthContext } from '@/lib/server/auth/auth';
|
||||||
import { safeDocumentName, toDocumentTypeFromName } from '@/lib/server/documents/utils';
|
import { safeDocumentName, toDocumentTypeFromName } from '@/lib/server/documents/utils';
|
||||||
import { errorToLog, serverLogger } from '@/lib/server/logger';
|
import { errorToLog, serverLogger } from '@/lib/server/logger';
|
||||||
|
import { errorResponse } from '@/lib/server/errors/next-response';
|
||||||
import {
|
import {
|
||||||
cleanupDocumentPreviewArtifacts,
|
cleanupDocumentPreviewArtifacts,
|
||||||
deleteDocumentPreviewRows,
|
deleteDocumentPreviewRows,
|
||||||
|
|
@ -197,7 +198,10 @@ export async function POST(req: NextRequest) {
|
||||||
event: 'documents.register.failed',
|
event: 'documents.register.failed',
|
||||||
error: errorToLog(error),
|
error: errorToLog(error),
|
||||||
}, 'Failed to register documents');
|
}, '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',
|
event: 'documents.list.failed',
|
||||||
error: errorToLog(error),
|
error: errorToLog(error),
|
||||||
}, 'Failed to load document metadata');
|
}, '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',
|
event: 'documents.delete.failed',
|
||||||
error: errorToLog(error),
|
error: errorToLog(error),
|
||||||
}, 'Failed to delete documents');
|
}, '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' },
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,7 @@ import { getClientIp } from '@/lib/server/rate-limit/request-ip';
|
||||||
import { getOrCreateDeviceId, setDeviceIdCookie } from '@/lib/server/rate-limit/device-id';
|
import { getOrCreateDeviceId, setDeviceIdCookie } from '@/lib/server/rate-limit/device-id';
|
||||||
import { nextUtcMidnightTimestampMs } from '@/lib/shared/timestamps';
|
import { nextUtcMidnightTimestampMs } from '@/lib/shared/timestamps';
|
||||||
import { errorToLog, serverLogger } from '@/lib/server/logger';
|
import { errorToLog, serverLogger } from '@/lib/server/logger';
|
||||||
|
import { errorResponse } from '@/lib/server/errors/next-response';
|
||||||
|
|
||||||
export const dynamic = 'force-dynamic';
|
export const dynamic = 'force-dynamic';
|
||||||
|
|
||||||
|
|
@ -89,9 +90,9 @@ export async function GET(req: NextRequest) {
|
||||||
event: 'rate_limit.status.get.failed',
|
event: 'rate_limit.status.get.failed',
|
||||||
error: errorToLog(error),
|
error: errorToLog(error),
|
||||||
}, 'Failed to get rate limit status');
|
}, 'Failed to get rate limit status');
|
||||||
return NextResponse.json(
|
return errorResponse(error, {
|
||||||
{ error: 'Failed to get rate limit status' },
|
apiErrorMessage: 'Failed to get rate limit status',
|
||||||
{ status: 500 }
|
normalize: { code: 'RATE_LIMIT_STATUS_GET_FAILED', errorClass: 'db' },
|
||||||
);
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -7,7 +7,8 @@ import {
|
||||||
ttsSegmentsS3NotConfiguredResponse,
|
ttsSegmentsS3NotConfiguredResponse,
|
||||||
} from '@/lib/server/tts/segments-audio';
|
} from '@/lib/server/tts/segments-audio';
|
||||||
import { getTtsSegmentAudioObjectStream } from '@/lib/server/tts/segments-blobstore';
|
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';
|
export const dynamic = 'force-dynamic';
|
||||||
|
|
||||||
|
|
@ -50,10 +51,12 @@ export async function GET(request: NextRequest) {
|
||||||
headers,
|
headers,
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error({
|
return errorResponse(error, {
|
||||||
|
logger,
|
||||||
event: 'tts.segments.audio_fallback_failed',
|
event: 'tts.segments.audio_fallback_failed',
|
||||||
error: errorToLog(error),
|
msg: 'Failed to load segment audio from fallback route',
|
||||||
}, 'Failed to load segment audio from fallback route');
|
apiErrorMessage: 'Failed to load segment audio',
|
||||||
return NextResponse.json({ error: 'Failed to load segment audio' }, { status: 500 });
|
normalize: { code: 'TTS_SEGMENT_AUDIO_FALLBACK_FAILED', errorClass: 'storage' },
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,8 @@
|
||||||
import { NextRequest, NextResponse } from 'next/server';
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
import { resolveSegmentDocumentScope } from '@/lib/server/tts/segments-auth';
|
import { resolveSegmentDocumentScope } from '@/lib/server/tts/segments-auth';
|
||||||
import { clearTtsSegmentCache } from '@/lib/server/tts/segments-cache';
|
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 runtime = 'nodejs';
|
||||||
export const dynamic = 'force-dynamic';
|
export const dynamic = 'force-dynamic';
|
||||||
|
|
@ -39,10 +40,12 @@ export async function POST(request: NextRequest) {
|
||||||
...cleared,
|
...cleared,
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error({
|
return errorResponse(error, {
|
||||||
|
logger,
|
||||||
event: 'tts.segments.clear_failed',
|
event: 'tts.segments.clear_failed',
|
||||||
error: errorToLog(error),
|
msg: 'Failed to clear TTS segment cache',
|
||||||
}, 'Failed to clear TTS segment cache');
|
apiErrorMessage: 'Failed to clear TTS segment cache',
|
||||||
return NextResponse.json({ error: 'Failed to clear TTS segment cache' }, { status: 500 });
|
normalize: { code: 'TTS_SEGMENTS_CLEAR_FAILED', errorClass: 'storage' },
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -35,6 +35,7 @@ import { getResolvedRuntimeConfig } from '@/lib/server/runtime-config';
|
||||||
import { resolveTtsModelForProvider } from '@/lib/shared/tts-provider-policy';
|
import { resolveTtsModelForProvider } from '@/lib/shared/tts-provider-policy';
|
||||||
import { resolveSegmentAudioUrls } from '@/lib/server/tts/segment-audio-urls';
|
import { resolveSegmentAudioUrls } from '@/lib/server/tts/segment-audio-urls';
|
||||||
import { createRequestLogger, errorToLog } from '@/lib/server/logger';
|
import { createRequestLogger, errorToLog } from '@/lib/server/logger';
|
||||||
|
import { errorResponse } from '@/lib/server/errors/next-response';
|
||||||
import type {
|
import type {
|
||||||
TTSSegmentInput,
|
TTSSegmentInput,
|
||||||
TTSSegmentManifestItem,
|
TTSSegmentManifestItem,
|
||||||
|
|
@ -828,11 +829,13 @@ export async function POST(request: NextRequest) {
|
||||||
attachDeviceIdCookie(response, deviceIdToSet, didCreateDeviceIdCookie);
|
attachDeviceIdCookie(response, deviceIdToSet, didCreateDeviceIdCookie);
|
||||||
return response;
|
return response;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error({
|
const response = errorResponse(error, {
|
||||||
|
logger,
|
||||||
event: 'tts.segments.ensure.route_failed',
|
event: 'tts.segments.ensure.route_failed',
|
||||||
error: errorToLog(error),
|
msg: 'TTS segments ensure route failed',
|
||||||
}, 'TTS segments ensure route failed');
|
apiErrorMessage: 'Failed to ensure TTS segments',
|
||||||
const response = NextResponse.json({ error: 'Failed to ensure TTS segments' }, { status: 500 });
|
normalize: { code: 'TTS_SEGMENTS_ENSURE_ROUTE_FAILED', errorClass: 'upstream' },
|
||||||
|
});
|
||||||
attachDeviceIdCookie(response, deviceIdToSet, didCreateDeviceIdCookie);
|
attachDeviceIdCookie(response, deviceIdToSet, didCreateDeviceIdCookie);
|
||||||
return response;
|
return response;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -20,7 +20,8 @@ import type {
|
||||||
import { isTtsProviderType } from '@/lib/shared/tts-provider-catalog';
|
import { isTtsProviderType } from '@/lib/shared/tts-provider-catalog';
|
||||||
import { resolveEffectiveProviderType } from '@/lib/shared/tts-provider-policy';
|
import { resolveEffectiveProviderType } from '@/lib/shared/tts-provider-policy';
|
||||||
import { resolveSegmentAudioUrls } from '@/lib/server/tts/segment-audio-urls';
|
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 runtime = 'nodejs';
|
||||||
export const dynamic = 'force-dynamic';
|
export const dynamic = 'force-dynamic';
|
||||||
|
|
@ -386,10 +387,12 @@ export async function GET(request: NextRequest) {
|
||||||
};
|
};
|
||||||
return NextResponse.json(response);
|
return NextResponse.json(response);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error({
|
return errorResponse(error, {
|
||||||
|
logger,
|
||||||
event: 'tts.segments.manifest.list_failed',
|
event: 'tts.segments.manifest.list_failed',
|
||||||
error: errorToLog(error),
|
msg: 'Failed to list TTS segments',
|
||||||
}, 'Failed to list TTS segments');
|
apiErrorMessage: 'Failed to list TTS segments',
|
||||||
return NextResponse.json({ error: 'Failed to list TTS segments' }, { status: 500 });
|
normalize: { code: 'TTS_SEGMENTS_MANIFEST_LIST_FAILED', errorClass: 'db' },
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,8 @@ import { resolveVoices } from '@/lib/server/tts/voice-resolution';
|
||||||
import { resolveTtsCredentials } from '@/lib/server/admin/resolve-credentials';
|
import { resolveTtsCredentials } from '@/lib/server/admin/resolve-credentials';
|
||||||
import { getResolvedRuntimeConfig } from '@/lib/server/runtime-config';
|
import { getResolvedRuntimeConfig } from '@/lib/server/runtime-config';
|
||||||
import { errorToLog, serverLogger } from '@/lib/server/logger';
|
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) {
|
export async function GET(req: NextRequest) {
|
||||||
try {
|
try {
|
||||||
|
|
@ -41,7 +43,10 @@ export async function GET(req: NextRequest) {
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!isBuiltInTtsProviderId(resolved.provider)) {
|
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
|
const effectiveProviderRef = resolved.adminRecord?.slug
|
||||||
?? req.headers.get('x-tts-provider')
|
?? 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 providerRef = req.headers.get('x-tts-provider') || 'openai';
|
||||||
const model = req.headers.get('x-tts-model') || 'tts-1';
|
const model = req.headers.get('x-tts-model') || 'tts-1';
|
||||||
const provider = isBuiltInTtsProviderId(providerRef) ? providerRef : 'openai';
|
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(
|
return NextResponse.json(
|
||||||
{
|
{
|
||||||
error: 'Failed to resolve voices',
|
...toApiErrorBody(
|
||||||
fallbackVoices: resolveTtsProviderModelPolicy({
|
{ ...normalized, message: 'Failed to resolve voices' },
|
||||||
providerRef,
|
{ includeDetails: false, includeRetryable: false },
|
||||||
providerType: provider,
|
),
|
||||||
model,
|
fallbackVoices,
|
||||||
}).defaultVoices,
|
|
||||||
},
|
},
|
||||||
{ status: 500 },
|
{ status: toHttpStatus(normalized) },
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,7 @@ import { audiobooks, documents, userDocumentProgress, userPreferences } from '@/
|
||||||
import { count, eq, ne } from 'drizzle-orm';
|
import { count, eq, ne } from 'drizzle-orm';
|
||||||
import { getOpenReaderTestNamespace, getUnclaimedUserIdForNamespace } from '@/lib/server/testing/test-namespace';
|
import { getOpenReaderTestNamespace, getUnclaimedUserIdForNamespace } from '@/lib/server/testing/test-namespace';
|
||||||
import { errorToLog, serverLogger } from '@/lib/server/logger';
|
import { errorToLog, serverLogger } from '@/lib/server/logger';
|
||||||
|
import { errorResponse } from '@/lib/server/errors/next-response';
|
||||||
|
|
||||||
async function checkClaimMigrationReadiness(): Promise<NextResponse | null> {
|
async function checkClaimMigrationReadiness(): Promise<NextResponse | null> {
|
||||||
const [legacyRows] = await db
|
const [legacyRows] = await db
|
||||||
|
|
@ -61,7 +62,10 @@ export async function GET(req: NextRequest) {
|
||||||
event: 'user.claim.status.failed',
|
event: 'user.claim.status.failed',
|
||||||
error: errorToLog(error),
|
error: errorToLog(error),
|
||||||
}, 'Failed checking claimable data');
|
}, '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',
|
event: 'user.claim.execute.failed',
|
||||||
error: errorToLog(error),
|
error: errorToLog(error),
|
||||||
}, 'Failed claiming anonymous data');
|
}, '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' },
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -12,6 +12,7 @@ import { isS3Configured } from '@/lib/server/storage/s3';
|
||||||
import { getOpenReaderTestNamespace } from '@/lib/server/testing/test-namespace';
|
import { getOpenReaderTestNamespace } from '@/lib/server/testing/test-namespace';
|
||||||
import { nowTimestampMs } from '@/lib/shared/timestamps';
|
import { nowTimestampMs } from '@/lib/shared/timestamps';
|
||||||
import { errorToLog, serverLogger } from '@/lib/server/logger';
|
import { errorToLog, serverLogger } from '@/lib/server/logger';
|
||||||
|
import { errorResponse } from '@/lib/server/errors/next-response';
|
||||||
|
|
||||||
export const dynamic = 'force-dynamic';
|
export const dynamic = 'force-dynamic';
|
||||||
|
|
||||||
|
|
@ -24,7 +25,10 @@ export async function GET(req: NextRequest) {
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!auth) {
|
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({
|
const session = await auth.api.getSession({
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,7 @@ import { normalizeVersion, shouldOpenChangelogForVersionChange } from '@/lib/sha
|
||||||
import { nowTimestampMs } from '@/lib/shared/timestamps';
|
import { nowTimestampMs } from '@/lib/shared/timestamps';
|
||||||
import { resolveUserStateScope } from '@/lib/server/user/resolve-state-scope';
|
import { resolveUserStateScope } from '@/lib/server/user/resolve-state-scope';
|
||||||
import { errorToLog, serverLogger } from '@/lib/server/logger';
|
import { errorToLog, serverLogger } from '@/lib/server/logger';
|
||||||
|
import { errorResponse } from '@/lib/server/errors/next-response';
|
||||||
import {
|
import {
|
||||||
deserializeUserPreferencesPayload,
|
deserializeUserPreferencesPayload,
|
||||||
extractUserPreferencesMeta,
|
extractUserPreferencesMeta,
|
||||||
|
|
@ -85,6 +86,9 @@ export async function POST(req: NextRequest) {
|
||||||
event: 'user.changelog.version_check.failed',
|
event: 'user.changelog.version_check.failed',
|
||||||
error: errorToLog(error),
|
error: errorToLog(error),
|
||||||
}, 'Failed to check changelog version');
|
}, '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' },
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -10,6 +10,7 @@ import { listAdminProviders } from '@/lib/server/admin/providers';
|
||||||
import { getResolvedRuntimeConfig } from '@/lib/server/runtime-config';
|
import { getResolvedRuntimeConfig } from '@/lib/server/runtime-config';
|
||||||
import { normalizeLegacyProviderRef, resolveProviderDefaults } from '@/lib/shared/tts-provider-policy';
|
import { normalizeLegacyProviderRef, resolveProviderDefaults } from '@/lib/shared/tts-provider-policy';
|
||||||
import { errorToLog, serverLogger } from '@/lib/server/logger';
|
import { errorToLog, serverLogger } from '@/lib/server/logger';
|
||||||
|
import { errorResponse } from '@/lib/server/errors/next-response';
|
||||||
import {
|
import {
|
||||||
deserializeUserPreferencesPayload,
|
deserializeUserPreferencesPayload,
|
||||||
extractUserPreferencesMeta,
|
extractUserPreferencesMeta,
|
||||||
|
|
@ -246,7 +247,10 @@ export async function GET(req: NextRequest) {
|
||||||
event: 'user.preferences.load.failed',
|
event: 'user.preferences.load.failed',
|
||||||
error: errorToLog(error),
|
error: errorToLog(error),
|
||||||
}, 'Failed to load user preferences');
|
}, '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',
|
event: 'user.preferences.update.failed',
|
||||||
error: errorToLog(error),
|
error: errorToLog(error),
|
||||||
}, 'Failed to update user preferences');
|
}, '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' },
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,7 @@ import { isValidDocumentId } from '@/lib/server/documents/blobstore';
|
||||||
import { resolveUserStateScope } from '@/lib/server/user/resolve-state-scope';
|
import { resolveUserStateScope } from '@/lib/server/user/resolve-state-scope';
|
||||||
import { coerceTimestampMs, nowTimestampMs } from '@/lib/shared/timestamps';
|
import { coerceTimestampMs, nowTimestampMs } from '@/lib/shared/timestamps';
|
||||||
import { errorToLog, serverLogger } from '@/lib/server/logger';
|
import { errorToLog, serverLogger } from '@/lib/server/logger';
|
||||||
|
import { errorResponse } from '@/lib/server/errors/next-response';
|
||||||
|
|
||||||
export const dynamic = 'force-dynamic';
|
export const dynamic = 'force-dynamic';
|
||||||
|
|
||||||
|
|
@ -67,7 +68,10 @@ export async function GET(req: NextRequest) {
|
||||||
event: 'user.progress.load.failed',
|
event: 'user.progress.load.failed',
|
||||||
error: errorToLog(error),
|
error: errorToLog(error),
|
||||||
}, 'Failed to load user progress');
|
}, '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',
|
event: 'user.progress.update.failed',
|
||||||
error: errorToLog(error),
|
error: errorToLog(error),
|
||||||
}, 'Failed to update user progress');
|
}, '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' },
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
import { getWorkerClientConfigFromEnv } from '@/lib/server/compute/worker';
|
import { getWorkerClientConfigFromEnv } from '@/lib/server/compute/worker';
|
||||||
import type { WorkerOperationState } from '@openreader/compute-core/api-contracts';
|
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;
|
const WORKER_OP_REQUEST_TIMEOUT_MS = 2_500;
|
||||||
|
|
||||||
|
|
@ -14,13 +15,13 @@ export async function fetchWorkerOperationState<Result>(
|
||||||
try {
|
try {
|
||||||
cfg = getWorkerClientConfigFromEnv();
|
cfg = getWorkerClientConfigFromEnv();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
serverLogger.warn({
|
logDegraded(serverLogger, {
|
||||||
event: 'compute.worker_op_state.config.invalid',
|
event: 'compute.worker_op_state.config.invalid',
|
||||||
opId: normalized,
|
msg: 'Worker client env missing/invalid',
|
||||||
degraded: true,
|
|
||||||
step: 'read_worker_config',
|
step: 'read_worker_config',
|
||||||
error: errorToLog(error),
|
context: { opId: normalized },
|
||||||
}, 'Worker client env missing/invalid');
|
error,
|
||||||
|
});
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -40,39 +41,41 @@ export async function fetchWorkerOperationState<Result>(
|
||||||
|
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
const upstreamResponseBody = await res.text().catch(() => '');
|
const upstreamResponseBody = await res.text().catch(() => '');
|
||||||
serverLogger.warn({
|
logDegraded(serverLogger, {
|
||||||
event: 'compute.worker_op_state.fetch.failed',
|
event: 'compute.worker_op_state.fetch.failed',
|
||||||
opId: normalized,
|
msg: 'Worker op request failed',
|
||||||
status: res.status,
|
|
||||||
upstreamResponseBody,
|
|
||||||
degraded: true,
|
|
||||||
step: 'fetch_worker_op',
|
step: 'fetch_worker_op',
|
||||||
|
context: {
|
||||||
|
opId: normalized,
|
||||||
|
status: res.status,
|
||||||
|
upstreamResponseBody,
|
||||||
|
},
|
||||||
error: {
|
error: {
|
||||||
name: 'WorkerOpStateFetchFailed',
|
name: 'WorkerOpStateFetchFailed',
|
||||||
message: `Worker op request failed with status ${res.status}`,
|
message: `Worker op request failed with status ${res.status}`,
|
||||||
},
|
},
|
||||||
}, 'Worker op request failed');
|
});
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
const parsed = await res.json() as WorkerOperationState<Result>;
|
const parsed = await res.json() as WorkerOperationState<Result>;
|
||||||
if (!parsed || typeof parsed !== 'object' || parsed.opId !== normalized) {
|
if (!parsed || typeof parsed !== 'object' || parsed.opId !== normalized) {
|
||||||
serverLogger.warn({
|
logDegraded(serverLogger, {
|
||||||
event: 'compute.worker_op_state.response.invalid',
|
event: 'compute.worker_op_state.response.invalid',
|
||||||
opId: normalized,
|
msg: 'Worker op response invalid',
|
||||||
degraded: true,
|
|
||||||
step: 'validate_worker_op_response',
|
step: 'validate_worker_op_response',
|
||||||
}, 'Worker op response invalid');
|
context: { opId: normalized },
|
||||||
|
});
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
return parsed;
|
return parsed;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
serverLogger.warn({
|
logDegraded(serverLogger, {
|
||||||
event: 'compute.worker_op_state.fetch.error',
|
event: 'compute.worker_op_state.fetch.error',
|
||||||
opId: normalized,
|
msg: 'Worker op request threw',
|
||||||
degraded: true,
|
|
||||||
step: 'fetch_worker_op',
|
step: 'fetch_worker_op',
|
||||||
error: errorToLog(error),
|
context: { opId: normalized },
|
||||||
}, 'Worker op request threw');
|
error,
|
||||||
|
});
|
||||||
return null;
|
return null;
|
||||||
} finally {
|
} finally {
|
||||||
clearTimeout(timeout);
|
clearTimeout(timeout);
|
||||||
|
|
|
||||||
65
src/lib/server/errors/catalog.ts
Normal file
65
src/lib/server/errors/catalog.ts
Normal 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),
|
||||||
|
);
|
||||||
203
src/lib/server/errors/contract.ts
Normal file
203
src/lib/server/errors/contract.ts
Normal 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);
|
||||||
|
}
|
||||||
48
src/lib/server/errors/logging.ts
Normal file
48
src/lib/server/errors/logging.ts
Normal 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}`);
|
||||||
|
}
|
||||||
90
src/lib/server/errors/next-response.ts
Normal file
90
src/lib/server/errors/next-response.ts
Normal 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);
|
||||||
|
}
|
||||||
|
|
@ -6,31 +6,6 @@ import pinoPretty from 'pino-pretty';
|
||||||
export type ServerLogger = Logger;
|
export type ServerLogger = Logger;
|
||||||
export type ServerLogLevel = 'error' | 'warn' | 'info';
|
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 = {
|
export type LoggedError = {
|
||||||
name: string;
|
name: string;
|
||||||
message: 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 {
|
export function hashForLog(value: string | null | undefined): string | null {
|
||||||
const normalized = typeof value === 'string' ? value.trim() : '';
|
const normalized = typeof value === 'string' ? value.trim() : '';
|
||||||
if (!normalized) return null;
|
if (!normalized) return null;
|
||||||
|
|
|
||||||
55
tests/unit/server-error-contract.spec.ts
Normal file
55
tests/unit/server-error-contract.spec.ts
Normal 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
43
tests/unit/server-error-response.spec.ts
Normal file
43
tests/unit/server-error-response.spec.ts
Normal 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');
|
||||||
|
});
|
||||||
|
});
|
||||||
Loading…
Reference in a new issue