Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/session-export-http-errors.md
Original file line number Diff line number Diff line change
@@ -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.
2 changes: 1 addition & 1 deletion apps/kimi-web/test/daemon-client.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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' } },
),
);

Expand Down
11 changes: 8 additions & 3 deletions packages/kap-server/src/middleware/defineRoute.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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. */
Expand Down Expand Up @@ -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 --------------------------------------------------------
Expand Down
22 changes: 18 additions & 4 deletions packages/kap-server/src/middleware/validate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ interface ValidationRequest {

interface ValidationReply {
send(payload: unknown): unknown;
code?: (statusCode: number) => unknown;
}

type PreHandlerHook = (
Expand Down Expand Up @@ -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<T>(schema: z.ZodType<T>): PreHandlerHook {
export function validateBody<T>(
schema: z.ZodType<T>,
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;
}
Expand All @@ -104,10 +110,14 @@ export function validateBody<T>(schema: z.ZodType<T>): 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<T>(schema: z.ZodType<T>): PreHandlerHook {
export function validateQuery<T>(
schema: z.ZodType<T>,
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;
}
Expand All @@ -119,10 +129,14 @@ export function validateQuery<T>(schema: z.ZodType<T>): PreHandlerHook {
/**
* Build a Fastify `preHandler` that parses `req.params` against `schema`.
*/
export function validateParams<T>(schema: z.ZodType<T>): PreHandlerHook {
export function validateParams<T>(
schema: z.ZodType<T>,
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;
}
Expand Down
14 changes: 12 additions & 2 deletions packages/kap-server/src/openapi/transforms.ts
Original file line number Diff line number Diff line change
Expand Up @@ -131,7 +131,7 @@ function patchSessionExport(paths: Record<string, unknown>): 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(),
Expand All @@ -141,9 +141,19 @@ function patchSessionExport(paths: Record<string, unknown>): 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<string, unknown>): void {
Expand Down
25 changes: 16 additions & 9 deletions packages/kap-server/src/routes/sessionExport.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand All @@ -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,
Expand All @@ -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'],
Expand Down Expand Up @@ -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',
Expand All @@ -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',
Expand Down
31 changes: 18 additions & 13 deletions packages/kap-server/test/openapi.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 () => {
Expand Down
14 changes: 12 additions & 2 deletions packages/kap-server/test/sessions.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -147,7 +147,12 @@ describe('server-v2 /api/v1/sessions', () => {
const id = 'sess_missing_export';
const { status, body } = await postJson<null>(`/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([]);
});
Expand Down Expand Up @@ -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');
});
Expand Down
Loading