refactor: complete phase 7 error-contract rollout

This commit is contained in:
Richard R 2026-05-27 08:02:31 -06:00
parent 00ce39ed74
commit 5305b896c6
15 changed files with 431 additions and 189 deletions

View file

@ -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',

View file

@ -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',

View file

@ -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;
}
}

View file

@ -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<void> | null = null;
export async function ensureAdminSeed(): Promise<void> {
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<void> {
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<void> {
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<void> {
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<void> {
),
);
} 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<void> {
.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,
});
}
}

View file

@ -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<string | undefined>
.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<Map<string, { value: unknown; source: stri
}
return out;
} catch (error) {
serverLogger.warn({
logDegraded(serverLogger, {
event: 'admin.runtime_config.read.failed',
degraded: true,
error: errorToLog(error),
}, 'Runtime config read failed');
msg: 'Runtime config read failed',
step: 'read_runtime_config_rows',
error,
});
return new Map();
}
}
@ -306,12 +309,13 @@ export async function seedRuntimeConfigFromEnv(): Promise<{ seeded: RuntimeConfi
.onConflictDoNothing({ target: adminSettings.key });
seeded.push(key);
} catch (error) {
serverLogger.warn({
logDegraded(serverLogger, {
event: 'admin.runtime_config.seed.failed',
degraded: true,
key,
error: errorToLog(error),
}, 'Runtime config seed failed');
msg: 'Runtime config seed failed',
step: 'seed_runtime_config_key',
context: { key },
error,
});
}
}
return { seeded };

View file

@ -11,7 +11,8 @@ import { getResolvedRuntimeConfig } from '@/lib/server/runtime-config';
import { assertUserSignupAllowed } from '@/lib/server/auth/signup-policy';
import * as authSchemaSqlite from "@/db/schema_auth_sqlite";
import * as authSchemaPostgres from "@/db/schema_auth_postgres";
import { errorToLog, hashForLog, serverLogger } from '@/lib/server/logger';
import { hashForLog, serverLogger } from '@/lib/server/logger';
import { logDegraded, logServerError } from '@/lib/server/errors/logging';
// Heavy modules (S3 SDK, blobstore, rate-limiter, claim-data) are loaded
// lazily via dynamic import() inside the beforeDelete / onLinkAccount
@ -94,13 +95,13 @@ const createAuth = () => 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

View file

@ -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<string, unknown>): 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}`);

View file

@ -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);
}
}

View file

@ -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`

View file

@ -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<void>
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<void>
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' },
});
});
}

View file

@ -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;
}

View file

@ -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,
});
}
}

View file

@ -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<string[]> {
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);

View file

@ -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,
});
}
}

View file

@ -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,
});
});
});