diff --git a/src/app/api/admin/providers/[id]/route.ts b/src/app/api/admin/providers/[id]/route.ts index c3755f6..e5fc4a3 100644 --- a/src/app/api/admin/providers/[id]/route.ts +++ b/src/app/api/admin/providers/[id]/route.ts @@ -41,7 +41,15 @@ export async function PUT( return NextResponse.json({ provider: toMasked(updated) }); } catch (error) { if (error instanceof AdminProviderError) { - return NextResponse.json({ error: error.message }, { status: error.status }); + return errorResponse(error, { + apiErrorMessage: error.message, + normalize: { + code: 'ADMIN_PROVIDERS_UPDATE_REQUEST_FAILED', + errorClass: error.status >= 500 ? 'db' : 'validation', + httpStatus: error.status, + retryable: error.status >= 500, + }, + }); } serverLogger.error({ event: 'admin.providers.update.failed', @@ -68,7 +76,15 @@ export async function DELETE( return NextResponse.json({ ok: true }); } catch (error) { if (error instanceof AdminProviderError) { - return NextResponse.json({ error: error.message }, { status: error.status }); + return errorResponse(error, { + apiErrorMessage: error.message, + normalize: { + code: 'ADMIN_PROVIDERS_DELETE_REQUEST_FAILED', + errorClass: error.status >= 500 ? 'db' : 'validation', + httpStatus: error.status, + retryable: error.status >= 500, + }, + }); } serverLogger.error({ event: 'admin.providers.delete.failed', diff --git a/src/app/api/admin/providers/route.ts b/src/app/api/admin/providers/route.ts index 96d8fa9..1600036 100644 --- a/src/app/api/admin/providers/route.ts +++ b/src/app/api/admin/providers/route.ts @@ -46,7 +46,15 @@ export async function POST(req: NextRequest) { return NextResponse.json({ provider: toMasked(record) }, { status: 201 }); } catch (error) { if (error instanceof AdminProviderError) { - return NextResponse.json({ error: error.message }, { status: error.status }); + return errorResponse(error, { + apiErrorMessage: error.message, + normalize: { + code: 'ADMIN_PROVIDERS_CREATE_REQUEST_FAILED', + errorClass: error.status >= 500 ? 'db' : 'validation', + httpStatus: error.status, + retryable: error.status >= 500, + }, + }); } serverLogger.error({ event: 'admin.providers.create.failed', diff --git a/src/lib/server/admin/email-sync.ts b/src/lib/server/admin/email-sync.ts index 3085584..105ec17 100644 --- a/src/lib/server/admin/email-sync.ts +++ b/src/lib/server/admin/email-sync.ts @@ -1,6 +1,7 @@ import { eq } from 'drizzle-orm'; import { db } from '@/db'; -import { errorToLog, hashForLog, serverLogger } from '@/lib/server/logger'; +import { hashForLog, serverLogger } from '@/lib/server/logger'; +import { logDegraded } from '@/lib/server/errors/logging'; // We only need the `user` table here. Better Auth manages its own schema files; // we import them lazily to avoid coupling this module to a single dialect. @@ -61,12 +62,13 @@ export async function syncAdminFlag( await db.update(user).set({ isAdmin: shouldBeAdmin }).where(eq(user.id, userId)); return shouldBeAdmin; } catch (error) { - serverLogger.warn({ + logDegraded(serverLogger, { event: 'admin.email_sync.user_flag_update.failed', - userIdHash: hashForLog(userId), - degraded: true, - error: errorToLog(error), - }, 'Failed to sync isAdmin flag for user'); + msg: 'Failed to sync isAdmin flag for user', + step: 'sync_admin_flag', + context: { userIdHash: hashForLog(userId) }, + error, + }); return currentIsAdmin; } } diff --git a/src/lib/server/admin/seed.ts b/src/lib/server/admin/seed.ts index 5057201..281f3d7 100644 --- a/src/lib/server/admin/seed.ts +++ b/src/lib/server/admin/seed.ts @@ -3,11 +3,12 @@ import { adminProviders, adminSettings } from '@/db/schema'; import { encryptSecret, apiKeyLast4 } from '@/lib/server/crypto/secrets'; import { randomUUID } from 'node:crypto'; import { and, eq } from 'drizzle-orm'; -import { errorToLog, serverLogger } from '@/lib/server/logger'; +import { serverLogger } from '@/lib/server/logger'; import { RUNTIME_CONFIG_SCHEMA, seedRuntimeConfigFromEnv, } from '@/lib/server/admin/settings'; +import { logDegraded } from '@/lib/server/errors/logging'; /** * Idempotent boot-time seeding for the admin layer. Safe to call multiple @@ -31,11 +32,12 @@ let seedPromise: Promise | null = null; export async function ensureAdminSeed(): Promise { if (!seedPromise) { seedPromise = runSeed().catch((error) => { - serverLogger.warn({ + logDegraded(serverLogger, { event: 'admin.seed.run.failed', - degraded: true, - error: errorToLog(error), - }, 'Admin seed run failed'); + msg: 'Admin seed run failed', + step: 'run_admin_seed', + error, + }); // Reset so a subsequent call can retry (e.g. once migrations run). seedPromise = null; throw error; @@ -63,11 +65,12 @@ async function seedDefaultAdminProvider(): Promise { try { existing = await db.select({ id: adminProviders.id }).from(adminProviders).limit(1); } catch (error) { - serverLogger.warn({ + logDegraded(serverLogger, { event: 'admin.seed.providers.check_failed', - degraded: true, - error: errorToLog(error), - }, 'Could not check admin_providers'); + msg: 'Could not check admin_providers', + step: 'check_existing_admin_providers', + error, + }); return; } if (existing.length > 0) return; @@ -94,24 +97,26 @@ async function seedDefaultAdminProvider(): Promise { updatedAt: now, }) .onConflictDoNothing({ target: adminSettings.key }); - serverLogger.warn({ + logDegraded(serverLogger, { event: 'admin.seed.restrict_user_api_keys.defaulted', - degraded: true, + msg: 'API_KEY present but AUTH_SECRET missing; defaulting restrictUserApiKeys=false', step: 'set_restrict_user_api_keys_fallback', - }, 'API_KEY present but AUTH_SECRET missing; defaulting restrictUserApiKeys=false'); + }); } catch (fallbackError) { - serverLogger.warn({ + logDegraded(serverLogger, { event: 'admin.seed.restrict_user_api_keys.fallback_write_failed', - degraded: true, - error: errorToLog(fallbackError), - }, 'Failed to write restrictUserApiKeys fallback after encryption failure'); + msg: 'Failed to write restrictUserApiKeys fallback after encryption failure', + step: 'set_restrict_user_api_keys_fallback', + error: fallbackError, + }); } } - serverLogger.warn({ + logDegraded(serverLogger, { event: 'admin.seed.provider_key_encrypt.failed', - degraded: true, - error: errorToLog(error), - }, 'Failed to encrypt default provider API key'); + msg: 'Failed to encrypt default provider API key', + step: 'encrypt_default_provider_key', + error, + }); return; } @@ -135,12 +140,13 @@ async function seedDefaultAdminProvider(): Promise { providerSlug: 'default-openai', }, 'Created default-openai admin provider from env'); } catch (error) { - serverLogger.warn({ + logDegraded(serverLogger, { event: 'admin.seed.provider_insert.failed', - degraded: true, - providerSlug: 'default-openai', - error: errorToLog(error), - }, 'Failed to insert default-openai provider'); + msg: 'Failed to insert default-openai provider', + step: 'insert_default_provider', + context: { providerSlug: 'default-openai' }, + error, + }); } } @@ -164,11 +170,12 @@ async function cleanupLegacyDefaultTtsProviderSeedRow(): Promise { ), ); } catch (error) { - serverLogger.warn({ + logDegraded(serverLogger, { event: 'admin.seed.legacy_default_provider_cleanup.failed', - degraded: true, - error: errorToLog(error), - }, 'Failed to cleanup legacy defaultTtsProvider seed row'); + msg: 'Failed to cleanup legacy defaultTtsProvider seed row', + step: 'cleanup_legacy_default_provider_seed', + error, + }); } } @@ -178,10 +185,11 @@ async function cleanupLegacyDefaultTtsModelRows(): Promise { .delete(adminSettings) .where(eq(adminSettings.key, 'defaultTtsModel')); } catch (error) { - serverLogger.warn({ + logDegraded(serverLogger, { event: 'admin.seed.legacy_default_model_cleanup.failed', - degraded: true, - error: errorToLog(error), - }, 'Failed to cleanup legacy defaultTtsModel rows'); + msg: 'Failed to cleanup legacy defaultTtsModel rows', + step: 'cleanup_legacy_default_model_rows', + error, + }); } } diff --git a/src/lib/server/admin/settings.ts b/src/lib/server/admin/settings.ts index 471f502..f10dffe 100644 --- a/src/lib/server/admin/settings.ts +++ b/src/lib/server/admin/settings.ts @@ -2,7 +2,8 @@ import { and, eq } from 'drizzle-orm'; import { db } from '@/db'; import { adminProviders, adminSettings } from '@/db/schema'; import { isAuthEnabled } from '@/lib/server/auth/config'; -import { errorToLog, serverLogger } from '@/lib/server/logger'; +import { serverLogger } from '@/lib/server/logger'; +import { logDegraded } from '@/lib/server/errors/logging'; /** * Runtime config: site-wide settings that used to live in build-time env vars. @@ -114,11 +115,12 @@ async function resolveImplicitDefaultTtsProvider(): Promise .limit(1); return rows[0]?.slug; } catch (error) { - serverLogger.warn({ + logDegraded(serverLogger, { event: 'admin.runtime_config.default_provider_lookup.failed', - degraded: true, - error: errorToLog(error), - }, 'Implicit defaultTtsProvider lookup failed'); + msg: 'Implicit defaultTtsProvider lookup failed', + step: 'resolve_implicit_default_provider', + error, + }); return undefined; } } @@ -146,11 +148,12 @@ async function readAllRows(): Promise betterAuth({ const { deleteUserStorageData } = await import('@/lib/server/user/data-cleanup'); await deleteUserStorageData(user.id, null); } catch (error) { - serverLogger.warn({ + logDegraded(serverLogger, { event: 'auth.user_delete.storage_cleanup_failed', - degraded: true, + msg: 'Failed to clean up user storage before deletion', step: 'delete_user_storage', - userIdHash: hashForLog(user.id), - error: errorToLog(error), - }, 'Failed to clean up user storage before deletion'); + context: { userIdHash: hashForLog(user.id) }, + error, + }); // Don't throw – allow the user deletion to proceed even if S3 cleanup fails. // Orphaned blobs are preferable to a blocked account deletion. } @@ -177,14 +178,16 @@ const createAuth = () => betterAuth({ newUserIdHash: hashForLog(newUser.user.id), }, 'Transferred rate limit data during account linking'); } catch (error) { - serverLogger.warn({ + logDegraded(serverLogger, { event: 'auth.link_account.transfer.rate_limit.failed', - degraded: true, + msg: 'Failed transferring rate limit data during account linking', step: 'transfer_rate_limit', - anonymousUserIdHash: hashForLog(anonymousUser.user.id), - newUserIdHash: hashForLog(newUser.user.id), - error: errorToLog(error), - }, 'Failed transferring rate limit data during account linking'); + context: { + anonymousUserIdHash: hashForLog(anonymousUser.user.id), + newUserIdHash: hashForLog(newUser.user.id), + }, + error, + }); // Don't throw here to prevent blocking the account linking process } @@ -200,14 +203,16 @@ const createAuth = () => betterAuth({ }, 'Transferred audiobooks during account linking'); } } catch (error) { - serverLogger.warn({ + logDegraded(serverLogger, { event: 'auth.link_account.transfer.audiobooks.failed', - degraded: true, + msg: 'Failed transferring audiobooks during account linking', step: 'transfer_audiobooks', - anonymousUserIdHash: hashForLog(anonymousUser.user.id), - newUserIdHash: hashForLog(newUser.user.id), - error: errorToLog(error), - }, 'Failed transferring audiobooks during account linking'); + context: { + anonymousUserIdHash: hashForLog(anonymousUser.user.id), + newUserIdHash: hashForLog(newUser.user.id), + }, + error, + }); // Don't throw here to prevent blocking the account linking process } @@ -223,14 +228,16 @@ const createAuth = () => betterAuth({ }, 'Transferred documents during account linking'); } } catch (error) { - serverLogger.warn({ + logDegraded(serverLogger, { event: 'auth.link_account.transfer.documents.failed', - degraded: true, + msg: 'Failed transferring documents during account linking', step: 'transfer_documents', - anonymousUserIdHash: hashForLog(anonymousUser.user.id), - newUserIdHash: hashForLog(newUser.user.id), - error: errorToLog(error), - }, 'Failed transferring documents during account linking'); + context: { + anonymousUserIdHash: hashForLog(anonymousUser.user.id), + newUserIdHash: hashForLog(newUser.user.id), + }, + error, + }); // Don't throw here to prevent blocking the account linking process } @@ -246,14 +253,16 @@ const createAuth = () => betterAuth({ }, 'Transferred preferences during account linking'); } } catch (error) { - serverLogger.warn({ + logDegraded(serverLogger, { event: 'auth.link_account.transfer.preferences.failed', - degraded: true, + msg: 'Failed transferring preferences during account linking', step: 'transfer_preferences', - anonymousUserIdHash: hashForLog(anonymousUser.user.id), - newUserIdHash: hashForLog(newUser.user.id), - error: errorToLog(error), - }, 'Failed transferring preferences during account linking'); + context: { + anonymousUserIdHash: hashForLog(anonymousUser.user.id), + newUserIdHash: hashForLog(newUser.user.id), + }, + error, + }); // Don't throw here to prevent blocking the account linking process } @@ -269,21 +278,24 @@ const createAuth = () => betterAuth({ }, 'Transferred reading progress during account linking'); } } catch (error) { - serverLogger.warn({ + logDegraded(serverLogger, { event: 'auth.link_account.transfer.progress.failed', - degraded: true, + msg: 'Failed transferring reading progress during account linking', step: 'transfer_progress', - anonymousUserIdHash: hashForLog(anonymousUser.user.id), - newUserIdHash: hashForLog(newUser.user.id), - error: errorToLog(error), - }, 'Failed transferring reading progress during account linking'); + context: { + anonymousUserIdHash: hashForLog(anonymousUser.user.id), + newUserIdHash: hashForLog(newUser.user.id), + }, + error, + }); // Don't throw here to prevent blocking the account linking process } } catch (error) { - serverLogger.error({ + logServerError(serverLogger, { event: 'auth.link_account.failed', - error: errorToLog(error), - }, 'onLinkAccount callback failed'); + msg: 'onLinkAccount callback failed', + error, + }); // Don't throw here to prevent blocking the account linking process } // Note: Anonymous user will be automatically deleted after this callback completes diff --git a/src/lib/server/compute/worker.ts b/src/lib/server/compute/worker.ts index df914db..6f94501 100644 --- a/src/lib/server/compute/worker.ts +++ b/src/lib/server/compute/worker.ts @@ -3,6 +3,7 @@ import type { ComputeBackend, PdfLayoutInput, WhisperAlignInput, WhisperAlignRes import { parseSseEventId, parseSsePayload } from '@openreader/compute-core'; import { getWorkerClientWaitTimeoutMs } from '@openreader/compute-core'; import { errorToLog, serverLogger } from '@/lib/server/logger'; +import { logDegraded, logServerError } from '@/lib/server/errors/logging'; import type { PdfLayoutJobRequest, PdfLayoutJobResult, @@ -46,24 +47,36 @@ function truncateForLog(value: string, maxChars = MAX_LOG_DETAIL_CHARS): string function logWorker(level: WorkerLogLevel, event: string, fields: Record): void { if (!LOG_EVENTS.has(event)) return; + const eventName = `compute.worker_client.${event}`; + const msg = `Worker client ${event}`; if (level === 'error') { - serverLogger.error({ - event: `compute.worker_client.${event}`, - operation: 'compute_worker_client', - ...fields, - }, `Worker client ${event}`); + logServerError(serverLogger, { + event: eventName, + msg, + error: fields.error ?? new Error(msg), + context: { + operation: 'compute_worker_client', + ...fields, + }, + normalize: { code: 'COMPUTE_WORKER_CLIENT_EVENT_FAILED', errorClass: 'upstream' }, + }); return; } if (level === 'warn') { - serverLogger.warn({ - event: `compute.worker_client.${event}`, - operation: 'compute_worker_client', - ...fields, - }, `Worker client ${event}`); + logDegraded(serverLogger, { + event: eventName, + msg, + step: 'worker_client', + context: { + operation: 'compute_worker_client', + ...fields, + }, + error: fields.error, + }); return; } serverLogger.info({ - event: `compute.worker_client.${event}`, + event: eventName, operation: 'compute_worker_client', ...fields, }, `Worker client ${event}`); diff --git a/src/lib/server/documents/previews.ts b/src/lib/server/documents/previews.ts index 3ed21dc..44699c6 100644 --- a/src/lib/server/documents/previews.ts +++ b/src/lib/server/documents/previews.ts @@ -2,7 +2,8 @@ import { randomUUID } from 'crypto'; import { and, eq, inArray, lt, or, sql } from 'drizzle-orm'; import { db } from '@/db'; import { documentPreviews } from '@/db/schema'; -import { errorToLog, serverLogger } from '@/lib/server/logger'; +import { serverLogger } from '@/lib/server/logger'; +import { logServerError } from '@/lib/server/errors/logging'; import { DOCUMENT_PREVIEW_CONTENT_TYPE, DOCUMENT_PREVIEW_VARIANT, @@ -375,12 +376,16 @@ export async function ensureDocumentPreview(doc: PreviewSourceDocument, namespac eTag: head.eTag, }); } catch (error) { - serverLogger.error({ + logServerError(serverLogger, { event: 'documents.preview.generate.failed', - documentId: doc.id, - documentType: doc.type, - error: errorToLog(error), - }, 'Preview generation failed'); + msg: 'Preview generation failed', + error, + context: { + documentId: doc.id, + documentType: doc.type, + }, + normalize: { code: 'DOCUMENT_PREVIEW_GENERATE_FAILED', errorClass: 'storage' }, + }); await markPreviewFailed(doc.id, namespaceKey, error); } } diff --git a/src/lib/server/errors/README.md b/src/lib/server/errors/README.md new file mode 100644 index 0000000..7227445 --- /dev/null +++ b/src/lib/server/errors/README.md @@ -0,0 +1,60 @@ +# Server Error Contract + +This module is the centralized server error contract for API routes, background jobs, and server libraries. + +## Canonical Shapes + +- App error contract: `ServerAppError` in `contract.ts` +- Normalization entrypoint: `normalizeServerError(error, ctx?)` +- API response mapping: `toApiErrorBody(...)` + `toHttpStatus(...)` +- Log helpers: `logServerError(...)` and `logDegraded(...)` + +## Classification Policy + +`ServerErrorClass` defaults: + +- `validation` -> `400`, `retryable=false` +- `auth` -> `401`, `retryable=false` +- `permission` -> `403`, `retryable=false` +- `upstream` -> `502`, `retryable=true` +- `storage` -> `503`, `retryable=true` +- `db` -> `500`, `retryable=true` +- `timeout` -> `504`, `retryable=true` +- `unknown` -> `500`, `retryable=false` + +## Code Naming + +App error codes use low-cardinality uppercase snake case: `DOMAIN_ACTION_REASON`. + +Examples: + +- `AUDIOBOOK_CHAPTER_PROCESS_FAILED` +- `DOCUMENT_PREVIEW_GENERATE_FAILED` +- `USER_EXPORT_AUTH_NOT_INITIALIZED` +- `UNKNOWN_SERVER_ERROR` + +## Route Contract + +Terminal route failures should use `errorResponse(...)` from `next-response.ts`. + +Response body shape: + +- `error` +- optional `errorCode` +- optional `retryable` +- optional safe `details` + +## Logging Contract + +Failure logs: + +- `event` +- `msg` +- nested `error` (from `errorToLog(...)`) +- optional `error.code` for app classification + +Degraded warnings: + +- `degraded: true` +- `step` or `fallbackPath` +- `event` + `msg` diff --git a/src/lib/server/jobs/user-pdf-layout-job.ts b/src/lib/server/jobs/user-pdf-layout-job.ts index 303d17d..341fd55 100644 --- a/src/lib/server/jobs/user-pdf-layout-job.ts +++ b/src/lib/server/jobs/user-pdf-layout-job.ts @@ -2,7 +2,8 @@ import { and, eq, inArray, isNull } from 'drizzle-orm'; import { db } from '@/db'; import { documents } from '@/db/schema'; import { documentKey, putParsedDocumentBlob } from '@/lib/server/documents/blobstore'; -import { errorToLog, serverLogger } from '@/lib/server/logger'; +import { serverLogger } from '@/lib/server/logger'; +import { logDegraded, logServerError } from '@/lib/server/errors/logging'; import { parseDocumentParseState, stringifyDocumentParseState, @@ -349,24 +350,28 @@ export async function parsePdfJob(input: UserPdfLayoutJobRequest): Promise readerType: 'pdf', }).then((cleared) => { if (cleared.warning) { - serverLogger.warn({ + logDegraded(serverLogger, { event: 'documents.parse.cache_invalidation.warning', - degraded: true, + msg: 'Parse cache invalidation warning', step: 'clear_tts_segment_cache', - documentId: input.documentId, - userId, - warning: cleared.warning, - }, 'Parse cache invalidation warning'); + context: { + documentId: input.documentId, + userId, + warning: cleared.warning, + }, + }); } }).catch((cacheError) => { - serverLogger.warn({ + logDegraded(serverLogger, { event: 'documents.parse.cache_invalidation.failed', - degraded: true, + msg: 'Parse cache invalidation failed', step: 'clear_tts_segment_cache', - documentId: input.documentId, - userId, - error: errorToLog(cacheError), - }, 'Parse cache invalidation failed'); + context: { + documentId: input.documentId, + userId, + }, + error: cacheError, + }); }); } } catch (error) { @@ -388,19 +393,27 @@ export async function parsePdfJob(input: UserPdfLayoutJobRequest): Promise parseState: stringifyDocumentParseState(failedState), }); } catch (statusError) { - serverLogger.error({ + logServerError(serverLogger, { event: 'documents.parse.status_write.failed', + msg: 'Failed to write parse status', + error: statusError, + context: { + documentId: input.documentId, + parseStatus, + }, + normalize: { code: 'DOCUMENT_PARSE_STATUS_WRITE_FAILED', errorClass: 'db' }, + }); + } + logServerError(serverLogger, { + event: 'documents.parse.job.failed', + msg: 'Parse job failed', + error, + context: { documentId: input.documentId, parseStatus, - error: errorToLog(statusError), - }, 'Failed to write parse status'); - } - serverLogger.error({ - event: 'documents.parse.job.failed', - documentId: input.documentId, - parseStatus, - error: errorToLog(error), - }, 'Parse job failed'); + }, + normalize: { code: 'DOCUMENT_PARSE_JOB_FAILED', errorClass: 'upstream' }, + }); } finally { running.delete(key); } @@ -410,10 +423,12 @@ export function enqueueParsePdfJob(input: UserPdfLayoutJobRequest): void { Promise.resolve() .then(() => parsePdfJob(input)) .catch((error) => { - serverLogger.error({ + logServerError(serverLogger, { event: 'documents.parse.job.uncaught_error', - documentId: input.documentId, - error: errorToLog(error), - }, 'Parse job uncaught error'); + msg: 'Parse job uncaught error', + error, + context: { documentId: input.documentId }, + normalize: { code: 'DOCUMENT_PARSE_JOB_UNCAUGHT_ERROR', errorClass: 'unknown' }, + }); }); } diff --git a/src/lib/server/rate-limit/rate-limiter.ts b/src/lib/server/rate-limit/rate-limiter.ts index 5c8f730..053cb8d 100644 --- a/src/lib/server/rate-limit/rate-limiter.ts +++ b/src/lib/server/rate-limit/rate-limiter.ts @@ -4,6 +4,7 @@ import { isAuthEnabled } from '@/lib/server/auth/config'; import { eq, and, lt, sql } from 'drizzle-orm'; import { nextUtcMidnightTimestampMs, nowTimestampMs } from '@/lib/shared/timestamps'; import { serverLogger } from '@/lib/server/logger'; +import { logDegraded } from '@/lib/server/errors/logging'; function readPositiveIntEnv(name: string, fallback: number): number { const raw = process.env[name]; @@ -11,13 +12,16 @@ function readPositiveIntEnv(name: string, fallback: number): number { const parsed = Number(raw); if (!Number.isFinite(parsed) || parsed <= 0) { - serverLogger.warn({ + logDegraded(serverLogger, { event: 'rate_limit.config.invalid_env', - degraded: true, - envVar: name, - envValue: raw, - fallbackValue: fallback, - }, 'Invalid rate limiter env value; using default'); + msg: 'Invalid rate limiter env value; using default', + step: 'read_limit_env', + context: { + envVar: name, + envValue: raw, + fallbackValue: fallback, + }, + }); return fallback; } diff --git a/src/lib/server/tts/segments-cache.ts b/src/lib/server/tts/segments-cache.ts index 858bef9..bad4b1f 100644 --- a/src/lib/server/tts/segments-cache.ts +++ b/src/lib/server/tts/segments-cache.ts @@ -3,7 +3,8 @@ import { db } from '@/db'; import { ttsSegmentEntries, ttsSegmentVariants } from '@/db/schema'; import { deleteTtsSegmentAudioObjects } from '@/lib/server/tts/segments-blobstore'; import type { ReaderType } from '@/types/user-state'; -import { errorToLog, serverLogger } from '@/lib/server/logger'; +import { serverLogger } from '@/lib/server/logger'; +import { logDegraded } from '@/lib/server/errors/logging'; type ClearTtsSegmentCacheInput = { userId: string; @@ -66,14 +67,16 @@ export async function clearTtsSegmentCache( } } catch (error) { warning = error instanceof Error ? error.message : 'Failed deleting some audio objects'; - serverLogger.warn({ + logDegraded(serverLogger, { event: 'tts.segments.cache.audio_cleanup_failed', - degraded: true, + msg: 'Failed clearing some TTS segment audio objects', step: 'delete_tts_audio_objects', - documentId: input.documentId, - userId: input.userId, - error: errorToLog(error), - }, 'Failed clearing some TTS segment audio objects'); + context: { + documentId: input.documentId, + userId: input.userId, + }, + error, + }); } } diff --git a/src/lib/server/tts/voice-resolution.ts b/src/lib/server/tts/voice-resolution.ts index e0d014d..d42ab3e 100644 --- a/src/lib/server/tts/voice-resolution.ts +++ b/src/lib/server/tts/voice-resolution.ts @@ -1,5 +1,6 @@ import { LRUCache } from 'lru-cache'; -import { errorToLog, serverLogger } from '@/lib/server/logger'; +import { serverLogger } from '@/lib/server/logger'; +import { logServerError } from '@/lib/server/errors/logging'; import { resolveProviderModels, type ReplicateVoiceInputKey, @@ -193,10 +194,12 @@ async function fetchReplicateOpenApiSchema(apiKey: string, model: string): Promi if (error instanceof DOMException && error.name === 'AbortError') { return null; } - serverLogger.error({ + logServerError(serverLogger, { event: 'tts.voice_resolution.replicate_schema_fetch.failed', - error: errorToLog(error), - }, 'Failed fetching Replicate model schema'); + msg: 'Failed fetching Replicate model schema', + error, + normalize: { code: 'TTS_VOICE_RESOLUTION_REPLICATE_SCHEMA_FETCH_FAILED', errorClass: 'upstream' }, + }); return null; } finally { clearTimeout(timeoutId); @@ -287,10 +290,12 @@ async function fetchDeepinfraVoices(apiKey: string): Promise { if (error instanceof DOMException && error.name === 'AbortError') { return []; } - serverLogger.error({ + logServerError(serverLogger, { event: 'tts.voice_resolution.deepinfra_voices_fetch.failed', - error: errorToLog(error), - }, 'Failed fetching Deepinfra voices'); + msg: 'Failed fetching Deepinfra voices', + error, + normalize: { code: 'TTS_VOICE_RESOLUTION_DEEPINFRA_FETCH_FAILED', errorClass: 'upstream' }, + }); return []; } finally { clearTimeout(timeoutId); diff --git a/src/lib/server/user/data-cleanup.ts b/src/lib/server/user/data-cleanup.ts index 54aa488..43f18b2 100644 --- a/src/lib/server/user/data-cleanup.ts +++ b/src/lib/server/user/data-cleanup.ts @@ -13,7 +13,8 @@ import { deleteDocumentPreviewArtifacts } from '@/lib/server/documents/previews- import { deleteDocumentPreviewRows } from '@/lib/server/documents/previews'; import { audiobookPrefix, deleteAudiobookPrefix } from '@/lib/server/audiobooks/blobstore'; import { deleteTtsSegmentPrefix } from '@/lib/server/tts/segments-blobstore'; -import { errorToLog, hashForLog, serverLogger } from '@/lib/server/logger'; +import { hashForLog, serverLogger } from '@/lib/server/logger'; +import { logDegraded } from '@/lib/server/errors/logging'; type DocumentRow = { id: string }; type AudiobookRow = { id: string }; @@ -50,27 +51,31 @@ export async function deleteUserStorageData( await deleteDocumentBlob(doc.id, namespace); docsDeleted++; } catch (error) { - serverLogger.warn({ + logDegraded(serverLogger, { event: 'user.data_cleanup.document_blob_delete.failed', - degraded: true, + msg: 'Failed to delete document blob', step: 'delete_document_blob', - documentId: doc.id, - userIdHash: hashForLog(userId), - error: errorToLog(error), - }, 'Failed to delete document blob'); + context: { + documentId: doc.id, + userIdHash: hashForLog(userId), + }, + error, + }); } try { await deleteDocumentPreviewArtifacts(doc.id, namespace); } catch (error) { - serverLogger.warn({ + logDegraded(serverLogger, { event: 'user.data_cleanup.document_preview_delete.failed', - degraded: true, + msg: 'Failed to delete preview artifacts', step: 'delete_document_preview_artifacts', - documentId: doc.id, - userIdHash: hashForLog(userId), - error: errorToLog(error), - }, 'Failed to delete preview artifacts'); + context: { + documentId: doc.id, + userIdHash: hashForLog(userId), + }, + error, + }); } } @@ -78,14 +83,16 @@ export async function deleteUserStorageData( try { await deleteDocumentPreviewRows(doc.id, namespace); } catch (error) { - serverLogger.warn({ + logDegraded(serverLogger, { event: 'user.data_cleanup.document_preview_rows_delete.failed', - degraded: true, + msg: 'Failed to delete preview rows', step: 'delete_document_preview_rows', - documentId: doc.id, - userIdHash: hashForLog(userId), - error: errorToLog(error), - }, 'Failed to delete preview rows'); + context: { + documentId: doc.id, + userIdHash: hashForLog(userId), + }, + error, + }); } } @@ -104,14 +111,16 @@ export async function deleteUserStorageData( await deleteAudiobookPrefix(prefix); booksDeleted++; } catch (error) { - serverLogger.warn({ + logDegraded(serverLogger, { event: 'user.data_cleanup.audiobook_blobs_delete.failed', - degraded: true, + msg: 'Failed to delete audiobook blobs', step: 'delete_audiobook_prefix', - bookId: book.id, - userIdHash: hashForLog(userId), - error: errorToLog(error), - }, 'Failed to delete audiobook blobs'); + context: { + bookId: book.id, + userIdHash: hashForLog(userId), + }, + error, + }); } } @@ -126,13 +135,13 @@ export async function deleteUserStorageData( segmentsDeleted += await deleteTtsSegmentPrefix(ttsPrefixV1); segmentsDeleted += await deleteTtsSegmentPrefix(ttsPrefixV2); } catch (error) { - serverLogger.warn({ + logDegraded(serverLogger, { event: 'user.data_cleanup.tts_segments_delete.failed', - degraded: true, + msg: 'Failed to delete TTS segment blobs', step: 'delete_tts_segment_prefixes', - userIdHash: hashForLog(userId), - error: errorToLog(error), - }, 'Failed to delete TTS segment blobs'); + context: { userIdHash: hashForLog(userId) }, + error, + }); } } diff --git a/tests/unit/server-route-error-mappings.spec.ts b/tests/unit/server-route-error-mappings.spec.ts new file mode 100644 index 0000000..ac1a131 --- /dev/null +++ b/tests/unit/server-route-error-mappings.spec.ts @@ -0,0 +1,78 @@ +import { expect, test } from '@playwright/test'; +import { errorResponse } from '../../src/lib/server/errors/next-response'; +import { AdminProviderError } from '../../src/lib/server/admin/providers'; + +test.describe('route error mapping contract', () => { + test('admin provider validation errors map to 4xx via route normalize policy', async () => { + const adminError = new AdminProviderError('slug is required', 400); + const response = errorResponse(adminError, { + apiErrorMessage: adminError.message, + normalize: { + code: 'ADMIN_PROVIDERS_CREATE_REQUEST_FAILED', + errorClass: 'validation', + httpStatus: adminError.status, + retryable: false, + }, + }); + + expect(response.status).toBe(400); + await expect(response.json()).resolves.toEqual({ + error: 'slug is required', + errorCode: 'ADMIN_PROVIDERS_CREATE_REQUEST_FAILED', + retryable: false, + }); + }); + + test('documents storage failures map to retryable 503', async () => { + const response = errorResponse(new Error('blobstore timeout'), { + apiErrorMessage: 'Failed to fetch document blob', + normalize: { + code: 'DOCUMENT_BLOB_FETCH_FAILED', + errorClass: 'storage', + }, + }); + + expect(response.status).toBe(503); + await expect(response.json()).resolves.toEqual({ + error: 'Failed to fetch document blob', + errorCode: 'DOCUMENT_BLOB_FETCH_FAILED', + retryable: true, + }); + }); + + test('audiobook upstream failures map to retryable 502', async () => { + const response = errorResponse(new Error('provider overloaded'), { + apiErrorMessage: 'Failed to process audio chapter', + normalize: { + code: 'AUDIOBOOK_CHAPTER_PROCESS_FAILED', + errorClass: 'upstream', + }, + }); + + expect(response.status).toBe(502); + await expect(response.json()).resolves.toEqual({ + error: 'Failed to process audio chapter', + errorCode: 'AUDIOBOOK_CHAPTER_PROCESS_FAILED', + retryable: true, + }); + }); + + test('user export auth initialization failure maps to 500 auth classification', async () => { + const response = errorResponse(new Error('Auth not initialized'), { + apiErrorMessage: 'Auth not initialized', + normalize: { + code: 'USER_EXPORT_AUTH_NOT_INITIALIZED', + errorClass: 'auth', + httpStatus: 500, + retryable: false, + }, + }); + + expect(response.status).toBe(500); + await expect(response.json()).resolves.toEqual({ + error: 'Auth not initialized', + errorCode: 'USER_EXPORT_AUTH_NOT_INITIALIZED', + retryable: false, + }); + }); +});