From 9bccdf4b6823ed1b8e438acf9a7ee113170625fc Mon Sep 17 00:00:00 2001 From: qer Date: Tue, 14 Jul 2026 13:06:24 +0800 Subject: [PATCH 1/2] fix(kap-server): use HTTP error statuses for session export --- apps/kimi-web/test/daemon-client.test.ts | 2 +- .../kap-server/src/middleware/defineRoute.ts | 11 +++++-- .../kap-server/src/middleware/validate.ts | 22 ++++++++++--- packages/kap-server/src/openapi/transforms.ts | 14 +++++++-- .../kap-server/src/routes/sessionExport.ts | 25 +++++++++------ packages/kap-server/test/openapi.test.ts | 31 +++++++++++-------- packages/kap-server/test/sessions.test.ts | 14 +++++++-- 7 files changed, 85 insertions(+), 34 deletions(-) diff --git a/apps/kimi-web/test/daemon-client.test.ts b/apps/kimi-web/test/daemon-client.test.ts index 1d452ff54b..5af60fe2f8 100644 --- a/apps/kimi-web/test/daemon-client.test.ts +++ b/apps/kimi-web/test/daemon-client.test.ts @@ -102,7 +102,7 @@ describe('DaemonKimiWebApi.exportSession', () => { vi.mocked(fetch).mockResolvedValue( new Response( JSON.stringify({ code: 41301, msg: 'export too large', request_id: 'req_server' }), - { status: 200, headers: { 'content-type': 'application/json' } }, + { status: 413, headers: { 'content-type': 'application/json' } }, ), ); diff --git a/packages/kap-server/src/middleware/defineRoute.ts b/packages/kap-server/src/middleware/defineRoute.ts index 3918afe2d3..f4a552ba6a 100644 --- a/packages/kap-server/src/middleware/defineRoute.ts +++ b/packages/kap-server/src/middleware/defineRoute.ts @@ -161,6 +161,11 @@ export interface DefineRouteOptions< path: string; /** Request-body Zod schema. */ body?: TBody; + /** + * Optional HTTP status for route-level validation failures. Omitted routes + * retain the legacy 200 response with a business error envelope. + */ + validationErrorStatus?: number; /** Route-params Zod schema. */ params?: TParams; /** Query-string Zod schema. */ @@ -243,13 +248,13 @@ export function defineRoute< const preHandler: unknown[] = []; if (options.params) { - preHandler.push(validateParams(options.params)); + preHandler.push(validateParams(options.params, options.validationErrorStatus)); } if (options.body) { - preHandler.push(validateBody(options.body)); + preHandler.push(validateBody(options.body, options.validationErrorStatus)); } if (options.querystring) { - preHandler.push(validateQuery(options.querystring)); + preHandler.push(validateQuery(options.querystring, options.validationErrorStatus)); } // -- swagger schema -------------------------------------------------------- diff --git a/packages/kap-server/src/middleware/validate.ts b/packages/kap-server/src/middleware/validate.ts index 8439f36886..fbad8e15be 100644 --- a/packages/kap-server/src/middleware/validate.ts +++ b/packages/kap-server/src/middleware/validate.ts @@ -35,6 +35,7 @@ interface ValidationRequest { interface ValidationReply { send(payload: unknown): unknown; + code?: (statusCode: number) => unknown; } type PreHandlerHook = ( @@ -82,12 +83,17 @@ function buildValidationEnvelope( /** * Build a Fastify `preHandler` that parses `req.body` against `schema`. - * On success, replaces `req.body` with the parsed value. + * On success, replaces `req.body` with the parsed value. Existing routes keep + * the historical 200/envelope behavior when `errorStatusCode` is omitted. */ -export function validateBody(schema: z.ZodType): PreHandlerHook { +export function validateBody( + schema: z.ZodType, + errorStatusCode?: number, +): PreHandlerHook { return (req, reply, done) => { const result = schema.safeParse(req.body); if (!result.success) { + if (errorStatusCode !== undefined) reply.code?.(errorStatusCode); reply.send(buildValidationEnvelope(zodIssuesToDetails(result.error), req.id)); return; } @@ -104,10 +110,14 @@ export function validateBody(schema: z.ZodType): PreHandlerHook { * fields arrive as strings. The schema is responsible for coercing * (`z.coerce.number()` etc.) when needed; we don't pre-coerce here. */ -export function validateQuery(schema: z.ZodType): PreHandlerHook { +export function validateQuery( + schema: z.ZodType, + errorStatusCode?: number, +): PreHandlerHook { return (req, reply, done) => { const result = schema.safeParse(req.query); if (!result.success) { + if (errorStatusCode !== undefined) reply.code?.(errorStatusCode); reply.send(buildValidationEnvelope(zodIssuesToDetails(result.error), req.id)); return; } @@ -119,10 +129,14 @@ export function validateQuery(schema: z.ZodType): PreHandlerHook { /** * Build a Fastify `preHandler` that parses `req.params` against `schema`. */ -export function validateParams(schema: z.ZodType): PreHandlerHook { +export function validateParams( + schema: z.ZodType, + errorStatusCode?: number, +): PreHandlerHook { return (req, reply, done) => { const result = schema.safeParse(req.params); if (!result.success) { + if (errorStatusCode !== undefined) reply.code?.(errorStatusCode); reply.send(buildValidationEnvelope(zodIssuesToDetails(result.error), req.id)); return; } diff --git a/packages/kap-server/src/openapi/transforms.ts b/packages/kap-server/src/openapi/transforms.ts index 316d10dcdd..50b09e82f4 100644 --- a/packages/kap-server/src/openapi/transforms.ts +++ b/packages/kap-server/src/openapi/transforms.ts @@ -131,7 +131,7 @@ function patchSessionExport(paths: Record): void { if (operation === undefined) return; setResponse(operation, '200', { - description: 'Session export archive or JSON error envelope', + description: 'Session export archive', headers: { 'content-disposition': headerString(), 'content-length': headerInteger(), @@ -141,9 +141,19 @@ function patchSessionExport(paths: Record): void { 'application/zip': { schema: binarySchema, }, - ...jsonContent(errorEnvelopeSchema), }, }); + for (const [status, description] of [ + ['400', 'Invalid session export request'], + ['404', 'Session not found'], + ['413', 'Session export is too large'], + ['500', 'Session export failed'], + ] as const) { + setResponse(operation, status, { + description, + content: jsonContent(errorEnvelopeSchema), + }); + } } function patchFileUpload(paths: Record): void { diff --git a/packages/kap-server/src/routes/sessionExport.ts b/packages/kap-server/src/routes/sessionExport.ts index ea10a28329..8c3f9e8fb5 100644 --- a/packages/kap-server/src/routes/sessionExport.ts +++ b/packages/kap-server/src/routes/sessionExport.ts @@ -24,8 +24,10 @@ import { exportSessionParamsSchema, exportSessionRequestSchema, } from '@moonshot-ai/protocol'; +import { z } from 'zod'; import { defineRoute } from '../middleware/defineRoute'; +import { envelopeJsonSchema } from '../middleware/schema'; const MAX_WEB_SESSION_EXPORT_BYTES = 64 * 1024 * 1024; @@ -41,9 +43,12 @@ interface SessionExportReply { readonly raw: ServerResponse; type(mime: string): SessionExportReply; header(name: string, value: string | number): SessionExportReply; + code(statusCode: number): SessionExportReply; send(payload: unknown): unknown; } +const sessionExportErrorResponseSchema = envelopeJsonSchema(z.null()); + export function registerSessionExportRoute( app: SessionExportRouteHost, core: Scope, @@ -56,14 +61,13 @@ export function registerSessionExportRoute( path: '/sessions/{session_id}/export', params: exportSessionParamsSchema, body: exportSessionRequestSchema, + validationErrorStatus: 400, rawResponse: { 200: { type: 'string', format: 'binary' }, - }, - errors: { - [ErrorCode.VALIDATION_FAILED]: {}, - [ErrorCode.SESSION_NOT_FOUND]: {}, - [ErrorCode.FILE_TOO_LARGE]: {}, - [ErrorCode.INTERNAL_ERROR]: {}, + 400: sessionExportErrorResponseSchema, + 404: sessionExportErrorResponseSchema, + 413: sessionExportErrorResponseSchema, + 500: sessionExportErrorResponseSchema, }, description: 'Export a session and diagnostic logs as a zip archive', tags: ['sessions'], @@ -187,11 +191,14 @@ function sanitizeSessionId(sessionId: string): string { function sendMappedError(reply: SessionExportReply, requestId: string, error: unknown): void { if (isError2(error)) { if (error.code === ErrorCodes.SESSION_NOT_FOUND) { - reply.send(errEnvelope(ErrorCode.SESSION_NOT_FOUND, error.message, requestId)); + reply + .code(404) + .type('application/json') + .send(errEnvelope(ErrorCode.SESSION_NOT_FOUND, error.message, requestId)); return; } if (error.code === ErrorCodes.SESSION_EXPORT_TOO_LARGE) { - reply.send( + reply.code(413).type('application/json').send( errEnvelope( ErrorCode.FILE_TOO_LARGE, 'session export exceeds the 64 MiB web limit', @@ -201,7 +208,7 @@ function sendMappedError(reply: SessionExportReply, requestId: string, error: un return; } } - reply.send( + reply.code(500).type('application/json').send( errEnvelope( ErrorCode.INTERNAL_ERROR, error instanceof Error ? error.message : 'internal error', diff --git a/packages/kap-server/test/openapi.test.ts b/packages/kap-server/test/openapi.test.ts index 19bb2827a2..0f33bb7563 100644 --- a/packages/kap-server/test/openapi.test.ts +++ b/packages/kap-server/test/openapi.test.ts @@ -93,27 +93,32 @@ describe('server-v2 OpenAPI', () => { expect(content['multipart/form-data']).toBeDefined(); }); - it('describes session export as a ZIP or JSON error envelope', async () => { + it('describes session export as a ZIP with explicit JSON error responses', async () => { const doc = await fetchOpenApi(); const exportOp = operation(doc, '/api/v1/sessions/{session_id}/export', 'post'); const responses = asRecord(exportOp['responses']); const response = asRecord(responses['200']); const content = asRecord(response['content']); - const headers = asRecord(response['headers']); const zipSchema = asRecord(asRecord(content['application/zip'])['schema']); - const errorSchema = asRecord(asRecord(content['application/json'])['schema']); - const errorProperties = asRecord(errorSchema['properties']); expect(zipSchema).toMatchObject({ type: 'string', format: 'binary' }); - expect(errorProperties).toMatchObject({ - code: expect.any(Object), - msg: expect.any(Object), - data: expect.any(Object), - request_id: expect.any(Object), - }); - expect(headers['content-disposition']).toBeDefined(); - expect(headers['content-length']).toBeDefined(); - expect(headers['cache-control']).toBeDefined(); + expect(content['application/json']).toBeUndefined(); + expect(asRecord(response['headers'])['content-disposition']).toBeDefined(); + expect(asRecord(response['headers'])['content-length']).toBeDefined(); + expect(asRecord(response['headers'])['cache-control']).toBeDefined(); + + for (const status of ['400', '404', '413', '500']) { + const errorResponse = asRecord(responses[status]); + const errorContent = asRecord(errorResponse['content']); + const errorSchema = asRecord(asRecord(errorContent['application/json'])['schema']); + const errorProperties = asRecord(errorSchema['properties']); + expect(errorProperties).toMatchObject({ + code: expect.any(Object), + msg: expect.any(Object), + data: expect.any(Object), + request_id: expect.any(Object), + }); + } }); it('represents the fs-action dispatcher as a oneOf union', async () => { diff --git a/packages/kap-server/test/sessions.test.ts b/packages/kap-server/test/sessions.test.ts index 3b08e5704c..2f29b0f29b 100644 --- a/packages/kap-server/test/sessions.test.ts +++ b/packages/kap-server/test/sessions.test.ts @@ -147,7 +147,12 @@ describe('server-v2 /api/v1/sessions', () => { const id = 'sess_missing_export'; const { status, body } = await postJson(`/api/v1/sessions/${id}/export`, {}); - expect(status).toBe(200); + expect(status).toBe(404); + expect(body).toMatchObject({ + code: 40401, + data: null, + request_id: expect.any(String), + }); expect(body.code).toBe(40401); await expect.poll(() => listExportTempDirs(id)).toEqual([]); }); @@ -185,7 +190,12 @@ describe('server-v2 /api/v1/sessions', () => { { web_log: '你'.repeat(87_382) }, ); - expect(status).toBe(200); + expect(status).toBe(400); + expect(body).toMatchObject({ + code: 40001, + data: null, + request_id: expect.any(String), + }); expect(body.code).toBe(40001); expect(body.details?.[0]?.path).toBe('web_log'); }); From dc037123880c87417bdbf7f01fefad75994f50e7 Mon Sep 17 00:00:00 2001 From: qer Date: Tue, 14 Jul 2026 13:22:20 +0800 Subject: [PATCH 2/2] chore: add session export error changeset --- .changeset/session-export-http-errors.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/session-export-http-errors.md diff --git a/.changeset/session-export-http-errors.md b/.changeset/session-export-http-errors.md new file mode 100644 index 0000000000..70bff584ed --- /dev/null +++ b/.changeset/session-export-http-errors.md @@ -0,0 +1,5 @@ +--- +"@moonshot-ai/kimi-code": patch +--- + +web: Return explicit HTTP errors for failed session exports so the browser can parse error envelopes reliably.