mirror of
https://github.com/hellodigua/ChatLab.git
synced 2026-10-25 17:54:34 +08:00
refactor(http): unify API error model
This commit is contained in:
1 parent
a14de9fc10
commit
f949c7a8f9
15 files changed
+111
-201
No files matched your search
@@ -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
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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/)
|
|
||||||
})
|
|
||||||
@@ -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', () => {
|
||||||
|
|||||||
@@ -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): {
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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'
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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'
|
||||||
|
|
||||||
|
|||||||
@@ -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:*",
|
||||||
|
|||||||
@@ -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')
|
||||||
|
})
|
||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
Reference in new issue
Block a user