fix: harden conversational rectification errors
This commit is contained in:
@@ -3,46 +3,55 @@ const errorDefinitions = {
|
||||
status: 400,
|
||||
error: "校正请求格式不正确",
|
||||
message: "请检查填写内容后再试。",
|
||||
retryable: false,
|
||||
},
|
||||
authentication_required: {
|
||||
status: 401,
|
||||
error: "请先登录",
|
||||
message: "登录后才能继续生时校正。",
|
||||
retryable: false,
|
||||
},
|
||||
case_not_found: {
|
||||
status: 404,
|
||||
error: "校正记录不存在",
|
||||
message: "请重新开始生时校正。",
|
||||
retryable: false,
|
||||
},
|
||||
stale_turn: {
|
||||
status: 409,
|
||||
error: "校正进度已更新",
|
||||
message: "请加载最新进度后再试。",
|
||||
retryable: true,
|
||||
},
|
||||
invalid_transition: {
|
||||
status: 409,
|
||||
error: "当前步骤不可用",
|
||||
message: "请加载最新进度后再试。",
|
||||
retryable: true,
|
||||
},
|
||||
candidate_changed: {
|
||||
status: 409,
|
||||
error: "候选结果已变化",
|
||||
message: "请查看最新候选结果后再确认。",
|
||||
retryable: true,
|
||||
},
|
||||
profile_incomplete: {
|
||||
status: 409,
|
||||
error: "出生资料尚未完成",
|
||||
message: "请先补全出生日期、时间和地点。",
|
||||
retryable: false,
|
||||
},
|
||||
insufficient_credits: {
|
||||
status: 409,
|
||||
error: "校正点数不足",
|
||||
message: "请补充点数后再开始校正。",
|
||||
retryable: false,
|
||||
},
|
||||
service_unavailable: {
|
||||
status: 503,
|
||||
error: "生时校正暂时不可用",
|
||||
message: "当前资料已安全保留,请稍后重试。",
|
||||
message: "服务暂时不可用,请稍后重试。",
|
||||
retryable: true,
|
||||
},
|
||||
} as const;
|
||||
|
||||
@@ -53,11 +62,23 @@ export type ConversationalRectificationPublicError = Readonly<{
|
||||
status: number;
|
||||
error: string;
|
||||
message: string;
|
||||
retryable: boolean;
|
||||
}>;
|
||||
|
||||
function createPublicError(code: ConversationalRectificationErrorCode): ConversationalRectificationPublicError {
|
||||
const definition = errorDefinitions[code];
|
||||
return Object.freeze({
|
||||
code,
|
||||
status: definition.status,
|
||||
error: definition.error,
|
||||
message: definition.message,
|
||||
retryable: definition.retryable,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* A domain error with a deliberately fixed public representation. The optional cause
|
||||
* is retained only for server-side logging and is never copied into the response.
|
||||
* A domain error with fixed, client-safe copy. Do not attach raw causes to this
|
||||
* browser-importable value; server code must log an unknown cause before mapping it.
|
||||
*/
|
||||
export class ConversationalRectificationError extends Error {
|
||||
readonly name = "ConversationalRectificationError";
|
||||
@@ -65,25 +86,26 @@ export class ConversationalRectificationError extends Error {
|
||||
readonly status: number;
|
||||
readonly public: ConversationalRectificationPublicError;
|
||||
|
||||
constructor(code: ConversationalRectificationErrorCode, options?: ErrorOptions) {
|
||||
constructor(code: ConversationalRectificationErrorCode) {
|
||||
const definition = errorDefinitions[code];
|
||||
super(definition.error, options);
|
||||
super(definition.error);
|
||||
this.code = code;
|
||||
this.status = definition.status;
|
||||
this.public = {
|
||||
code,
|
||||
status: definition.status,
|
||||
error: definition.error,
|
||||
message: definition.message,
|
||||
};
|
||||
this.public = createPublicError(code);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts unknown database, browser, and model failures to one safe recovery error.
|
||||
* The only error mapper intended for route responses. It returns a plain, frozen DTO
|
||||
* and never keeps the unknown input or any of its properties reachable.
|
||||
*/
|
||||
export function toConversationalRectificationError(error: unknown): ConversationalRectificationError {
|
||||
export function toConversationalRectificationPublicError(error: unknown): ConversationalRectificationPublicError {
|
||||
return error instanceof ConversationalRectificationError
|
||||
? error
|
||||
: new ConversationalRectificationError("service_unavailable", { cause: error });
|
||||
? error.public
|
||||
: createPublicError("service_unavailable");
|
||||
}
|
||||
|
||||
/** @deprecated Use toConversationalRectificationPublicError for route responses. */
|
||||
export function toConversationalRectificationError(error: unknown): ConversationalRectificationPublicError {
|
||||
return toConversationalRectificationPublicError(error);
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
import {
|
||||
ConversationalRectificationError,
|
||||
toConversationalRectificationError,
|
||||
toConversationalRectificationPublicError,
|
||||
} from "../src/lib/conversational-rectification/errors.ts";
|
||||
|
||||
const actionId = "a9890e09-d535-46f0-9a36-86017515a5a1";
|
||||
@@ -96,21 +97,52 @@ test("accepts only the exact public turn shape", () => {
|
||||
assert.equal(conversationalRectificationTurnSchema.safeParse({ ...turn, technicalReceipt: { ...turn.technicalReceipt, rawModelOutput: "secret" } }).success, false);
|
||||
});
|
||||
|
||||
test("maps known domain failures to stable Chinese recovery copy", () => {
|
||||
const stale = new ConversationalRectificationError("stale_turn");
|
||||
assert.deepEqual(stale.public, {
|
||||
function assertNoReachableText(value: unknown, forbidden: string, seen = new Set<unknown>()) {
|
||||
if (typeof value === "string") {
|
||||
assert.equal(value.includes(forbidden), false, `found raw text in ${value}`);
|
||||
return;
|
||||
}
|
||||
if (value === null || (typeof value !== "object" && typeof value !== "function") || seen.has(value)) return;
|
||||
|
||||
seen.add(value);
|
||||
for (const key of Reflect.ownKeys(value)) {
|
||||
assertNoReachableText(String(key), forbidden, seen);
|
||||
const descriptor = Object.getOwnPropertyDescriptor(value, key);
|
||||
if (descriptor && "value" in descriptor) assertNoReachableText(descriptor.value, forbidden, seen);
|
||||
}
|
||||
}
|
||||
|
||||
test("maps known domain failures to a frozen stable public DTO", () => {
|
||||
const stale = toConversationalRectificationPublicError(new ConversationalRectificationError("stale_turn"));
|
||||
assert.deepEqual(stale, {
|
||||
code: "stale_turn",
|
||||
status: 409,
|
||||
error: "校正进度已更新",
|
||||
message: "请加载最新进度后再试。",
|
||||
retryable: true,
|
||||
});
|
||||
assert.equal(Object.isFrozen(stale), true);
|
||||
assert.equal(Reflect.set(stale, "message", "mutated"), false);
|
||||
});
|
||||
|
||||
const recovered = toConversationalRectificationError(new Error("WebKit SyntaxError: SQL password=model secret"));
|
||||
assert.deepEqual(recovered.public, {
|
||||
test("maps unknown failures to a complete non-leaking public DTO", () => {
|
||||
const rawMessage = "WebKit SyntaxError: SQL password=model secret";
|
||||
const rawFailure = Object.assign(new Error(rawMessage, { cause: new Error(rawMessage) }), {
|
||||
browserError: rawMessage,
|
||||
modelResponse: { message: rawMessage },
|
||||
});
|
||||
const recovered = toConversationalRectificationPublicError(rawFailure);
|
||||
assert.deepEqual(recovered, {
|
||||
code: "service_unavailable",
|
||||
status: 503,
|
||||
error: "生时校正暂时不可用",
|
||||
message: "当前资料已安全保留,请稍后重试。",
|
||||
message: "服务暂时不可用,请稍后重试。",
|
||||
retryable: true,
|
||||
});
|
||||
assert.doesNotMatch(recovered.public.message, /WebKit|SQL|model|secret/i);
|
||||
assert.equal(Object.isFrozen(recovered), true);
|
||||
assertNoReachableText(recovered, rawMessage);
|
||||
assert.equal(JSON.stringify(recovered).includes(rawMessage), false);
|
||||
assert.equal(Reflect.set(recovered, "error", rawMessage), false);
|
||||
|
||||
assert.deepEqual(toConversationalRectificationError(rawFailure), recovered);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user