refactor(http): unify API error model

This commit is contained in:
digua committed 2026-07-16 20:09:18 +08:00
1 parent a14de9fc10
commit f949c7a8f9
15 files changed
+111 -201

No files matched your search

+2 -2
View File
@@ -5,14 +5,14 @@
*/ */
import Fastify, { type FastifyInstance, type FastifyError } from 'fastify' import Fastify, { type FastifyInstance, type FastifyError } from 'fastify'
import { authHook } from '@openchatlab/http-routes/auth'
import { import {
authHook,
ApiError, ApiError,
ApiErrorCode, ApiErrorCode,
apiErrorFromUnknown, apiErrorFromUnknown,
errorResponse, errorResponse,
serverError, serverError,
} from '@openchatlab/http-routes' } from '@openchatlab/http-routes/errors'
import { appLogger } from '@openchatlab/node-runtime' import { appLogger } from '@openchatlab/node-runtime'
const JSON_BODY_LIMIT = 50 * 1024 * 1024 // 50MB const JSON_BODY_LIMIT = 50 * 1024 * 1024 // 50MB
+1 -1
View File
@@ -4,8 +4,8 @@
import type { FastifyRequest, FastifyReply } from 'fastify' import type { FastifyRequest, FastifyReply } from 'fastify'
import { timingSafeEqual } from 'crypto' import { timingSafeEqual } from 'crypto'
import { unauthorized, errorResponse } from '@openchatlab/http-routes/errors'
import { getConfig } from './index' import { getConfig } from './index'
import { unauthorized, errorResponse } from './errors'
function safeTokenCompare(a: string, b: string): boolean { function safeTokenCompare(a: string, b: string): boolean {
const bufA = Buffer.from(a) const bufA = Buffer.from(a)
-31
View File
@@ -1,31 +0,0 @@
import assert from 'node:assert/strict'
import test from 'node:test'
import { DataDirCompatibilityError } from '@openchatlab/node-runtime/src/data-dir-compat'
import { ApiErrorCode, apiErrorFromUnknown } from './errors'
import { ImportInProgressError } from '@openchatlab/node-runtime/src/import/import-lock'
test('apiErrorFromUnknown maps the shared import lock error to 409', () => {
const apiError = apiErrorFromUnknown(new ImportInProgressError())
assert.equal(apiError?.code, ApiErrorCode.IMPORT_IN_PROGRESS)
assert.equal(apiError?.statusCode, 409)
})
test('apiErrorFromUnknown maps wrapped data directory compatibility errors to 409', () => {
const cause = new DataDirCompatibilityError(
'DATA_DIR_REQUIRES_NEWER_RUNTIME',
'ChatLab data directory requires runtime version 0.25.1 or newer; current version is 0.25.0.',
{
userDataDir: '/tmp/chatlab-data',
metaPath: '/tmp/chatlab-data/.chatlab-meta.json',
currentVersion: '0.25.0',
minRuntimeVersion: '0.25.1',
}
)
const apiError = apiErrorFromUnknown(new Error('Desktop startup formatted message', { cause }))
assert.equal(apiError?.code, ApiErrorCode.DATA_DIR_INCOMPATIBLE)
assert.equal(apiError?.statusCode, 409)
assert.match(apiError?.message ?? '', /requires runtime version 0\.25\.1/)
})
-146
View File
@@ -1,146 +0,0 @@
/**
* ChatLab API — Error codes and factory functions
*/
import { DataDirCompatibilityError } from '@openchatlab/node-runtime/src/data-dir-compat'
import { ImportInProgressError } from '@openchatlab/node-runtime/src/import/import-lock'
export enum ApiErrorCode {
UNAUTHORIZED = 'UNAUTHORIZED',
SESSION_NOT_FOUND = 'SESSION_NOT_FOUND',
INVALID_FORMAT = 'INVALID_FORMAT',
INVALID_PAYLOAD = 'INVALID_PAYLOAD',
SQL_READONLY_VIOLATION = 'SQL_READONLY_VIOLATION',
SQL_EXECUTION_ERROR = 'SQL_EXECUTION_ERROR',
EXPORT_TOO_LARGE = 'EXPORT_TOO_LARGE',
BODY_TOO_LARGE = 'BODY_TOO_LARGE',
IMPORT_IN_PROGRESS = 'IMPORT_IN_PROGRESS',
IDEMPOTENCY_CONFLICT = 'IDEMPOTENCY_CONFLICT',
IDEMPOTENCY_PENDING = 'IDEMPOTENCY_PENDING',
IMPORT_FAILED = 'IMPORT_FAILED',
DATA_DIR_INCOMPATIBLE = 'DATA_DIR_INCOMPATIBLE',
SERVER_ERROR = 'SERVER_ERROR',
}
const HTTP_STATUS: Record<ApiErrorCode, number> = {
[ApiErrorCode.UNAUTHORIZED]: 401,
[ApiErrorCode.SESSION_NOT_FOUND]: 404,
[ApiErrorCode.INVALID_FORMAT]: 400,
[ApiErrorCode.INVALID_PAYLOAD]: 400,
[ApiErrorCode.SQL_READONLY_VIOLATION]: 400,
[ApiErrorCode.SQL_EXECUTION_ERROR]: 400,
[ApiErrorCode.EXPORT_TOO_LARGE]: 400,
[ApiErrorCode.BODY_TOO_LARGE]: 413,
[ApiErrorCode.IMPORT_IN_PROGRESS]: 409,
[ApiErrorCode.IDEMPOTENCY_CONFLICT]: 409,
[ApiErrorCode.IDEMPOTENCY_PENDING]: 409,
[ApiErrorCode.IMPORT_FAILED]: 500,
[ApiErrorCode.DATA_DIR_INCOMPATIBLE]: 409,
[ApiErrorCode.SERVER_ERROR]: 500,
}
export class ApiError extends Error {
code: ApiErrorCode
statusCode: number
constructor(code: ApiErrorCode, message: string) {
super(message)
this.name = 'ApiError'
this.code = code
this.statusCode = HTTP_STATUS[code]
}
}
export function unauthorized(message = 'Invalid or missing token'): ApiError {
return new ApiError(ApiErrorCode.UNAUTHORIZED, message)
}
export function sessionNotFound(id: string): ApiError {
return new ApiError(ApiErrorCode.SESSION_NOT_FOUND, `Session not found: ${id}`)
}
export function invalidFormat(message: string): ApiError {
return new ApiError(ApiErrorCode.INVALID_FORMAT, message)
}
export function invalidPayload(message: string): ApiError {
return new ApiError(ApiErrorCode.INVALID_PAYLOAD, message)
}
export function idempotencyConflict(): ApiError {
return new ApiError(ApiErrorCode.IDEMPOTENCY_CONFLICT, 'Same Idempotency-Key with different request body hash')
}
export function idempotencyPending(): ApiError {
return new ApiError(
ApiErrorCode.IDEMPOTENCY_PENDING,
'A request with this Idempotency-Key is still in progress. Please retry later.'
)
}
export function sqlReadonlyViolation(): ApiError {
return new ApiError(ApiErrorCode.SQL_READONLY_VIOLATION, 'Only SELECT queries are allowed')
}
export function sqlExecutionError(message: string): ApiError {
return new ApiError(ApiErrorCode.SQL_EXECUTION_ERROR, message)
}
export function exportTooLarge(count: number, limit: number): ApiError {
return new ApiError(
ApiErrorCode.EXPORT_TOO_LARGE,
`Message count ${count} exceeds export limit ${limit}. Use paginated /messages API instead.`
)
}
export function importInProgress(): ApiError {
return new ApiError(ApiErrorCode.IMPORT_IN_PROGRESS, 'An import operation is already in progress')
}
export function importFailed(message: string): ApiError {
return new ApiError(ApiErrorCode.IMPORT_FAILED, message)
}
export function dataDirIncompatible(message: string): ApiError {
return new ApiError(ApiErrorCode.DATA_DIR_INCOMPATIBLE, message)
}
export function serverError(message = 'Internal server error'): ApiError {
return new ApiError(ApiErrorCode.SERVER_ERROR, message)
}
export function apiErrorFromUnknown(error: unknown): ApiError | null {
if (error instanceof ApiError) return error
if (error instanceof ImportInProgressError) return importInProgress()
const compatibilityError = findDataDirCompatibilityError(error)
if (compatibilityError) return dataDirIncompatible(compatibilityError.message)
return null
}
function findDataDirCompatibilityError(error: unknown): DataDirCompatibilityError | null {
if (error instanceof DataDirCompatibilityError) return error
if (error instanceof Error && error.cause) return findDataDirCompatibilityError(error.cause)
return null
}
export function successResponse<T>(data: T, meta?: Record<string, unknown>) {
return {
success: true as const,
data,
meta: {
timestamp: Math.floor(Date.now() / 1000),
version: '0.0.2',
...meta,
},
}
}
export function errorResponse(error: ApiError) {
return {
success: false as const,
error: {
code: error.code,
message: error.message,
},
}
}
@@ -1,7 +1,7 @@
import assert from 'node:assert/strict' import assert from 'node:assert/strict'
import test from 'node:test' import test from 'node:test'
import { IMPORT_IN_PROGRESS_ERROR_KEY } from '@openchatlab/node-runtime/src/import/import-lock' import { IMPORT_IN_PROGRESS_ERROR_KEY } from '@openchatlab/node-runtime/src/import/import-lock'
import { ApiErrorCode } from '../errors' import { ApiErrorCode } from '@openchatlab/http-routes/errors'
import { analysisFromNewImport, analysisFromPushImport, apiErrorFromImportResult } from './import-helpers' import { analysisFromNewImport, analysisFromPushImport, apiErrorFromImportResult } from './import-helpers'
test('maps an import lock result to the documented 409 API error', () => { test('maps an import lock result to the documented 409 API error', () => {
@@ -9,6 +9,7 @@ test('maps an import lock result to the documented 409 API error', () => {
assert.equal(error.code, ApiErrorCode.IMPORT_IN_PROGRESS) assert.equal(error.code, ApiErrorCode.IMPORT_IN_PROGRESS)
assert.equal(error.statusCode, 409) assert.equal(error.statusCode, 409)
assert.equal(error.message, 'An import operation is already in progress')
}) })
test('keeps ordinary import failures as IMPORT_FAILED', () => { test('keeps ordinary import failures as IMPORT_FAILED', () => {
+10 -2
View File
@@ -1,10 +1,18 @@
import { IMPORT_IN_PROGRESS_ERROR_KEY } from '@openchatlab/node-runtime/src/import/import-lock' import { IMPORT_IN_PROGRESS_ERROR_KEY } from '@openchatlab/node-runtime/src/import/import-lock'
import type { PushImportAnalysisResult } from '@openchatlab/node-runtime/src/services/push-importer' import type { PushImportAnalysisResult } from '@openchatlab/node-runtime/src/services/push-importer'
import type { AnalyzeNewImportResult } from '@openchatlab/node-runtime/src/import/streaming-importer' import type { AnalyzeNewImportResult } from '@openchatlab/node-runtime/src/import/streaming-importer'
import { importFailed, importInProgress, type ApiError } from '../errors' import { importFailed, importInProgress, type ApiError } from '@openchatlab/http-routes/errors'
const DESKTOP_IMPORT_IN_PROGRESS_MESSAGE = 'An import operation is already in progress'
export function desktopImportInProgressError(): ApiError {
return importInProgress(DESKTOP_IMPORT_IN_PROGRESS_MESSAGE)
}
export function apiErrorFromImportResult(error: string | undefined, fallbackMessage: string): ApiError { export function apiErrorFromImportResult(error: string | undefined, fallbackMessage: string): ApiError {
return error === IMPORT_IN_PROGRESS_ERROR_KEY ? importInProgress() : importFailed(error || fallbackMessage) return error === IMPORT_IN_PROGRESS_ERROR_KEY
? desktopImportInProgressError()
: importFailed(error || fallbackMessage)
} }
export function analysisFromNewImport(result: AnalyzeNewImportResult): { export function analysisFromNewImport(result: AnalyzeNewImportResult): {
+12 -8
View File
@@ -20,23 +20,27 @@ import * as crypto from 'crypto'
import { pipeline } from 'stream/promises' import { pipeline } from 'stream/promises'
import { hashImportBody, ImportIdempotencyCache, isValidImportSessionId } from '@openchatlab/node-runtime' import { hashImportBody, ImportIdempotencyCache, isValidImportSessionId } from '@openchatlab/node-runtime'
import type { PushImportPayload } from '@openchatlab/node-runtime' import type { PushImportPayload } from '@openchatlab/node-runtime'
import { getTempDir } from '../../paths/locations'
import * as worker from '../../worker/workerManager'
import { import {
ApiError, ApiError,
ApiErrorCode, ApiErrorCode,
successResponse, successResponse,
apiErrorFromUnknown, apiErrorFromUnknown,
importInProgress,
importFailed, importFailed,
invalidFormat, invalidFormat,
invalidPayload, invalidPayload,
idempotencyConflict, idempotencyConflict,
idempotencyPending, idempotencyPending,
errorResponse, errorResponse,
} from '../errors' } from '@openchatlab/http-routes/errors'
import { getTempDir } from '../../paths/locations'
import * as worker from '../../worker/workerManager'
import { apiLogger } from '../logger' import { apiLogger } from '../logger'
import { analysisFromNewImport, analysisFromPushImport, apiErrorFromImportResult } from './import-helpers' import {
analysisFromNewImport,
analysisFromPushImport,
apiErrorFromImportResult,
desktopImportInProgressError,
} from './import-helpers'
// Tracks active External API requests; the shared data-directory lock enforces writer exclusion. // Tracks active External API requests; the shared data-directory lock enforces writer exclusion.
const isImporting = new Set<string>() const isImporting = new Set<string>()
@@ -188,7 +192,7 @@ async function handleUnifiedImport(request: FastifyRequest, reply: FastifyReply,
// IDEMPOTENCY_PENDING;只有本次新占位的 key 才会在锁冲突时释放。 // IDEMPOTENCY_PENDING;只有本次新占位的 key 才会在锁冲突时释放。
if (isImporting.has(sessionId)) { if (isImporting.has(sessionId)) {
idempotencyFail(cacheKey) idempotencyFail(cacheKey)
const err = importInProgress() const err = desktopImportInProgressError()
reply.code(err.statusCode).send(errorResponse(err)) reply.code(err.statusCode).send(errorResponse(err))
return return
} }
@@ -266,7 +270,7 @@ async function handleUnifiedImport(request: FastifyRequest, reply: FastifyReply,
idempotencyFail(cacheKey) idempotencyFail(cacheKey)
const err = const err =
outcome.reason === 'import_in_progress' outcome.reason === 'import_in_progress'
? importInProgress() ? desktopImportInProgressError()
: outcome.reason === 'invalid_payload' : outcome.reason === 'invalid_payload'
? invalidPayload(outcome.message) ? invalidPayload(outcome.message)
: importFailed(outcome.message) : importFailed(outcome.message)
@@ -341,7 +345,7 @@ async function handleUnifiedImport(request: FastifyRequest, reply: FastifyReply,
async function handleLegacyImport(request: FastifyRequest, reply: FastifyReply, sessionId?: string): Promise<void> { async function handleLegacyImport(request: FastifyRequest, reply: FastifyReply, sessionId?: string): Promise<void> {
const lockKey = sessionId ?? '__legacy__' const lockKey = sessionId ?? '__legacy__'
if (isImporting.has(lockKey)) { if (isImporting.has(lockKey)) {
const err = importInProgress() const err = desktopImportInProgressError()
reply.code(err.statusCode).send(errorResponse(err)) reply.code(err.statusCode).send(errorResponse(err))
return return
} }
+8 -1
View File
@@ -3,8 +3,15 @@
*/ */
import type { FastifyInstance } from 'fastify' import type { FastifyInstance } from 'fastify'
import {
successResponse,
sessionNotFound,
exportTooLarge,
sqlExecutionError,
ApiError,
errorResponse,
} from '@openchatlab/http-routes/errors'
import * as worker from '../../worker/workerManager' import * as worker from '../../worker/workerManager'
import { successResponse, sessionNotFound, exportTooLarge, sqlExecutionError, ApiError, errorResponse } from '../errors'
const EXPORT_MESSAGE_LIMIT = 100_000 const EXPORT_MESSAGE_LIMIT = 100_000
+1 -1
View File
@@ -6,7 +6,7 @@
import type { FastifyInstance } from 'fastify' import type { FastifyInstance } from 'fastify'
import { app } from 'electron' import { app } from 'electron'
import { successResponse } from '../errors' import { successResponse } from '@openchatlab/http-routes/errors'
import * as worker from '../../worker/workerManager' import * as worker from '../../worker/workerManager'
import { getDesktopAppVersion } from '../../runtime/compat' import { getDesktopAppVersion } from '../../runtime/compat'
+7 -1
View File
@@ -3,8 +3,14 @@
*/ */
import Fastify, { type FastifyInstance, type FastifyError } from 'fastify' import Fastify, { type FastifyInstance, type FastifyError } from 'fastify'
import {
ApiError,
ApiErrorCode,
apiErrorFromUnknown,
errorResponse,
serverError,
} from '@openchatlab/http-routes/errors'
import { authHook } from './auth' import { authHook } from './auth'
import { ApiError, ApiErrorCode, apiErrorFromUnknown, errorResponse, serverError } from './errors'
import { apiLogger } from './logger' import { apiLogger } from './logger'
const JSON_BODY_LIMIT = 50 * 1024 * 1024 // 50MB const JSON_BODY_LIMIT = 50 * 1024 * 1024 // 50MB
+7 -1
View File
@@ -1,5 +1,11 @@
import type { FastifyError, FastifyInstance, FastifyReply } from 'fastify' import type { FastifyError, FastifyInstance, FastifyReply } from 'fastify'
import { ApiError, ApiErrorCode, apiErrorFromUnknown, errorResponse, serverError } from '@openchatlab/http-routes' import {
ApiError,
ApiErrorCode,
apiErrorFromUnknown,
errorResponse,
serverError,
} from '@openchatlab/http-routes/errors'
import { appLogger } from '@openchatlab/node-runtime' import { appLogger } from '@openchatlab/node-runtime'
import { createInternalAuthHook } from './auth' import { createInternalAuthHook } from './auth'
+5
View File
@@ -6,6 +6,11 @@
"type": "module", "type": "module",
"main": "./src/index.ts", "main": "./src/index.ts",
"types": "./src/index.ts", "types": "./src/index.ts",
"exports": {
".": "./src/index.ts",
"./auth": "./src/auth.ts",
"./errors": "./src/errors.ts"
},
"dependencies": { "dependencies": {
"@openchatlab/config": "workspace:*", "@openchatlab/config": "workspace:*",
"@openchatlab/core": "workspace:*", "@openchatlab/core": "workspace:*",
+29 -2
View File
@@ -1,7 +1,7 @@
import assert from 'node:assert/strict' import assert from 'node:assert/strict'
import test from 'node:test' import test from 'node:test'
import { DataDirCompatibilityError } from '@openchatlab/node-runtime/src/data-dir-compat' import { DataDirCompatibilityError } from '@openchatlab/node-runtime/data-dir-compat'
import { ApiErrorCode, apiErrorFromUnknown } from './errors' import { ApiErrorCode, apiErrorFromUnknown, invalidFormat } from './errors'
import { ImportInProgressError } from '@openchatlab/node-runtime/src/import/import-lock' import { ImportInProgressError } from '@openchatlab/node-runtime/src/import/import-lock'
test('apiErrorFromUnknown maps the shared import lock error to 409', () => { test('apiErrorFromUnknown maps the shared import lock error to 409', () => {
@@ -29,3 +29,30 @@ test('apiErrorFromUnknown maps data directory compatibility errors to 409', () =
assert.equal(apiError?.statusCode, 409) assert.equal(apiError?.statusCode, 409)
assert.match(apiError?.message ?? '', /requires runtime version 0\.25\.1/) assert.match(apiError?.message ?? '', /requires runtime version 0\.25\.1/)
}) })
test('apiErrorFromUnknown maps wrapped data directory compatibility errors to 409', () => {
const cause = new DataDirCompatibilityError(
'DATA_DIR_REQUIRES_NEWER_RUNTIME',
'ChatLab data directory requires runtime version 0.25.1 or newer; current version is 0.25.0.',
{
userDataDir: '/tmp/chatlab-data',
metaPath: '/tmp/chatlab-data/.chatlab-meta.json',
currentVersion: '0.25.0',
minRuntimeVersion: '0.25.1',
}
)
const apiError = apiErrorFromUnknown(new Error('Platform-formatted message', { cause }))
assert.equal(apiError?.code, ApiErrorCode.DATA_DIR_INCOMPATIBLE)
assert.equal(apiError?.statusCode, 409)
assert.match(apiError?.message ?? '', /requires runtime version 0\.25\.1/)
})
test('invalidFormat creates the canonical INVALID_FORMAT response error', () => {
const error = invalidFormat('Content-Type must be application/json')
assert.equal(error.code, ApiErrorCode.INVALID_FORMAT)
assert.equal(error.statusCode, 400)
assert.equal(error.message, 'Content-Type must be application/json')
})
+22 -4
View File
@@ -4,7 +4,7 @@
* Platform-agnostic error handling shared by CLI Server and Electron Internal Server. * Platform-agnostic error handling shared by CLI Server and Electron Internal Server.
*/ */
import { DataDirCompatibilityError } from '@openchatlab/node-runtime/src/data-dir-compat' import { DataDirCompatibilityError } from '@openchatlab/node-runtime/data-dir-compat'
import { ImportInProgressError } from '@openchatlab/node-runtime/src/import/import-lock' import { ImportInProgressError } from '@openchatlab/node-runtime/src/import/import-lock'
export enum ApiErrorCode { export enum ApiErrorCode {
@@ -61,6 +61,10 @@ export function sessionNotFound(id: string): ApiError {
return new ApiError(ApiErrorCode.SESSION_NOT_FOUND, `Session not found: ${id}`) return new ApiError(ApiErrorCode.SESSION_NOT_FOUND, `Session not found: ${id}`)
} }
export function invalidFormat(message: string): ApiError {
return new ApiError(ApiErrorCode.INVALID_FORMAT, message)
}
export function invalidPayload(message: string): ApiError { export function invalidPayload(message: string): ApiError {
return new ApiError(ApiErrorCode.INVALID_PAYLOAD, message) return new ApiError(ApiErrorCode.INVALID_PAYLOAD, message)
} }
@@ -84,8 +88,8 @@ export function serverError(message = 'Internal server error'): ApiError {
return new ApiError(ApiErrorCode.SERVER_ERROR, message) return new ApiError(ApiErrorCode.SERVER_ERROR, message)
} }
export function importInProgress(): ApiError { export function importInProgress(message = 'Another import is already in progress'): ApiError {
return new ApiError(ApiErrorCode.IMPORT_IN_PROGRESS, 'Another import is already in progress') return new ApiError(ApiErrorCode.IMPORT_IN_PROGRESS, message)
} }
export function idempotencyConflict(): ApiError { export function idempotencyConflict(): ApiError {
@@ -110,7 +114,21 @@ export function dataDirIncompatible(message: string): ApiError {
export function apiErrorFromUnknown(error: unknown): ApiError | null { export function apiErrorFromUnknown(error: unknown): ApiError | null {
if (error instanceof ApiError) return error if (error instanceof ApiError) return error
if (error instanceof ImportInProgressError) return importInProgress() if (error instanceof ImportInProgressError) return importInProgress()
if (error instanceof DataDirCompatibilityError) return dataDirIncompatible(error.message) const compatibilityError = findDataDirCompatibilityError(error)
if (compatibilityError) return dataDirIncompatible(compatibilityError.message)
return null
}
function findDataDirCompatibilityError(error: unknown): DataDirCompatibilityError | null {
const seen = new Set<unknown>()
let current = error
while (current instanceof Error && !seen.has(current)) {
if (current instanceof DataDirCompatibilityError) return current
seen.add(current)
current = current.cause
}
return null return null
} }
+5
View File
@@ -14,11 +14,16 @@ export {
ApiErrorCode, ApiErrorCode,
unauthorized, unauthorized,
sessionNotFound, sessionNotFound,
invalidFormat,
invalidPayload, invalidPayload,
sqlReadonlyViolation, sqlReadonlyViolation,
sqlExecutionError, sqlExecutionError,
exportTooLarge, exportTooLarge,
serverError, serverError,
importInProgress,
idempotencyConflict,
idempotencyPending,
importFailed,
dataDirIncompatible, dataDirIncompatible,
apiErrorFromUnknown, apiErrorFromUnknown,
successResponse, successResponse,