refactor: complete phase 7 error-contract rollout
This commit is contained in:
parent
00ce39ed74
commit
5305b896c6
15 changed files with 431 additions and 189 deletions
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 };
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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}`);
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
60
src/lib/server/errors/README.md
Normal file
60
src/lib/server/errors/README.md
Normal 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`
|
||||
|
|
@ -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' },
|
||||
});
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
78
tests/unit/server-route-error-mappings.spec.ts
Normal file
78
tests/unit/server-route-error-mappings.spec.ts
Normal 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,
|
||||
});
|
||||
});
|
||||
});
|
||||
Loading…
Reference in a new issue