fix(errors): unify backend error protocol and preserve db detail

This commit is contained in:
miracle 2026-08-05 17:25:21 +08:00 committed by GitHub
parent f1617bbd87
commit c8c40ba839
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
24 changed files with 1197 additions and 182 deletions

View File

@ -227,6 +227,13 @@ async fn worker_process_recovers_after_parser_error() {
.await
.expect("create events table");
let typed_err = client
.execute_typed(None, "select * from table limit 19;".to_string(), Some(10), None, Some(Duration::from_secs(5)))
.await
.expect_err("reserved word query should fail");
assert_eq!(typed_err.code, "duckdb_execute_failed");
assert!(typed_err.message.contains("Parser Error"), "unexpected error: {}", typed_err.message);
let err = client
.execute(None, "select * from table limit 19;".to_string(), Some(10), None, Some(Duration::from_secs(5)))
.await

View File

@ -156,7 +156,7 @@ async function save() {
await store.saveProfiles(cloneProfiles(draft.value));
toast(t("settings.tunnelsSaved"));
} catch (error) {
toast(t("settings.tunnelsSaveFailed", { message: translateBackendError(t, String(error)) }), 5000);
toast(t("settings.tunnelsSaveFailed", { message: translateBackendError(t, error) }), 5000);
} finally {
isSaving.value = false;
}
@ -175,7 +175,7 @@ async function testSelected() {
testResult.value = { ok: true, message: message ? t("settings.tunnelsTestSuccess") + ": " + message : t("settings.tunnelsTestSuccess") };
} catch (error) {
if (!testGuard.isCurrent(requestId, profile)) return;
testResult.value = { ok: false, message: t("settings.tunnelsTestFailed", { message: translateBackendError(t, String(error)) }) };
testResult.value = { ok: false, message: t("settings.tunnelsTestFailed", { message: translateBackendError(t, error) }) };
} finally {
if (testGuard.isCurrent(requestId, selectedSsh.value || selectedProxy.value)) isTesting.value = false;
}

View File

@ -3008,7 +3008,7 @@ async function aiTestConn() {
aiTestResult.value = "success";
} catch (e: any) {
aiTestResult.value = "error";
aiTestError.value = translateBackendError(t, e?.message || String(e));
aiTestError.value = translateBackendError(t, e);
} finally {
aiTesting.value = false;
}

View File

@ -65,7 +65,7 @@ async function refreshTree() {
try {
await connectionStore.refreshAllTree();
} catch (e: any) {
toast(t("connection.connectFailed", { message: translateBackendError(t, e?.message || String(e)) }), 5000);
toast(t("connection.connectFailed", { message: translateBackendError(t, e) }), 5000);
}
}

View File

@ -148,7 +148,7 @@ async function loadTopics(force = false) {
try {
await topicSelectRef.value?.loadTopics();
} catch (e: unknown) {
error.value = translateBackendError(t, formatError(e));
error.value = translateBackendError(t, e);
} finally {
topicsLoading.value = false;
}
@ -261,7 +261,7 @@ async function loadRuntimeClients() {
}
} catch (e: unknown) {
if (isRuntimeLoadCurrent(loadSeq, currentKey)) {
error.value = translateBackendError(t, formatError(e)) || String(e);
error.value = translateBackendError(t, e);
}
} finally {
if (loadSeq === runtimeLoadSeq) {

View File

@ -77,7 +77,7 @@ async function loadData() {
throw firstError;
}
} catch (e: any) {
toast(t("connection.connectFailed", { message: translateBackendError(t, e?.message || String(e)) }), 5000);
toast(t("connection.connectFailed", { message: translateBackendError(t, e) }), 5000);
} finally {
loading.value = false;
}
@ -100,7 +100,7 @@ async function installExtension(name: string) {
await loadData();
emit("changed");
} catch (e: any) {
toast(t("connection.connectFailed", { message: translateBackendError(t, e?.message || String(e)) }), 5000);
toast(t("connection.connectFailed", { message: translateBackendError(t, e) }), 5000);
} finally {
installing.value = null;
}
@ -122,7 +122,7 @@ async function dropExtension(name: string) {
await loadData();
emit("changed");
} catch (e: any) {
toast(t("connection.connectFailed", { message: translateBackendError(t, e?.message || String(e)) }), 5000);
toast(t("connection.connectFailed", { message: translateBackendError(t, e) }), 5000);
} finally {
dropping.value = null;
}

View File

@ -2136,7 +2136,7 @@ async function confirmPasteTable() {
await connectionStore.refreshObjectListTreeNode(props.connection.id, props.database, selectedSchema.value);
toast(t("contextMenu.pasteTableCancelledAfterPartial"), 5000);
} catch (e: any) {
toast(t("contextMenu.pasteTableRefreshFailed", { message: translateBackendError(t, e?.message || String(e)) }), 5000);
toast(t("contextMenu.pasteTableRefreshFailed", { message: translateBackendError(t, e) }), 5000);
}
}
return;

View File

@ -779,7 +779,7 @@ async function loadMoreObjectGroupChildren() {
try {
await connectionStore.loadMoreObjectGroupChildren(node);
} catch (e: any) {
toast(t("connection.connectFailed", { message: translateBackendError(t, e?.message || String(e)) }), 5000);
toast(t("connection.connectFailed", { message: translateBackendError(t, e) }), 5000);
}
}
@ -788,7 +788,7 @@ async function loadAllObjectGroupChildren() {
try {
await connectionStore.loadAllObjectGroupChildren(node);
} catch (e: any) {
toast(t("connection.connectFailed", { message: translateBackendError(t, e?.message || String(e)) }), 5000);
toast(t("connection.connectFailed", { message: translateBackendError(t, e) }), 5000);
}
}
@ -1150,7 +1150,7 @@ async function openObjectBrowser() {
await toggle();
}
} catch (e: any) {
toast(t("connection.connectFailed", { message: translateBackendError(t, e?.message || String(e)) }), 5000);
toast(t("connection.connectFailed", { message: translateBackendError(t, e) }), 5000);
openDriverStoreForInstallError(e?.message || String(e));
}
}
@ -1163,7 +1163,7 @@ async function openUserAdmin() {
connectionStore.activeConnectionId = node.connectionId;
queryStore.openUserAdmin(node.connectionId);
} catch (e: any) {
toast(t("connection.connectFailed", { message: translateBackendError(t, e?.message || String(e)) }), 5000);
toast(t("connection.connectFailed", { message: translateBackendError(t, e) }), 5000);
}
}
@ -1175,7 +1175,7 @@ async function openProcessList() {
connectionStore.activeConnectionId = node.connectionId;
queryStore.openProcessList(node.connectionId);
} catch (e: any) {
toast(t("connection.connectFailed", { message: translateBackendError(t, e?.message || String(e)) }), 5000);
toast(t("connection.connectFailed", { message: translateBackendError(t, e) }), 5000);
}
}
@ -1193,7 +1193,7 @@ async function openServerDashboard() {
queryStore.openMysqlDashboard(node.connectionId);
}
} catch (e: any) {
toast(t("connection.connectFailed", { message: translateBackendError(t, e?.message || String(e)) }), 5000);
toast(t("connection.connectFailed", { message: translateBackendError(t, e) }), 5000);
}
}
@ -1205,7 +1205,7 @@ async function openDamengJobAdmin() {
connectionStore.activeConnectionId = node.connectionId;
queryStore.openDamengJobAdmin(node.connectionId);
} catch (e: any) {
toast(t("connection.connectFailed", { message: translateBackendError(t, e?.message || String(e)) }), 5000);
toast(t("connection.connectFailed", { message: translateBackendError(t, e) }), 5000);
}
}
@ -1240,7 +1240,7 @@ async function newQuery() {
const options = await getDatabaseOptions(node.connectionId);
queryStore.createTab(node.connectionId, resolveDefaultDatabase(connection, options), undefined, "query");
} catch (e: any) {
toast(t("connection.connectFailed", { message: translateBackendError(t, e?.message || String(e)) }), 5000);
toast(t("connection.connectFailed", { message: translateBackendError(t, e) }), 5000);
openDriverStoreForInstallError(e?.message || String(e));
}
}
@ -1312,7 +1312,7 @@ async function newSelectTemplate() {
});
openSqlTemplateTab(context.node.connectionId!, context.node.database!, context.node.schema, context.node.catalog, sql);
} catch (e: any) {
toast(t("connection.connectFailed", { message: translateBackendError(t, e?.message || String(e)) }), 5000);
toast(t("connection.connectFailed", { message: translateBackendError(t, e) }), 5000);
}
}
@ -1332,7 +1332,7 @@ async function newInsertTemplate() {
});
openSqlTemplateTab(context.node.connectionId!, context.node.database!, context.node.schema, context.node.catalog, sql);
} catch (e: any) {
toast(t("connection.connectFailed", { message: translateBackendError(t, e?.message || String(e)) }), 5000);
toast(t("connection.connectFailed", { message: translateBackendError(t, e) }), 5000);
}
}
@ -1351,7 +1351,7 @@ async function newUpdateTemplate() {
});
openSqlTemplateTab(context.node.connectionId!, context.node.database!, context.node.schema, context.node.catalog, sql);
} catch (e: any) {
toast(t("connection.connectFailed", { message: translateBackendError(t, e?.message || String(e)) }), 5000);
toast(t("connection.connectFailed", { message: translateBackendError(t, e) }), 5000);
}
}
@ -1370,7 +1370,7 @@ async function newDeleteTemplate() {
});
openSqlTemplateTab(context.node.connectionId!, context.node.database!, context.node.schema, context.node.catalog, sql);
} catch (e: any) {
toast(t("connection.connectFailed", { message: translateBackendError(t, e?.message || String(e)) }), 5000);
toast(t("connection.connectFailed", { message: translateBackendError(t, e) }), 5000);
}
}
@ -1424,7 +1424,7 @@ async function refresh() {
try {
await connectionStore.refreshTreeNode(node);
} catch (e: any) {
toast(t("connection.connectFailed", { message: translateBackendError(t, e?.message || String(e)) }), 5000);
toast(t("connection.connectFailed", { message: translateBackendError(t, e) }), 5000);
openDriverStoreForInstallError(e?.message || String(e), node);
}
}
@ -2539,7 +2539,7 @@ async function confirmEditDatabaseProperties() {
showEditDatabasePropertiesDialog.value = false;
await connectionStore.loadDatabases(node.connectionId, { force: true });
} catch (e: any) {
toast(t("contextMenu.tableOperationFailed", { message: translateBackendError(t, e?.message || String(e)) }), 5000);
toast(t("contextMenu.tableOperationFailed", { message: translateBackendError(t, e) }), 5000);
} finally {
editDatabasePropertiesLoading.value = false;
}
@ -2611,7 +2611,7 @@ async function confirmEditSchemaComment() {
showEditSchemaCommentDialog.value = false;
await connectionStore.loadSchemas(node.connectionId, node.database, { force: true });
} catch (e: any) {
toast(t("contextMenu.tableOperationFailed", { message: translateBackendError(t, e?.message || String(e)) }), 5000);
toast(t("contextMenu.tableOperationFailed", { message: translateBackendError(t, e) }), 5000);
} finally {
schemaCommentLoading.value = false;
}

View File

@ -114,7 +114,7 @@ export function useSidebarConnectionMutationRuntime(options: SidebarConnectionMu
await copyToClipboard(String(port));
toast(t("contextMenu.finalProxyPortCopied", { port }), 2000);
} catch (error: any) {
toast(t("grid.copyFailed", { message: translateBackendError(t, error?.message || String(error)) }), 5000);
toast(t("grid.copyFailed", { message: translateBackendError(t, error) }), 5000);
}
}
@ -150,7 +150,7 @@ export function useSidebarConnectionMutationRuntime(options: SidebarConnectionMu
try {
await revealPathInFileManager(path);
} catch (error: any) {
toast(translateBackendError(t, typeof error === "string" ? error : error?.message || String(error)), 5000);
toast(translateBackendError(t, error), 5000);
}
}

View File

@ -294,7 +294,7 @@ export function useSidebarDatabaseSpecificMutationRuntime(options: SidebarDataba
if (liveNode) liveNode.isExpanded = true;
toast(t("nacos.namespaceCreated", { name: namespaceName }), 3000);
} catch (error: any) {
toast(t("contextMenu.tableOperationFailed", { message: translateBackendError(t, error?.message || String(error)) }), 5000);
toast(t("contextMenu.tableOperationFailed", { message: translateBackendError(t, error) }), 5000);
} finally {
createNacosNamespaceLoading.value = false;
}
@ -322,7 +322,7 @@ export function useSidebarDatabaseSpecificMutationRuntime(options: SidebarDataba
await connectionStore.loadNacosNamespaces(node.connectionId, { force: true });
toast(t("nacos.namespaceUpdated", { name: namespaceName }), 3000);
} catch (error: any) {
toast(t("contextMenu.tableOperationFailed", { message: translateBackendError(t, error?.message || String(error)) }), 5000);
toast(t("contextMenu.tableOperationFailed", { message: translateBackendError(t, error) }), 5000);
} finally {
editNacosNamespaceLoading.value = false;
}

View File

@ -240,6 +240,23 @@ describe("backend error translation", () => {
expect(translateBackendError(t, error)).toBe(`${t(error.messageKey)}\n\n${error.detail}`);
});
test("shows a native adapter code with the DuckDB detail", () => {
const t = translatorFor("en");
const error = {
version: 1,
code: "DBX-JDBC-4001",
messageKey: "backendErrors.jdbc.sqlFailed",
messageParams: { stage: "execute" },
source: "jdbcAgent",
operationOutcome: "unknown",
origin: { subsystem: "database", adapter: "native", driver: "duckdb" },
diagnostics: { category: "sql", stage: "execute", adapterCode: "duckdb_execute_failed" },
detail: "Catalog Error: Table missing_table does not exist",
} as const;
expect(translateBackendError(t, error)).toBe(`${t(error.messageKey, error.messageParams)}\n\n[duckdb_execute_failed] ${error.detail}`);
});
test("hides internal Agent error data from structured error details", () => {
const t = translatorFor("zh-CN");
const detail = 'driver: bad connection\nDBX_AGENT_ERROR_DATA:{"category":null,"agentSessionId":"session-1"}';
@ -262,7 +279,7 @@ describe("backend error translation", () => {
["non-finite params", { messageParams: { retryAfter: Number.POSITIVE_INFINITY } }],
["non-string detail", { detail: 42 }],
["object detail", { detail: { message: "database failure" } }],
["unknown source", { source: "http" }],
["non-string source", { source: 42 }],
["unknown outcome", { operationOutcome: "completed" }],
])("rejects malformed structured envelopes with %s", (_name, override) => {
expect(
@ -278,12 +295,102 @@ describe("backend error translation", () => {
).toBeNull();
});
test("accepts unknown compatibility sources and extensible origins", () => {
const error = normalizeBackendError({
version: 1,
code: "DBX-DB-4001",
messageKey: "backendErrors.jdbc.sqlFailed",
messageParams: { stage: "execute" },
source: "nativeDatabase",
operationOutcome: "unknown",
origin: { subsystem: "database", adapter: "native", driver: "postgresql" },
detail: "relation missing_table does not exist",
});
expect(error?.source).toBe("nativeDatabase");
expect(error?.origin?.driver).toBe("postgresql");
});
test("falls back to legacy text for plain HTTP and Tauri failures", () => {
const t = translatorFor("zh-CN");
const error = new BackendErrorException("legacy backend failure");
expect(error.backendError.code).toBe("DBX-LEGACY-0001");
expect(translateBackendError(t, error)).toBe(`${t("backendErrors.legacy")}\n\nlegacy backend failure`);
});
test("preserves JSON envelopes carried by strings and Error messages", () => {
const envelope = {
version: 1,
code: "DBX-JDBC-4001",
messageKey: "backendErrors.jdbc.sqlFailed",
messageParams: { stage: "execute" },
source: "jdbcAgent",
operationOutcome: "unknown",
detail: "relation missing_table does not exist",
} as const;
expect(normalizeBackendError(JSON.stringify(envelope))).toEqual(envelope);
expect(normalizeBackendError(new Error(JSON.stringify(envelope)))).toEqual(envelope);
});
test("retains bounded diagnostics from unknown rejection objects", () => {
const error = new BackendErrorException({ reason: "database worker returned a vendor diagnostic" });
expect(error.backendError.code).toBe("DBX-LEGACY-0001");
expect(error.backendError.detail).toBe("database worker returned a vendor diagnostic");
expect(new BackendErrorException({ reason: "x".repeat(70_000) }).backendError.detail).toHaveLength(64 * 1024);
});
test("normalizes structured errors across Error realms and module copies", () => {
const envelope = {
version: 1,
code: "DBX-JDBC-5001",
messageKey: "backendErrors.jdbc.protocolFailed",
messageParams: {},
source: "jdbcAgent" as const,
operationOutcome: "unknown" as const,
detail: "connection reset by peer",
};
const copiedError = Object.assign(new Error("Backend request failed"), {
name: "BackendErrorException",
backendError: envelope,
});
const workerError = { name: "BackendErrorException", message: JSON.stringify(envelope) };
expect(normalizeBackendError(copiedError)).toEqual(envelope);
expect(normalizeBackendError(workerError)).toEqual(envelope);
});
test("does not recurse forever through cyclic error wrappers", () => {
const self: Record<string, unknown> = {};
self.error = self;
expect(normalizeBackendError(self)).toBeNull();
expect(() => new BackendErrorException(self)).not.toThrow();
const first: Record<string, unknown> = {};
const second: Record<string, unknown> = {};
first.backendError = second;
second.error = first;
expect(normalizeBackendError(first)).toBeNull();
expect(() => new BackendErrorException(first)).not.toThrow();
});
test("stops at a finite wrapper depth", () => {
let wrapper: Record<string, unknown> = { error: { message: "deep legacy error" } };
for (let index = 0; index < 32; index += 1) wrapper = { backendError: wrapper };
expect(normalizeBackendError(wrapper)).toBeNull();
});
test("does not stringify a structured envelope as [object Object]", () => {
expect(
formatError({
version: 1,
code: "DBX-JDBC-5001",
messageKey: "backendErrors.jdbc.protocolFailed",
messageParams: {},
source: "jdbcAgent",
operationOutcome: "unknown",
}),
).toBe("DBX-JDBC-5001");
});
});
// Matching on message text only works while both sides agree on the wording, so

View File

@ -128,7 +128,10 @@ function translateStructuredBackendError(t: BackendErrorTranslate, error: Backen
const translated = t(error.messageKey, error.messageParams);
const summary = translated !== error.messageKey ? translated : t("backendErrors.unknown");
const detail = error.detail ? sanitizeBackendErrorMessage(error.detail).trim() : undefined;
return detail && detail !== summary ? `${summary}\n\n${detail}` : summary;
const rawAdapterCode = error.diagnostics?.adapterCode;
const adapterCode = typeof rawAdapterCode === "string" && /^[A-Za-z][A-Za-z0-9_.-]{0,63}$/.test(rawAdapterCode) ? rawAdapterCode : undefined;
const diagnosticDetail = detail && adapterCode ? `[${adapterCode}] ${detail}` : (detail ?? adapterCode);
return diagnosticDetail && diagnosticDetail !== summary ? `${summary}\n\n${diagnosticDetail}` : summary;
}
export function translateBackendError(t: BackendErrorTranslate, error: unknown): string {

View File

@ -0,0 +1,72 @@
import { describe, expect, test, vi } from "vitest";
import { backendResponseError, importAgentDriver, installJdbcPluginLocal } from "@/lib/backend/http";
import { BackendErrorException } from "@/lib/backend/errorUtils";
const envelope = {
version: 1,
code: "DBX-JDBC-4001",
messageKey: "backendErrors.jdbc.sqlFailed",
messageParams: { stage: "execute" },
source: "jdbcAgent",
operationOutcome: "unknown",
detail: "Incorrect syntax near SELECT",
} as const;
describe("HTTP backend error parsing", () => {
test.each([
["direct envelope", JSON.stringify(envelope), envelope],
["nested envelope", JSON.stringify({ error: envelope }), envelope],
["legacy text", "relation missing_table does not exist", undefined],
["malformed JSON text", "{not-json", undefined],
])("preserves %s body diagnostics", async (_name, body, expected) => {
const error = await backendResponseError(new Response(body, { status: 500 }));
if (expected) {
expect(error.backendError).toEqual(expected);
} else {
expect(error.backendError.code).toBe("DBX-LEGACY-0001");
expect(error.backendError.detail).toBe(body);
}
});
test("uses a stable summary for an empty body", async () => {
const error = await backendResponseError(new Response("", { status: 503 }));
expect(error.backendError.code).toBe("DBX-LEGACY-0001");
expect(error.backendError.detail).toBeUndefined();
expect(error.message).toBe("Backend request failed");
});
test("keeps a safe SQL diagnostic in a JSON envelope unchanged", async () => {
const error = await backendResponseError(new Response(JSON.stringify(envelope), { status: 400 }));
expect(error.backendError.detail).toBe("Incorrect syntax near SELECT");
});
test.each([
["JDBC plugin upload", () => installJdbcPluginLocal(new File(["plugin"], "plugin.zip"))],
["Agent driver upload", () => importAgentDriver("postgres", new File(["driver"], "driver.zip"))],
])("normalizes %s multipart failures through the nested backend envelope", async (_name, upload) => {
vi.stubGlobal("fetch", vi.fn().mockResolvedValue(new Response(JSON.stringify({ error: envelope }), { status: 400 })));
const error = await upload().catch((value: unknown) => value);
expect(error).toBeInstanceOf(BackendErrorException);
expect(error).toMatchObject({
backendError: expect.objectContaining({ code: envelope.code, detail: envelope.detail }),
});
vi.unstubAllGlobals();
});
test.each([
["JDBC plugin upload", () => installJdbcPluginLocal(new File(["plugin"], "plugin.zip"))],
["Agent driver upload", () => importAgentDriver("postgres", new File(["driver"], "driver.zip"))],
])("normalizes %s multipart failures through the direct backend envelope", async (_name, upload) => {
vi.stubGlobal("fetch", vi.fn().mockResolvedValue(new Response(JSON.stringify(envelope), { status: 400 })));
const error = await upload().catch((value: unknown) => value);
expect(error).toBeInstanceOf(BackendErrorException);
expect(error).toMatchObject({
backendError: expect.objectContaining({ code: envelope.code, detail: envelope.detail }),
});
vi.unstubAllGlobals();
});
});

View File

@ -9,13 +9,21 @@ export interface BackendError {
code: string;
messageKey: string;
messageParams: Record<string, BackendErrorParam>;
source: "jdbcAgent" | "jdbcAgentLegacy" | "legacyBackend";
/** Compatibility provenance. New callers should prefer origin metadata. */
source: string;
operationOutcome: "not_started" | "unknown";
origin?: {
subsystem: string;
adapter: string;
driver?: string;
};
detail?: string;
diagnostics?: Record<string, unknown>;
helpUrl?: string;
}
const MAX_FALLBACK_CHARS = 64 * 1024;
const MAX_ERROR_PARSE_DEPTH = 16;
const AGENT_RPC_ERROR_DATA_MARKER = "\nDBX_AGENT_ERROR_DATA:";
export function sanitizeBackendErrorMessage(message: string): string {
@ -45,25 +53,75 @@ function isBackendError(value: unknown): value is BackendError {
!candidate.messageParams ||
typeof candidate.messageParams !== "object" ||
Array.isArray(candidate.messageParams) ||
!["jdbcAgent", "jdbcAgentLegacy", "legacyBackend"].includes(String(candidate.source)) ||
typeof candidate.source !== "string" ||
candidate.source.length === 0 ||
candidate.source.length > 64 ||
!["not_started", "unknown"].includes(String(candidate.operationOutcome))
) {
return false;
}
if (candidate.origin !== undefined) {
const origin = candidate.origin;
const originRecord = origin as Record<string, unknown>;
if (
!origin ||
typeof origin !== "object" ||
Array.isArray(origin) ||
typeof originRecord.subsystem !== "string" ||
typeof originRecord.adapter !== "string" ||
originRecord.subsystem.length > 64 ||
originRecord.adapter.length > 64 ||
(originRecord.driver !== undefined && (typeof originRecord.driver !== "string" || originRecord.driver.length > 64))
) {
return false;
}
}
if (candidate.detail !== undefined && typeof candidate.detail !== "string") return false;
return Object.values(candidate.messageParams).every((param) => typeof param === "string" || typeof param === "boolean" || (typeof param === "number" && Number.isFinite(param)));
}
export function normalizeBackendError(error: unknown): BackendError | null {
if (error instanceof BackendErrorException) return error.backendError;
return normalizeBackendErrorAtDepth(error, new WeakSet<object>(), 0);
}
function normalizeBackendErrorAtDepth(error: unknown, seen: WeakSet<object>, depth: number): BackendError | null {
if (depth > MAX_ERROR_PARSE_DEPTH) return null;
if (error && typeof error === "object") {
if (seen.has(error)) return null;
seen.add(error);
if ("name" in error && error.name === "BackendErrorException" && "backendError" in error) {
const normalized = normalizeBackendErrorAtDepth((error as { backendError: unknown }).backendError, seen, depth + 1);
if (normalized) return normalized;
}
}
if (typeof error === "string") {
try {
return normalizeBackendErrorAtDepth(JSON.parse(error), seen, depth + 1);
} catch {
return null;
}
}
if (isBackendError(error)) return error;
if (error && typeof error === "object" && "backendError" in error) {
const backendError = (error as { backendError: unknown }).backendError;
if (isBackendError(backendError)) return backendError;
const normalized = normalizeBackendErrorAtDepth(backendError, seen, depth + 1);
if (normalized) return normalized;
}
if (error && typeof error === "object" && "error" in error) {
const nested = (error as { error: unknown }).error;
if (isBackendError(nested)) return nested;
const normalized = normalizeBackendErrorAtDepth(nested, seen, depth + 1);
if (normalized) return normalized;
}
if (error && typeof error === "object" && "message" in error && typeof error.message === "string") {
try {
const parsed: unknown = JSON.parse(error.message);
const normalized = normalizeBackendErrorAtDepth(parsed, seen, depth + 1);
if (normalized) return normalized;
} catch {
// Keep checking compatibility wrappers before falling back to plain text.
}
}
return null;
}
@ -73,7 +131,8 @@ export class BackendErrorException extends Error {
constructor(error: unknown) {
const backendError = normalizeRawBackendError(error);
const fallbackMessage = sanitizeBackendErrorMessage(typeof error === "string" ? error : error instanceof Error ? error.message : "Backend request failed");
const fallbackDetail = boundedFallbackText(error);
const fallbackMessage = sanitizeBackendErrorMessage(fallbackDetail ?? "Backend request failed");
super(backendError?.detail ? sanitizeBackendErrorMessage(backendError.detail) : fallbackMessage);
this.name = "BackendErrorException";
this.backendError = backendError ?? {
@ -83,23 +142,46 @@ export class BackendErrorException extends Error {
messageParams: {},
source: "legacyBackend",
operationOutcome: "unknown",
detail: fallbackMessage,
origin: { subsystem: "backend", adapter: "legacy" },
...(fallbackDetail ? { detail: sanitizeBackendErrorMessage(fallbackDetail) } : {}),
};
}
}
function normalizeRawBackendError(error: unknown): BackendError | null {
if (typeof error === "string") {
try {
const parsed: unknown = JSON.parse(error);
return normalizeBackendError(parsed);
} catch {
return null;
}
}
return normalizeBackendError(error);
}
function boundedFallbackText(error: unknown): string | undefined {
return boundedFallbackTextAtDepth(error, new WeakSet<object>(), 0);
}
function boundedFallbackTextAtDepth(error: unknown, seen: WeakSet<object>, depth: number): string | undefined {
if (depth > MAX_ERROR_PARSE_DEPTH) return undefined;
let text: string | undefined;
if (typeof error === "string") {
text = error;
} else if (error instanceof Error) {
text = error.message;
} else if (error && typeof error === "object") {
if (seen.has(error)) return undefined;
seen.add(error);
const candidate = error as Record<string, unknown>;
for (const key of ["message", "reason", "detail"]) {
if (typeof candidate[key] === "string") {
text = candidate[key];
break;
}
}
if (!text && "error" in candidate) text = boundedFallbackTextAtDepth(candidate.error, seen, depth + 1);
if (!text && "backendError" in candidate) text = boundedFallbackTextAtDepth(candidate.backendError, seen, depth + 1);
}
const normalized = text?.trim();
if (!normalized) return undefined;
return Array.from(normalized).slice(0, MAX_FALLBACK_CHARS).join("");
}
/**
* Formats an unknown error value into a human-readable string.
* Handles Error objects, strings, null/undefined, and other types.
@ -117,6 +199,7 @@ function normalizeRawBackendError(error: unknown): BackendError | null {
export function formatError(e: unknown): string {
const backendError = normalizeBackendError(e);
if (backendError?.detail) return sanitizeBackendErrorMessage(backendError.detail);
if (backendError) return backendError.code;
if (e instanceof Error) {
return sanitizeBackendErrorMessage(e.message);

View File

@ -251,7 +251,7 @@ async function put<T>(url: string, body: unknown): Promise<T> {
return res.json();
}
async function backendResponseError(response: Response): Promise<BackendErrorException> {
export async function backendResponseError(response: Response): Promise<BackendErrorException> {
const text = await response.text();
let payload: unknown = text;
try {
@ -287,7 +287,7 @@ export async function testConnectionWithInfo(config: ConnectionConfig): Promise<
if (response.status === 404) {
return normalizeConnectionTestResult(await testConnection(config), config);
}
if (!response.ok) throw new Error(await response.text());
if (!response.ok) throw await backendResponseError(response);
return normalizeConnectionTestResult(await response.json(), config);
}
@ -406,7 +406,7 @@ export async function importJdbcDrivers(pathsOrFiles: (string | File)[]): Promis
method: "POST",
body: formData,
});
if (!res.ok) throw new Error(await res.text());
if (!res.ok) throw await backendResponseError(res);
return res.json();
}
@ -455,7 +455,7 @@ export async function installJdbcPluginLocal(pathOrFile: string | File): Promise
method: "POST",
body: formData,
});
if (!uploadRes.ok) throw new Error(await uploadRes.text());
if (!uploadRes.ok) throw await backendResponseError(uploadRes);
return uploadRes.json();
}
@ -534,7 +534,7 @@ export async function importAgentsFromZip(fileOrPath: string | File, operationId
method: "POST",
body: formData,
});
if (!res.ok) throw new Error(await res.text());
if (!res.ok) throw await backendResponseError(res);
const result: { count: number } = await res.json();
return result.count;
}
@ -556,7 +556,7 @@ export async function importAgentDriver(dbType: string, pathOrFile: string | Fil
method: "POST",
body: formData,
});
if (!uploadRes.ok) throw new Error(await uploadRes.text());
if (!uploadRes.ok) throw await backendResponseError(uploadRes);
}
export const importAgentJar = importAgentDriver;
@ -1293,7 +1293,7 @@ export async function aiStream(sessionId: string, request: AiCompletionRequest,
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ session_id: sessionId, request }),
});
if (!res.ok) throw new Error(await res.text());
if (!res.ok) throw await backendResponseError(res);
const reader = res.body!.getReader();
const decoder = new TextDecoder();
@ -1389,7 +1389,7 @@ export async function aiAgentStream(
}),
signal,
});
if (!res.ok) throw new Error(await res.text());
if (!res.ok) throw await backendResponseError(res);
const reader = res.body!.getReader();
const decoder = new TextDecoder();
@ -1492,7 +1492,7 @@ export async function saveMcpGlobalPolicy(policy: Omit<McpGlobalPolicy, "configu
headers: { "Content-Type": "application/json" },
body: JSON.stringify(policy),
});
if (!res.ok) throw new Error(await res.text());
if (!res.ok) throw await backendResponseError(res);
}
export async function loadMaxAgentTurns(): Promise<number> {
@ -1505,7 +1505,7 @@ export async function saveMaxAgentTurns(maxAgentTurns: number): Promise<void> {
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ maxAgentTurns }),
});
if (!res.ok) throw new Error(await res.text());
if (!res.ok) throw await backendResponseError(res);
}
export async function loadMaxRetries(): Promise<number> {
@ -1518,7 +1518,7 @@ export async function saveMaxRetries(maxRetries: number): Promise<void> {
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ maxRetries }),
});
if (!res.ok) throw new Error(await res.text());
if (!res.ok) throw await backendResponseError(res);
}
export interface OpenTabsStatePayload {
@ -1820,7 +1820,7 @@ export async function previewSqlFile(fileOrPath: string | File): Promise<SqlFile
method: "POST",
body: formData,
});
if (!res.ok) throw new Error(await res.text());
if (!res.ok) throw await backendResponseError(res);
return res.json();
}
@ -1890,7 +1890,7 @@ export async function startTransfer(request: TransferRequest, onProgress: (progr
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ request }),
});
if (!res.ok) throw new Error(await res.text());
if (!res.ok) throw await backendResponseError(res);
// 2. SSE to listen for progress
return new Promise((resolve, reject) => {
@ -1952,7 +1952,7 @@ export async function previewTableImportFile(fileOrPath: string | File | TableIm
previewLimit: options.previewLimit,
}),
});
if (!res.ok) throw new Error(await res.text());
if (!res.ok) throw await backendResponseError(res);
return res.json();
}
const formData = new FormData();
@ -1964,7 +1964,7 @@ export async function previewTableImportFile(fileOrPath: string | File | TableIm
method: "POST",
body: formData,
});
if (!res.ok) throw new Error(await res.text());
if (!res.ok) throw await backendResponseError(res);
return res.json();
}
@ -1975,7 +1975,7 @@ export async function importTableFile(request: TableImportRequest, onProgress: (
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ request }),
});
if (!res.ok) throw new Error(await res.text());
if (!res.ok) throw await backendResponseError(res);
// 2. SSE to listen for progress
return new Promise((resolve, reject) => {
@ -2029,7 +2029,7 @@ export async function exportDatabaseSql(request: DatabaseExportRequest, onProgre
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ request }),
});
if (!res.ok) throw new Error(await res.text());
if (!res.ok) throw await backendResponseError(res);
// 2. SSE to listen for progress
return new Promise((resolve, reject) => {
@ -2691,7 +2691,7 @@ export async function nacosSearchConfigContent(connectionId: string, req: NacosC
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ connectionId, req }),
});
if (!response.ok) throw new Error(await response.text());
if (!response.ok) throw await backendResponseError(response);
if (!response.body) throw new Error("Nacos content search did not return a response stream");
const reader = response.body.getReader();
@ -2738,7 +2738,7 @@ export async function nacosExportConfigs(connectionId: string, selector: NacosCo
headers: { "content-type": "application/json" },
body: JSON.stringify({ connectionId, selector, fileName }),
});
if (!response.ok) throw new Error(await response.text());
if (!response.ok) throw await backendResponseError(response);
const blob = await response.blob();
const url = URL.createObjectURL(blob);
const anchor = document.createElement("a");
@ -2758,7 +2758,7 @@ export async function nacosPreviewConfigImport(connectionId: string, targetNames
method: "POST",
body: formData,
});
if (!response.ok) throw new Error(await response.text());
if (!response.ok) throw await backendResponseError(response);
return response.json();
}
@ -3039,7 +3039,7 @@ export async function documentDownloadGridFsFile(connectionId: string, database:
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ connectionId, database, bucket, fileId }),
});
if (!res.ok) throw new Error(await res.text());
if (!res.ok) throw await backendResponseError(res);
const data = (await res.json()) as number[];
return new Uint8Array(data);
}
@ -3058,7 +3058,7 @@ export async function documentUploadGridFsFile(connectionId: string, database: s
method: "POST",
body,
});
if (!res.ok) throw new Error(await res.text());
if (!res.ok) throw await backendResponseError(res);
return res.json();
}

View File

@ -3,6 +3,15 @@ import { BackendErrorException, type BackendError } from "@/lib/backend/errorUti
import { listen, type UnlistenFn } from "@tauri-apps/api/event";
import { normalizeRustMongoCommand, type MongoCommand } from "@/lib/mongo/mongoShellCommand";
import { ExternalSqlFileTooLargeError } from "@/lib/sql/sqlFileOpen";
/** Normalize Tauri rejections once at the public backend boundary. */
async function invokeBackend<T>(command: string, args?: Record<string, unknown>): Promise<T> {
try {
return await invoke<T>(command, args);
} catch (error) {
throw error instanceof BackendErrorException ? error : new BackendErrorException(error);
}
}
import type {
ConnectionConfig,
ConnectionTestResult,
@ -835,7 +844,7 @@ export async function setAiGlobalCustomInstructions(content: string): Promise<vo
}
export async function testConnection(config: ConnectionConfig): Promise<string> {
return invoke("test_connection", { config });
return invokeBackend("test_connection", { config });
}
export async function testConnectionWithInfo(config: ConnectionConfig): Promise<ConnectionTestResult> {
@ -851,31 +860,31 @@ export async function testConnectionWithInfo(config: ConnectionConfig): Promise<
}
export async function connectDb(config: ConnectionConfig, clientAttempt?: number): Promise<string> {
return invoke("connect_db", { config, clientAttempt });
return invokeBackend("connect_db", { config, clientAttempt });
}
export async function connectionDatabaseInfo(connectionId: string, database?: string): Promise<DatabaseConnectionInfo | undefined> {
const info = await invoke<DatabaseConnectionInfo | null>("connection_database_info", { connectionId, database });
const info = await invokeBackend<DatabaseConnectionInfo | null>("connection_database_info", { connectionId, database });
return info ?? undefined;
}
export async function saveConnectionDatabaseInfo(connectionId: string, databaseInfo: DatabaseConnectionInfo): Promise<void> {
return invoke("save_connection_database_info", {
return invokeBackend("save_connection_database_info", {
connectionId,
databaseInfo,
});
}
export async function connectionFinalProxyPort(config: ConnectionConfig): Promise<number> {
return invoke("connection_final_proxy_port", { config });
return invokeBackend("connection_final_proxy_port", { config });
}
export async function disconnectDb(connectionId: string, clientAttempt?: number): Promise<void> {
return invoke("disconnect_db", { connectionId, clientAttempt });
return invokeBackend("disconnect_db", { connectionId, clientAttempt });
}
export async function checkConnectionHealth(connectionId: string): Promise<void> {
return invoke("check_connection_health", { connectionId });
return invokeBackend("check_connection_health", { connectionId });
}
export async function connectionIdentifierQuote(connectionId: string, database?: string): Promise<string | undefined> {
@ -3405,7 +3414,7 @@ export async function startTransfer(request: TransferRequest, onProgress: (progr
await invoke("start_transfer", { request });
} catch (e) {
unlisten?.();
reject(e);
reject(e instanceof BackendErrorException ? e : new BackendErrorException(e));
}
})();
});
@ -3561,7 +3570,7 @@ export async function importTableFile(request: TableImportRequest, onProgress: (
return summary;
} catch (e) {
unlisten();
throw e;
throw e instanceof BackendErrorException ? e : new BackendErrorException(e);
}
}
@ -3707,7 +3716,7 @@ export async function startTableExport(request: TableExportRequest, onProgress:
onProgress(event.payload);
if (event.payload.status === "Done" || event.payload.status === "Error" || event.payload.status === "Cancelled") {
if (event.payload.status === "Error") {
finish(() => rejectTerminal(new Error(event.payload.errorMessage || "Export failed")));
finish(() => rejectTerminal(new BackendErrorException(event.payload.errorMessage || "Export failed")));
} else {
finish(() => resolveTerminal(event.payload));
}
@ -3720,7 +3729,7 @@ export async function startTableExport(request: TableExportRequest, onProgress:
settled = true;
unlisten?.();
}
throw error;
throw error instanceof BackendErrorException ? error : new BackendErrorException(error);
}
}
@ -3752,7 +3761,7 @@ export async function startQueryResultExport(request: QueryResultExportRequest,
onProgress(event.payload);
if (event.payload.status === "Done" || event.payload.status === "Error" || event.payload.status === "Cancelled") {
if (event.payload.status === "Error") {
finish(() => rejectTerminal(new Error(event.payload.errorMessage || "Export failed")));
finish(() => rejectTerminal(new BackendErrorException(event.payload.errorMessage || "Export failed")));
} else {
finish(() => resolveTerminal(event.payload));
}
@ -3765,7 +3774,7 @@ export async function startQueryResultExport(request: QueryResultExportRequest,
settled = true;
unlisten?.();
}
throw error;
throw error instanceof BackendErrorException ? error : new BackendErrorException(error);
}
}

View File

@ -39,4 +39,24 @@ describe("appendQueryResultSegment spatial merge", () => {
// Every row keeps its own SRID; a later page does not overwrite earlier cells.
expect(merged.spatial_values).toEqual([[4326], [3857], [3857], [4490]]);
});
it("raises the structured segment error instead of reconstructing from the Error row", () => {
const previous = make(1);
const segment = {
...make(0),
execution_error: true as const,
rows: [["legacy row text"]],
error: {
version: 1 as const,
code: "DBX-JDBC-4001",
messageKey: "backendErrors.jdbc.sqlFailed",
messageParams: { stage: "execute" },
source: "jdbcAgent" as const,
operationOutcome: "unknown" as const,
detail: "relation missing_table does not exist",
},
} as unknown as QueryResult;
expect(() => appendQueryResultSegment(previous, segment, 100)).toThrow("relation missing_table does not exist");
});
});

View File

@ -63,7 +63,7 @@ import { useSavedSqlStore } from "@/stores/savedSqlStore";
import { useExportTracker } from "@/composables/useExportTracker";
import { recordQueryCancellationLatency, resourceLifecycleDiagnostics } from "@/lib/diagnostics/resourceLifecycleDiagnostics";
import { appendDebugLog } from "@/lib/backend/debugLog";
import { formatError, normalizeBackendError, type BackendError } from "@/lib/backend/errorUtils";
import { BackendErrorException, formatError, normalizeBackendError, type BackendError } from "@/lib/backend/errorUtils";
import { createSavedSqlEditorPosition, initSavedSqlEditorPositions, restoreSavedSqlEditorPosition, saveSavedSqlEditorPosition } from "@/lib/app/savedSqlEditorPosition";
import { ensureSqlExtension } from "@/lib/savedSql/savedSqlFileName";
import { resolveSavedSqlExecutionTarget, savedSqlExecutionTargetFromTab, type SavedSqlExecutionTarget, type SavedSqlOpenTargetMode } from "@/lib/savedSql/savedSqlExecutionTarget";
@ -161,7 +161,9 @@ function exactTotalFromIncompletePage(result: QueryResult, pageLimit: number | u
}
export function appendQueryResultSegment(previous: QueryResult, segment: QueryResult, maxRows: number): QueryResult {
if (segment.execution_error) throw new Error(String(segment.rows[0]?.[0] ?? "Failed to load the next result segment"));
if (segment.execution_error) {
throw segment.error ? new BackendErrorException(segment.error) : new BackendErrorException(String(segment.rows[0]?.[0] ?? "Failed to load the next result segment"));
}
if (previous.columns.length !== segment.columns.length || previous.columns.some((column, index) => column !== segment.columns[index])) {
throw new Error("Result columns changed while loading the next segment");
}

View File

@ -1,4 +1,4 @@
//! Stable, safe backend error envelopes and the v1 JDBC Agent catalog.
//! Stable backend error envelopes and the v1 JDBC Agent catalog.
use std::collections::BTreeMap;
@ -9,9 +9,12 @@ use crate::db::agent_driver::{
AgentErrorStage, AgentOperationOutcome, AgentSessionDisposition,
};
const MAX_DETAIL_BYTES: usize = 512;
const MAX_DETAIL_BYTES: usize = 64 * 1024;
/// The origin of a backend error as exposed on the wire.
/// The v1 compatibility source of a backend error.
///
/// New code should use `origin` for subsystem/adapter information. This field
/// remains unchanged so older clients can continue to consume v1 envelopes.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub enum BackendErrorSource {
@ -20,6 +23,53 @@ pub enum BackendErrorSource {
LegacyBackend,
}
/// Extensible subsystem metadata for backend errors.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
#[serde(rename_all = "camelCase")]
pub enum BackendSubsystem {
Database,
Tunnel,
Extension,
Ai,
MessageQueue,
Backend,
}
/// The adapter that produced the error within its subsystem.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
#[serde(rename_all = "camelCase")]
pub enum BackendErrorAdapter {
JdbcAgent,
JdbcAgentLegacy,
Native,
Plugin,
Http,
Legacy,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct BackendErrorOrigin {
subsystem: BackendSubsystem,
adapter: BackendErrorAdapter,
#[serde(skip_serializing_if = "Option::is_none")]
driver: Option<&'static str>,
}
impl BackendErrorOrigin {
const fn database(adapter: BackendErrorAdapter) -> Self {
Self { subsystem: BackendSubsystem::Database, adapter, driver: None }
}
const fn database_driver(adapter: BackendErrorAdapter, driver: &'static str) -> Self {
Self { subsystem: BackendSubsystem::Database, adapter, driver: Some(driver) }
}
const fn backend() -> Self {
Self { subsystem: BackendSubsystem::Backend, adapter: BackendErrorAdapter::Legacy, driver: None }
}
}
/// Whether the operation definitely had not started or its result is unknown.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
@ -51,6 +101,8 @@ pub struct BackendErrorDiagnostics {
vendor_code: Option<i32>,
#[serde(skip_serializing_if = "Option::is_none")]
exception_class: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
adapter_code: Option<String>,
}
/// Public v1 backend error envelope.
@ -65,6 +117,7 @@ pub struct BackendError {
message_key: String,
message_params: BTreeMap<String, BackendMessageParam>,
source: BackendErrorSource,
origin: BackendErrorOrigin,
operation_outcome: BackendOperationOutcome,
#[serde(skip_serializing_if = "Option::is_none")]
detail: Option<String>,
@ -80,37 +133,51 @@ impl BackendError {
match error {
AgentCallError::Structured { message, context, .. } => {
let (entry, outcome) = structured_entry(context);
let detail = match context.category {
AgentErrorCategory::Sql => bounded_native_detail(message),
_ => bounded_detail(message),
};
Self::new(
entry,
BackendErrorSource::JdbcAgent,
BackendErrorOrigin::database(BackendErrorAdapter::JdbcAgent),
outcome,
stage_param(entry, context.stage),
safe_detail(message),
detail,
Some(diagnostics_from_context(context)),
)
}
AgentCallError::Legacy { message, .. } => Self::new(
catalog_entry(CatalogCode::JdbcLegacyFailure),
BackendErrorSource::JdbcAgentLegacy,
BackendOperationOutcome::Unknown,
BTreeMap::new(),
safe_detail(message),
None,
),
AgentCallError::Legacy { message, hints, .. } => {
let detail = match hints.category {
Some(AgentErrorCategory::Sql) => bounded_native_detail(message),
_ => bounded_detail(message),
};
Self::new(
catalog_entry(CatalogCode::JdbcLegacyFailure),
BackendErrorSource::JdbcAgentLegacy,
BackendErrorOrigin::database(BackendErrorAdapter::JdbcAgentLegacy),
BackendOperationOutcome::Unknown,
BTreeMap::new(),
detail,
None,
)
}
AgentCallError::ContractViolation { message, .. } => Self::new(
catalog_entry(CatalogCode::ContractInvalid),
BackendErrorSource::JdbcAgent,
BackendErrorOrigin::database(BackendErrorAdapter::JdbcAgent),
BackendOperationOutcome::Unknown,
BTreeMap::new(),
safe_detail(message),
bounded_detail(message),
None,
),
AgentCallError::Transport { message } => Self::new(
catalog_entry(CatalogCode::ProtocolFailed),
BackendErrorSource::JdbcAgent,
BackendErrorOrigin::database(BackendErrorAdapter::JdbcAgent),
BackendOperationOutcome::Unknown,
BTreeMap::new(),
safe_detail(message),
bounded_detail(message),
None,
),
AgentCallError::Timeout { stage, operation_outcome } => {
@ -121,6 +188,7 @@ impl BackendError {
Self::new(
entry,
BackendErrorSource::JdbcAgent,
BackendErrorOrigin::database(BackendErrorAdapter::JdbcAgent),
map_outcome(*operation_outcome),
stage_param(entry, *stage),
None,
@ -130,6 +198,7 @@ impl BackendError {
AgentCallError::Canceled { stage, operation_outcome } => Self::new(
catalog_entry(CatalogCode::Canceled),
BackendErrorSource::JdbcAgent,
BackendErrorOrigin::database(BackendErrorAdapter::JdbcAgent),
map_outcome(*operation_outcome),
stage_param(catalog_entry(CatalogCode::Canceled), *stage),
None,
@ -143,9 +212,10 @@ impl BackendError {
Self::new(
catalog_entry(CatalogCode::LegacyBackend),
BackendErrorSource::LegacyBackend,
BackendErrorOrigin::backend(),
BackendOperationOutcome::Unknown,
BTreeMap::new(),
safe_detail(message),
bounded_detail(message),
None,
)
}
@ -156,32 +226,57 @@ impl BackendError {
Self::new(
entry,
BackendErrorSource::JdbcAgent,
BackendErrorOrigin::database(BackendErrorAdapter::Native),
BackendOperationOutcome::Unknown,
stage_param(entry, AgentErrorStage::Execute),
safe_detail(message),
bounded_detail(message),
Some(diagnostics_for_local("timeout", AgentErrorStage::Execute)),
)
}
/// Create a SQL failure envelope while retaining the bounded diagnostic detail.
/// Create a SQL failure envelope while retaining bounded native driver detail.
///
/// The caller must pass a typed SQL execution error, not a connection or
/// transport diagnostic. Native SQL text is intentionally not parsed or
/// rewritten because the database dialect owns its format.
pub fn from_sql_detail(message: &str) -> Self {
let entry = catalog_entry(CatalogCode::SqlFailed);
Self::new(
entry,
BackendErrorSource::JdbcAgent,
BackendErrorOrigin::database(BackendErrorAdapter::Native),
BackendOperationOutcome::Unknown,
stage_param(entry, AgentErrorStage::Execute),
safe_detail(message),
bounded_native_detail(message),
Some(diagnostics_for_local("sql", AgentErrorStage::Execute)),
)
}
/// Adapt a DuckDB worker error while retaining both the native detail and
/// the worker protocol code for diagnostics at the public boundary.
pub fn from_duckdb_worker_error(code: &str, message: &str) -> Self {
let is_sql_error = matches!(code, "duckdb_execute_failed" | "duckdb_worker_poisoned");
let entry = catalog_entry(if is_sql_error { CatalogCode::SqlFailed } else { CatalogCode::LegacyBackend });
let source = if is_sql_error { BackendErrorSource::JdbcAgent } else { BackendErrorSource::LegacyBackend };
let category = if is_sql_error { "sql" } else { "backend" };
Self::new(
entry,
source,
BackendErrorOrigin::database_driver(BackendErrorAdapter::Native, "duckdb"),
BackendOperationOutcome::Unknown,
stage_param(entry, AgentErrorStage::Execute),
if is_sql_error { bounded_native_detail(message) } else { bounded_detail(message) },
Some(diagnostics_for_local_with_adapter_code(category, AgentErrorStage::Execute, code)),
)
}
/// Create a cancellation envelope for work canceled by the Rust executor.
pub fn from_canceled(stage: AgentErrorStage, operation_outcome: AgentOperationOutcome) -> Self {
let entry = catalog_entry(CatalogCode::Canceled);
Self::new(
entry,
BackendErrorSource::LegacyBackend,
BackendErrorOrigin::database(BackendErrorAdapter::Native),
map_outcome(operation_outcome),
stage_param(entry, stage),
None,
@ -216,6 +311,10 @@ impl BackendError {
self.source
}
pub fn origin(&self) -> BackendErrorOrigin {
self.origin
}
pub fn operation_outcome(&self) -> BackendOperationOutcome {
self.operation_outcome
}
@ -236,6 +335,7 @@ impl BackendError {
fn new(
entry: &'static CatalogEntry,
source: BackendErrorSource,
origin: BackendErrorOrigin,
operation_outcome: BackendOperationOutcome,
message_params: BTreeMap<String, BackendMessageParam>,
detail: Option<String>,
@ -249,6 +349,7 @@ impl BackendError {
message_key: entry.message_key.to_string(),
message_params,
source,
origin,
operation_outcome,
detail,
diagnostics,
@ -506,13 +607,23 @@ fn diagnostics_from_context(context: &AgentErrorContext) -> BackendErrorDiagnost
sql_state: context.sql_state.as_deref().map(|value| bounded_ascii(value, 32)),
vendor_code: context.vendor_code,
exception_class: context.exception_class.as_deref().map(|value| bounded_ascii(value, 128)),
..Default::default()
}
}
fn diagnostics_for_local(category: &str, stage: AgentErrorStage) -> BackendErrorDiagnostics {
diagnostics_for_local_with_adapter_code(category, stage, "")
}
fn diagnostics_for_local_with_adapter_code(
category: &str,
stage: AgentErrorStage,
adapter_code: &str,
) -> BackendErrorDiagnostics {
BackendErrorDiagnostics {
category: Some(category.to_string()),
stage: Some(stage_name(stage).to_string()),
adapter_code: (!adapter_code.is_empty()).then(|| bounded_ascii(adapter_code, 64)),
..Default::default()
}
}
@ -532,53 +643,358 @@ fn bounded_text(value: &str, max_bytes: usize) -> String {
value[..end].to_string()
}
fn bounded_detail(message: &str) -> Option<String> {
if message.trim().is_empty() {
return None;
}
let detail = safe_detail(message)?;
let detail = bounded_text(&detail, MAX_DETAIL_BYTES);
(!detail.is_empty()).then_some(detail)
}
fn bounded_native_detail(message: &str) -> Option<String> {
// This path is only for typed native SQL failures. Do not infer or rewrite
// SQL content here; preserving the driver's diagnostic is the contract.
let trimmed = message.trim();
if trimmed.is_empty() {
return None;
}
let detail = bounded_text(trimmed, MAX_DETAIL_BYTES);
(!detail.is_empty()).then_some(detail)
}
fn safe_detail(message: &str) -> Option<String> {
let trimmed = message.trim();
if trimmed.is_empty() {
return None;
}
let normalized = trimmed.split_ascii_whitespace().collect::<Vec<_>>().join(" ");
let lowered = normalized.to_ascii_lowercase();
let sensitive_markers = [
"://",
"jdbc:",
"password",
"passwd",
"pwd",
"token",
"secret",
"authorization",
"bearer",
"api_key",
"apikey",
"credential",
"auth=",
"key=",
"user=",
"username=",
"uid=",
"access_key",
"private_key",
"agent session",
"agentsessionid",
"session id",
"session_id",
"session=",
];
if sensitive_markers.iter().any(|marker| lowered.contains(marker)) {
let detail = redact_session_identifier(&redact_sensitive_fragments(trimmed));
if contains_only_redacted_sensitive_tokens(&detail) {
return None;
}
let sql_verbs = [
"select", "insert", "update", "delete", "drop", "create", "alter", "truncate", "merge", "call", "with",
"grant", "revoke", "comment", "explain", "begin", "commit", "rollback",
];
if lowered.split(|ch: char| !ch.is_ascii_alphabetic()).any(|word| sql_verbs.contains(&word)) {
return None;
}
let detail = bounded_text(&normalized, MAX_DETAIL_BYTES);
(!detail.is_empty()).then_some(detail)
}
fn contains_only_redacted_sensitive_tokens(value: &str) -> bool {
let mut has_token = false;
let all_sensitive = value.split_whitespace().all(|token| {
has_token = true;
if token == "[redacted]" || matches!(token, "=" | ":") {
return true;
}
let normalized = token.trim_matches(|ch: char| !ch.is_ascii_alphanumeric() && ch != '_' && ch != '-');
if sensitive_key_name(normalized) {
return true;
}
['=', ':'].iter().any(|separator| {
token.split_once(*separator).is_some_and(|(key, value)| {
sensitive_key_name(key)
&& value.trim_matches(|ch| matches!(ch, ';' | ',' | ']' | ')' | '}')) == "[redacted]"
})
})
});
has_token && all_sensitive
}
fn redact_sensitive_fragments(value: &str) -> String {
let redacted_url = redact_url_userinfo(value);
let mut redacted = String::with_capacity(redacted_url.len());
let mut cursor = 0;
while let Some((value_start, value_end)) = next_sensitive_assignment(&redacted_url, cursor) {
redacted.push_str(&redacted_url[cursor..value_start]);
redacted.push_str("[redacted]");
cursor = value_end;
}
redacted.push_str(&redacted_url[cursor..]);
let source_tokens = redacted.split_whitespace().collect::<Vec<_>>();
let mut tokens = Vec::with_capacity(source_tokens.len());
let mut redact_next = false;
let mut changed = false;
let mut index = 0;
while index < source_tokens.len() {
let token = source_tokens[index];
if redact_next {
tokens.push("[redacted]");
redact_next = false;
changed = true;
} else if token.eq_ignore_ascii_case("bearer") {
tokens.push("[redacted]");
redact_next = true;
changed = true;
} else if token.eq_ignore_ascii_case("authorization:") && source_tokens.get(index + 1) == Some(&"[redacted]") {
tokens.push("[redacted]");
index += 1;
changed = true;
} else {
tokens.push(token);
}
index += 1;
}
if changed {
tokens.join(" ")
} else {
redacted
}
}
fn redact_url_userinfo(value: &str) -> String {
let mut ranges = Vec::new();
let mut search_from = 0;
while let Some(relative_scheme_end) = value[search_from..].find("://") {
let authority_start = search_from + relative_scheme_end + "://".len();
let authority_end = value[authority_start..]
.char_indices()
.find(|(_, ch)| matches!(ch, '/' | '?' | '#') || ch.is_ascii_whitespace())
.map(|(offset, _)| authority_start + offset)
.unwrap_or(value.len());
let authority = &value[authority_start..authority_end];
if let Some(user_info_end) = authority.rfind('@') {
let user_info = &authority[..user_info_end];
if let Some(password_separator) = user_info.find(':') {
let password_start = authority_start + password_separator + 1;
let password_end = authority_start + user_info_end;
if password_start < password_end {
ranges.push((password_start, password_end));
}
}
}
if authority_end == value.len() {
break;
}
search_from = authority_end;
}
if ranges.is_empty() {
return value.to_string();
}
let mut redacted = String::with_capacity(value.len());
let mut cursor = 0;
for (start, end) in ranges {
redacted.push_str(&value[cursor..start]);
redacted.push_str("[redacted]");
cursor = end;
}
redacted.push_str(&value[cursor..]);
redacted
}
fn sensitive_key_name(key: &str) -> bool {
let key = key.chars().filter(|ch| ch.is_ascii_alphanumeric()).map(|ch| ch.to_ascii_lowercase()).collect::<String>();
matches!(
key.as_str(),
"password"
| "passwd"
| "pwd"
| "token"
| "accesstoken"
| "refreshtoken"
| "secret"
| "authorization"
| "apikey"
| "credential"
| "auth"
| "key"
| "user"
| "username"
| "uid"
| "accesskey"
| "privatekey"
| "session"
| "sessionid"
| "agentsessionid"
| "jwt"
| "cookie"
)
}
fn next_sensitive_assignment(value: &str, from: usize) -> Option<(usize, usize)> {
let mut index = from;
while index < value.len() {
let ch = value[index..].chars().next()?;
if !is_sensitive_key_char(ch)
|| (index > 0 && value[..index].chars().next_back().is_some_and(is_sensitive_key_char))
{
index += ch.len_utf8();
continue;
}
let key_start = index;
let mut key_end = index;
while key_end < value.len() {
let key_char = value[key_end..].chars().next()?;
if !is_sensitive_key_char(key_char) {
break;
}
key_end += key_char.len_utf8();
}
if !sensitive_key_name(&value[key_start..key_end]) {
index = key_end;
continue;
}
let mut separator_start = key_end;
if let Some(quote) = value[..key_start].chars().next_back().filter(|ch| matches!(ch, '\'' | '"')) {
if value[separator_start..].starts_with(quote) {
separator_start += quote.len_utf8();
}
}
while separator_start < value.len()
&& value[separator_start..].chars().next().is_some_and(|ch| ch.is_ascii_whitespace())
{
separator_start += value[separator_start..].chars().next()?.len_utf8();
}
let separator = value[separator_start..].chars().next()?;
if !matches!(separator, '=' | ':') {
index = key_end;
continue;
}
let mut value_start = separator_start + separator.len_utf8();
while value_start < value.len()
&& value[value_start..].chars().next().is_some_and(|ch| ch.is_ascii_whitespace())
{
value_start += value[value_start..].chars().next()?.len_utf8();
}
let value_end = consume_sensitive_value(value, value_start, &value[key_start..key_end]);
return Some((value_start, value_end));
}
None
}
fn is_sensitive_key_char(ch: char) -> bool {
ch.is_ascii_alphanumeric() || matches!(ch, '_' | '-')
}
fn consume_sensitive_value(value: &str, start: usize, key: &str) -> usize {
if start >= value.len() {
return start;
}
let first = value[start..].chars().next().unwrap_or_default();
if first == '{' {
return consume_braced_value(value, start);
}
if matches!(first, '\'' | '"') {
let mut escaped = false;
let mut iter = value[start + first.len_utf8()..].char_indices();
while let Some((offset, ch)) = iter.next() {
if escaped {
escaped = false;
continue;
}
if ch == '\\' {
escaped = true;
continue;
}
if ch == first {
let end = start + first.len_utf8() + offset + ch.len_utf8();
if value[end..].starts_with(first) {
iter.next();
continue;
}
return end;
}
}
return value.len();
}
let mut end = start;
while end < value.len() {
let ch = value[end..].chars().next().unwrap_or_default();
if ch.is_ascii_whitespace() || matches!(ch, '&' | ';' | ',' | ']' | ')' | '}') {
break;
}
end += ch.len_utf8();
}
let normalized_key =
key.chars().filter(|ch| ch.is_ascii_alphanumeric()).map(|ch| ch.to_ascii_lowercase()).collect::<String>();
if normalized_key == "authorization" {
let scheme = value[start..end].to_ascii_lowercase();
if matches!(scheme.as_str(), "bearer" | "basic" | "digest") {
let mut token_start = end;
while token_start < value.len()
&& value[token_start..].chars().next().is_some_and(|ch| ch.is_ascii_whitespace())
{
token_start += value[token_start..].chars().next().unwrap().len_utf8();
}
let mut token_end = token_start;
while token_end < value.len() {
let ch = value[token_end..].chars().next().unwrap_or_default();
if ch.is_ascii_whitespace() || matches!(ch, '&' | ';' | ',' | ']' | ')' | '}') {
break;
}
token_end += ch.len_utf8();
}
return token_end;
}
}
end
}
fn consume_braced_value(value: &str, start: usize) -> usize {
let mut index = start + '{'.len_utf8();
while index < value.len() {
let ch = value[index..].chars().next().unwrap_or_default();
if ch == '}' {
let next = index + ch.len_utf8();
if value[next..].starts_with('}') {
index = next + '}'.len_utf8();
continue;
}
return next;
}
index += ch.len_utf8();
}
value.len()
}
fn redact_session_identifier(value: &str) -> String {
let lowered = value.to_ascii_lowercase();
for marker in ["session id", "session_id", "agentsessionid"] {
if let Some(marker_start) = lowered.find(marker) {
let value_start = value[marker_start + marker.len()..]
.char_indices()
.find(|(_, ch)| !ch.is_ascii_whitespace() && *ch != ':' && *ch != '=')
.map(|(offset, _)| marker_start + marker.len() + offset);
if let Some(value_start) = value_start {
let value_end = value[value_start..]
.char_indices()
.find(|(_, ch)| ch.is_ascii_whitespace())
.map(|(offset, _)| value_start + offset)
.unwrap_or(value.len());
let mut result = value.to_string();
result.replace_range(value_start..value_end, "[redacted]");
return result;
}
}
}
let Some(colon) = value.find(':') else {
return value.to_string();
};
if !lowered[..colon].contains("session") {
return value.to_string();
}
let value_start = value[colon + 1..]
.char_indices()
.find(|(_, ch)| !ch.is_ascii_whitespace())
.map(|(offset, _)| colon + 1 + offset);
let Some(value_start) = value_start else {
return value.to_string();
};
let value_end = value[value_start..]
.char_indices()
.find(|(_, ch)| ch.is_ascii_whitespace())
.map(|(offset, _)| value_start + offset)
.unwrap_or(value.len());
let mut result = value.to_string();
result.replace_range(value_start..value_end, "[redacted]");
result
}
#[cfg(test)]
mod tests {
use super::*;
@ -675,17 +1091,65 @@ mod tests {
}
#[test]
fn unsafe_detail_is_omitted_and_internal_fields_are_not_serialized() {
for message in [
"DROP TABLE users",
"SELECT\tpassword FROM users",
"postgresql://host/db?user=alice&pwd=secret",
"Bearer token-value",
"CALL refresh_cache()",
"syntax error in statement [UPDATE customers SET ssn='123']",
"Agent session not found: 7f51e7f4-7cee-42db-bfeb-76d1199d1afe",
fn sql_errors_expose_generic_database_origin_without_changing_v1_source() {
let payload =
serde_json::to_value(BackendError::from_sql_detail("relation missing_table does not exist")).unwrap();
assert_eq!(payload["source"], "jdbcAgent");
assert_eq!(payload["origin"]["subsystem"], "database");
assert_eq!(payload["origin"]["adapter"], "native");
}
#[test]
fn duckdb_worker_sql_error_preserves_worker_code_detail_and_driver_origin() {
let payload = serde_json::to_value(BackendError::from_duckdb_worker_error(
"duckdb_execute_failed",
"Parser Error: syntax error at or near SELECT",
))
.unwrap();
assert_eq!(payload["code"], "DBX-JDBC-4001");
assert_eq!(payload["detail"], "Parser Error: syntax error at or near SELECT");
assert_eq!(payload["origin"]["subsystem"], "database");
assert_eq!(payload["origin"]["adapter"], "native");
assert_eq!(payload["origin"]["driver"], "duckdb");
assert_eq!(payload["diagnostics"]["adapterCode"], "duckdb_execute_failed");
}
#[test]
fn public_detail_redacts_credentials_and_preserves_native_sql_text() {
for (message, expected) in [
("Incorrect syntax near SELECT", "Incorrect syntax near SELECT"),
("ERROR: relation missing_table does not exist", "ERROR: relation missing_table does not exist"),
("ORA-00942: table or view does not exist", "ORA-00942: table or view does not exist"),
(
"syntax error in statement [UPDATE customers SET ssn='123']",
"syntax error in statement [UPDATE customers SET ssn='123']",
),
(
"syntax error at or near 'SELECT email FROM customers WHERE ssn = 123'",
"syntax error at or near 'SELECT email FROM customers WHERE ssn = 123'",
),
(
"failed executing SELECT * FROM [Users] WHERE email='literal-secret@example.com'",
"failed executing SELECT * FROM [Users] WHERE email='literal-secret@example.com'",
),
(
"connection failed: jdbc:postgresql://host/db?user=alice&password=secret",
"connection failed: jdbc:postgresql://host/db?user=[redacted]&password=[redacted]",
),
("connection failed: Authorization: Bearer abc123", "connection failed: [redacted]"),
("Agent session not found: 7f51e7f4-7cee-42db-bfeb-76d1199d1afe", "Agent session not found: [redacted]"),
("Agent session id private-session", "Agent session id [redacted]"),
] {
assert!(safe_detail(message).is_none(), "detail leaked: {message}");
assert_eq!(bounded_detail(message).as_deref(), Some(expected), "detail changed unexpectedly: {message}");
}
for message in ["DROP TABLE users", "SELECT password FROM users", "CALL refresh_cache()"] {
assert_eq!(bounded_detail(message).as_deref(), Some(message));
}
for message in
["Bearer token-value", "Authorization: Bearer token-value", "password = secret", "password: secret"]
{
assert!(bounded_detail(message).is_none(), "credential detail leaked: {message}");
}
let error = BackendError::from_agent_call_error(&AgentCallError::Structured {
rpc_code: -1,
@ -701,7 +1165,104 @@ mod tests {
assert!(value.get("retryable").is_none());
assert!(value.get("sessionDisposition").is_none());
assert!(value.get("agentSessionId").is_none());
assert!(value.get("detail").is_none());
assert_eq!(value["detail"], "jdbc:postgresql://host/db?password=[redacted]");
}
#[test]
fn public_detail_redacts_sensitive_key_value_variants() {
let message = concat!(
"driver failed: PASSWORD:secret-a passwd = 'secret-b' PWD : \"secret-c\" ",
"refresh_token=secret-d access-token : secret-e api_key='secret-f' ",
"apikey : secret-g credential=secret-h private-key:secret-i ",
"authorization: Bearer secret-j cookie = \"session=secret-k\" ",
"sessionid:secret-l jwt = secret-m secret:secret-n token = secret-o ",
"access_token:secret-p session=secret-q private_key='secret-r'"
);
let detail = bounded_detail(message).expect("non-sensitive context should remain");
for secret in [
"secret-a", "secret-b", "secret-c", "secret-d", "secret-e", "secret-f", "secret-g", "secret-h", "secret-i",
"secret-j", "secret-k", "secret-l", "secret-m", "secret-n", "secret-o", "secret-p", "secret-q", "secret-r",
] {
assert!(!detail.contains(secret), "sensitive value leaked: {secret}; detail={detail}");
}
assert!(detail.contains("driver failed"));
}
#[test]
fn public_detail_redacts_mixed_url_and_dsn_sensitive_fields() {
let message = concat!(
"connection failed for jdbc:postgresql://db.example/app?user=alice&password : url-secret&sslmode=require ",
"host=db.example port=5432 password:\"dsn secret\" session = 'session-secret'"
);
let detail = bounded_detail(message).expect("non-sensitive context should remain");
for secret in ["alice", "url-secret", "dsn secret", "session-secret"] {
assert!(!detail.contains(secret), "sensitive value leaked: {secret}; detail={detail}");
}
assert!(detail.contains("connection failed"));
}
#[test]
fn serialized_native_sql_detail_preserves_url_like_text_and_braced_literals() {
let cases = [
(
"syntax error in statement [SELECT 'jdbc:postgresql://alice:url-secret@db.example/app']",
"syntax error in statement [SELECT 'jdbc:postgresql://alice:url-secret@db.example/app']",
),
(
"syntax error in statement [SELECT 'Driver={PostgreSQL};PWD={dsn;secret};SERVER=db.example']",
"syntax error in statement [SELECT 'Driver={PostgreSQL};PWD={dsn;secret};SERVER=db.example']",
),
];
for (message, expected) in cases {
let payload = serde_json::to_value(BackendError::from_sql_detail(message)).unwrap();
assert_eq!(payload["detail"].as_str(), Some(expected), "detail changed unexpectedly: {message}");
}
}
#[test]
fn serialized_public_detail_preserves_nested_sql_and_literals() {
let message = "syntax error in statement [SELECT * FROM [Users] WHERE email='literal-secret@example.com']";
let payload = serde_json::to_value(BackendError::from_sql_detail(message)).unwrap();
assert_eq!(payload["detail"].as_str(), Some(message));
}
#[test]
fn structured_connection_detail_still_redacts_credentials() {
let error = BackendError::from_agent_call_error(&AgentCallError::Structured {
rpc_code: -1,
message: "connection failed: jdbc:postgresql://alice:url-secret@db.example/app".to_string(),
context: context(
AgentErrorCategory::Connection,
AgentErrorStage::Connect,
AgentOperationOutcome::NotStarted,
),
});
assert_eq!(error.detail(), Some("connection failed: jdbc:postgresql://alice:[redacted]@db.example/app"));
}
#[test]
fn structured_sql_detail_preserves_native_text() {
let message = "syntax error in statement [UPDATE users SET password='user-input']";
let error = BackendError::from_agent_call_error(&AgentCallError::Structured {
rpc_code: -1,
message: message.to_string(),
context: context(AgentErrorCategory::Sql, AgentErrorStage::Execute, AgentOperationOutcome::Unknown),
});
assert_eq!(error.detail(), Some(message));
}
#[test]
fn bounded_detail_remains_bounded_after_redaction() {
let detail = bounded_detail(&format!("ERROR: {}", "x".repeat(MAX_DETAIL_BYTES + 100))).unwrap();
assert!(detail.len() > 512);
assert!(detail.len() <= MAX_DETAIL_BYTES);
assert!(detail.is_char_boundary(MAX_DETAIL_BYTES));
}
#[test]
@ -770,7 +1331,7 @@ mod tests {
assert_eq!(error.code(), "DBX-JDBC-9001");
assert_eq!(
error.detail(),
Some("ERROR: relation \"dbx_table_that_does_not_exist\" does not exist Position: 15")
Some("ERROR: relation \"dbx_table_that_does_not_exist\" does not exist\n Position: 15")
);
}
@ -785,7 +1346,7 @@ mod tests {
let error = BackendError::from_legacy_string(&legacy);
assert_eq!(error.code(), "DBX-JDBC-9001");
assert_eq!(error.detail(), Some("无效的表或视图名 错误码: -2106"));
assert_eq!(error.detail(), Some("无效的表或视图名\n错误码: -2106"));
}
#[test]

View File

@ -195,6 +195,17 @@ impl DuckDbWorkerClient {
cancel_token: Option<CancellationToken>,
query_timeout: Option<Duration>,
) -> Result<db::QueryResult, String> {
self.execute_typed(database, sql, max_rows, cancel_token, query_timeout).await.map_err(|error| error.message)
}
pub async fn execute_typed(
&self,
database: Option<String>,
sql: String,
max_rows: Option<usize>,
cancel_token: Option<CancellationToken>,
query_timeout: Option<Duration>,
) -> Result<db::QueryResult, DuckDbWorkerError> {
let _query_guard = self.inner.query_lock.lock().await;
let client = self.clone();
// Cancellation and timeout restart the worker via cancel_or_kill below. An ordinary
@ -206,7 +217,10 @@ impl DuckDbWorkerClient {
// than letting the worker self-exit) avoids racing our own next request against a dying
// worker, and OS-level kill never runs the destructor that would abort the process.
let future = async move {
client.ensure_connected().await?;
client
.ensure_connected()
.await
.map_err(|message| DuckDbWorkerError::new("duckdb_worker_connect_failed", message))?;
match client
.send_request_structured::<db::QueryResult>(
DuckDbWorkerMethod::Execute,
@ -220,7 +234,7 @@ impl DuckDbWorkerClient {
if error.code == DUCKDB_WORKER_POISONED_CODE {
client.kill().await;
}
Err(error.message)
Err(error)
}
}
};
@ -252,9 +266,9 @@ impl DuckDbWorkerClient {
}
}
async fn cancel_or_kill(&self, final_error: String) -> Result<db::QueryResult, String> {
async fn cancel_or_kill(&self, final_error: String) -> Result<db::QueryResult, DuckDbWorkerError> {
let _ = self.cancel().await;
Err(final_error)
Err(DuckDbWorkerError::from(final_error))
}
pub async fn list_databases(&self) -> Result<Vec<db::DatabaseInfo>, String> {

View File

@ -36,7 +36,7 @@ pub const QUERY_CANCELED: &str = "Query canceled";
/// (desktop/CLI diagnose first; this is only the Rust SQL-executor backstop).
const MONGO_SHELL_COMMAND_HINT: &str = "Use MongoDB shell-style commands, for example: db.collection.find({}).limit(100), db.collection.aggregate([]), db.collection.aggregate([], { explain: true }), db.version(), db.collection.countDocuments({}), db.collection.distinct(\"field\"), db.collection.getIndexes(), db.collection.createIndex({...}), or db.collection.insertOne({...}).";
const SQL_OMITTED_ERROR_CONTEXT: &str =
"SQL text omitted from user-facing error; enable debug SQL diagnostics for a redacted statement.";
"SQL text omitted from user-facing error; enable debug SQL diagnostics to inspect the original statement.";
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum PoolErrorAction {
@ -48,6 +48,7 @@ pub enum PoolErrorAction {
#[derive(Debug, Clone)]
pub enum QueryExecutionError {
Agent(AgentCallError),
DuckDb { code: String, message: String },
Canceled { stage: AgentErrorStage, operation_outcome: AgentOperationOutcome },
Timeout(String),
Sql(String),
@ -58,6 +59,7 @@ impl QueryExecutionError {
pub fn into_legacy_string(self) -> String {
match self {
Self::Agent(error) => error.into_legacy_string(),
Self::DuckDb { message, .. } => message,
Self::Canceled { .. } => canceled_error(),
Self::Timeout(error) => error,
Self::Sql(error) => error,
@ -68,6 +70,9 @@ impl QueryExecutionError {
pub fn into_backend_error(self) -> crate::backend_error::BackendError {
match self {
Self::Agent(error) => crate::backend_error::BackendError::from_agent_call_error(&error),
Self::DuckDb { code, message } => {
crate::backend_error::BackendError::from_duckdb_worker_error(&code, &message)
}
Self::Canceled { stage, operation_outcome } => {
crate::backend_error::BackendError::from_canceled(stage, operation_outcome)
}
@ -80,9 +85,12 @@ impl QueryExecutionError {
fn with_omitted_sql_context(self, sql: &str) -> Self {
match self {
Self::Agent(error) => Self::Agent(error),
Self::DuckDb { code, message } => {
Self::DuckDb { code, message: query_error_with_omitted_sql_context(&message, sql) }
}
canceled @ Self::Canceled { .. } => canceled,
Self::Timeout(error) => Self::Timeout(query_error_with_omitted_sql_context(&error, sql)),
Self::Sql(error) => Self::Sql(query_error_with_omitted_sql_context(&error, sql)),
Self::Sql(error) => Self::Sql(append_typed_sql_error_context(&error, sql)),
Self::Legacy(error) => Self::Legacy(query_error_with_omitted_sql_context(&error, sql)),
}
}
@ -90,6 +98,7 @@ impl QueryExecutionError {
fn with_context(self, context: &str) -> Self {
match self {
Self::Agent(error) => Self::Agent(error),
Self::DuckDb { code, message } => Self::DuckDb { code, message: format!("{message}; {context}") },
canceled @ Self::Canceled { .. } => canceled,
Self::Timeout(error) => Self::Timeout(format!("{error}; {context}")),
Self::Sql(error) => Self::Sql(format!("{error}; {context}")),
@ -100,7 +109,7 @@ impl QueryExecutionError {
fn as_agent_error(&self) -> Option<&AgentCallError> {
match self {
Self::Agent(error) => Some(error),
Self::Canceled { .. } | Self::Timeout(_) | Self::Sql(_) | Self::Legacy(_) => None,
Self::DuckDb { .. } | Self::Canceled { .. } | Self::Timeout(_) | Self::Sql(_) | Self::Legacy(_) => None,
}
}
}
@ -109,6 +118,7 @@ impl std::fmt::Display for QueryExecutionError {
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::Agent(error) => error.fmt(formatter),
Self::DuckDb { message, .. } => formatter.write_str(message),
Self::Canceled { .. } => formatter.write_str(QUERY_CANCELED),
Self::Timeout(error) => formatter.write_str(error),
Self::Sql(error) => formatter.write_str(error),
@ -139,6 +149,14 @@ fn query_error_with_omitted_sql_context(error: &str, _sql: &str) -> String {
crate::db::agent_driver::append_legacy_error_context(error, SQL_OMITTED_ERROR_CONTEXT)
}
fn append_typed_sql_error_context(error: &str, _sql: &str) -> String {
if error.contains(SQL_OMITTED_ERROR_CONTEXT) {
return error.to_string();
}
let separator = if error.trim_start().starts_with("Server error:") { " " } else { "\n" };
format!("{error}{separator}{SQL_OMITTED_ERROR_CONTEXT}")
}
/// A multi-statement result with metadata intended for query clients.
///
/// `execution_error` is emitted for synthesized per-statement errors so clients
@ -900,6 +918,7 @@ fn query_execution_error_action(
}
match error {
QueryExecutionError::Canceled { .. } => PoolErrorAction::Keep,
QueryExecutionError::DuckDb { message, .. } => query_pool_error_action(db_type, sql, message),
QueryExecutionError::Timeout(message)
| QueryExecutionError::Sql(message)
| QueryExecutionError::Legacy(message) => query_pool_error_action(db_type, sql, message),
@ -1252,6 +1271,8 @@ async fn do_execute_typed(
let pool = connections.get(pool_key).ok_or("Connection not found")?;
let mut typed_agent_error = None;
#[cfg(feature = "duckdb-sidecar")]
let mut typed_duckdb_error = None;
let result: Result<db::QueryResult, String> = match pool {
#[cfg(feature = "duckdb-sidecar")]
PoolKind::DuckDbWorker(client) => {
@ -1271,7 +1292,17 @@ async fn do_execute_typed(
let database = database.map(str::to_string);
let max_rows = options.max_rows;
drop(connections);
client.execute(database, sql, max_rows, cancel_token, query_timeout).await
match client.execute_typed(database, sql, max_rows, cancel_token, query_timeout).await {
Ok(result) => Ok(result),
Err(error) => {
let is_control_error = error.message == QUERY_CANCELED
|| is_dbx_query_timeout_error(&error.message.to_ascii_lowercase());
if !is_control_error {
typed_duckdb_error = Some(error.clone());
}
Err(error.message)
}
}
}
#[cfg(not(feature = "duckdb-sidecar"))]
PoolKind::DuckDbWorker(_) => {
@ -1625,6 +1656,10 @@ async fn do_execute_typed(
result
.map(normalize_query_result_for_js)
.map_err(|error| {
#[cfg(feature = "duckdb-sidecar")]
if let Some(duckdb_error) = typed_duckdb_error {
return QueryExecutionError::DuckDb { code: duckdb_error.code, message: duckdb_error.message };
}
typed_agent_error.map_or_else(|| QueryExecutionError::Legacy(error), QueryExecutionError::Agent)
})
.map_err(|error| classify_query_error(pool_db_type, error))
@ -5044,6 +5079,18 @@ for line in sys.stdin:
assert_eq!(error.into_backend_error().code(), "DBX-JDBC-1002");
}
#[test]
fn duckdb_worker_error_preserves_catalog_identity_and_detail() {
let error = QueryExecutionError::DuckDb {
code: "duckdb_execute_failed".to_string(),
message: "Catalog Error: Table missing_table does not exist".to_string(),
};
let backend_error = error.into_backend_error();
assert_eq!(backend_error.code(), "DBX-JDBC-4001");
assert_eq!(backend_error.detail(), Some("Catalog Error: Table missing_table does not exist"));
}
#[test]
fn query_timeout_preserves_timeout_catalog_identity_and_detail() {
let error = classify_query_error(
@ -5060,7 +5107,7 @@ for line in sys.stdin:
);
assert_eq!(
backend_error.detail(),
Some("Query timed out after 1 seconds SQL text omitted from user-facing error; enable debug SQL diagnostics for a redacted statement.")
Some("Query timed out after 1 seconds\nSQL text omitted from user-facing error; enable debug SQL diagnostics to inspect the original statement.")
);
}
@ -5081,7 +5128,7 @@ for line in sys.stdin:
assert_eq!(
backend_error.detail(),
Some(
"ERROR: relation \"dbx_table_that_does_not_exist\" does not exist SQL text omitted from user-facing error; enable debug SQL diagnostics for a redacted statement."
"ERROR: relation \"dbx_table_that_does_not_exist\" does not exist\nSQL text omitted from user-facing error; enable debug SQL diagnostics to inspect the original statement."
)
);
}
@ -5106,7 +5153,7 @@ for line in sys.stdin:
assert_eq!(
backend_error.detail(),
Some(
"Server error: `ERROR 1064 (42000): You have an error in your SQL syntax` SQL text omitted from user-facing error; enable debug SQL diagnostics for a redacted statement."
"Server error: `ERROR 1064 (42000): You have an error in your SQL syntax` SQL text omitted from user-facing error; enable debug SQL diagnostics to inspect the original statement."
)
);
}
@ -5127,7 +5174,7 @@ for line in sys.stdin:
assert_eq!(
backend_error.detail(),
Some(
"ERROR: relation \"dbx_table_that_does_not_exist\" does not exist SQL text omitted from user-facing error; enable debug SQL diagnostics for a redacted statement."
"ERROR: relation \"dbx_table_that_does_not_exist\" does not exist\nSQL text omitted from user-facing error; enable debug SQL diagnostics to inspect the original statement."
)
);
}
@ -5147,7 +5194,7 @@ for line in sys.stdin:
assert_eq!(
backend_error.detail(),
Some(
"Statement 1 failed: ERROR: relation \"dbx_table_that_does_not_exist\" does not exist SQL text omitted from user-facing error; enable debug SQL diagnostics for a redacted statement."
"Statement 1 failed: ERROR: relation \"dbx_table_that_does_not_exist\" does not exist\nSQL text omitted from user-facing error; enable debug SQL diagnostics to inspect the original statement."
)
);
}
@ -5511,6 +5558,18 @@ for line in sys.stdin:
assert_eq!(repeated.matches(SQL_OMITTED_ERROR_CONTEXT).count(), 1);
}
#[test]
fn typed_sql_error_context_keeps_driver_text_on_one_line() {
let error = QueryExecutionError::Sql("Server error: `ERROR 1064 (42000): syntax error`".to_string())
.with_omitted_sql_context("SELECT * FROM users")
.into_backend_error();
assert_eq!(
error.detail(),
Some("Server error: `ERROR 1064 (42000): syntax error` SQL text omitted from user-facing error; enable debug SQL diagnostics to inspect the original statement.")
);
}
#[test]
fn reconnect_retry_error_context_omits_raw_sql() {
let sql = "select 'secret-123' as token";

View File

@ -92,7 +92,7 @@ mod tests {
}
#[tokio::test]
async fn http_response_preserves_filtered_legacy_detail() {
async fn http_response_preserves_original_legacy_detail() {
let response = AppError::internal("database failed").into_response();
assert_eq!(response.status(), StatusCode::INTERNAL_SERVER_ERROR);
assert_eq!(response.headers()[axum::http::header::CONTENT_TYPE], "application/json");
@ -105,7 +105,16 @@ mod tests {
}
#[tokio::test]
async fn http_response_preserves_filtered_structured_agent_detail() {
async fn http_response_preserves_sql_keyword_diagnostic_detail() {
let response = AppError::internal("Incorrect syntax near SELECT").into_response();
let body = axum::body::to_bytes(response.into_body(), usize::MAX).await.unwrap();
let payload: serde_json::Value = serde_json::from_slice(&body).unwrap();
assert_eq!(payload["code"], "DBX-LEGACY-0001");
assert_eq!(payload["detail"], "Incorrect syntax near SELECT");
}
#[tokio::test]
async fn http_response_preserves_original_structured_agent_detail() {
use dbx_core::db::agent_driver::{
AgentCallError, AgentErrorCategory, AgentErrorContext, AgentErrorStage, AgentOperationOutcome,
AgentSessionDisposition,
@ -137,4 +146,53 @@ mod tests {
assert_eq!(payload["detail"], "relation customer_orders does not exist");
assert!(payload["diagnostics"].get("agentSessionId").is_none());
}
#[tokio::test]
async fn http_response_preserves_duckdb_native_detail_and_worker_code() {
let error = BackendError::from_duckdb_worker_error(
"duckdb_execute_failed",
"Catalog Error: Table missing_table does not exist",
);
let response = AppError::from(error).into_response();
let body = axum::body::to_bytes(response.into_body(), usize::MAX).await.unwrap();
let payload: serde_json::Value = serde_json::from_slice(&body).unwrap();
assert_eq!(payload["detail"], "Catalog Error: Table missing_table does not exist");
assert_eq!(payload["origin"]["driver"], "duckdb");
assert_eq!(payload["diagnostics"]["adapterCode"], "duckdb_execute_failed");
}
#[tokio::test]
async fn http_response_preserves_native_sql_detail() {
let error = BackendError::from_sql_detail(
"ERROR: statement [UPDATE users SET password='user-input' WHERE email='literal-secret@example.com']",
);
let response = AppError::from(error).into_response();
let body = axum::body::to_bytes(response.into_body(), usize::MAX).await.unwrap();
let payload: serde_json::Value = serde_json::from_slice(&body).unwrap();
let detail = payload["detail"].as_str().unwrap();
assert_eq!(
detail,
"ERROR: statement [UPDATE users SET password='user-input' WHERE email='literal-secret@example.com']"
);
}
#[tokio::test]
async fn http_response_redacts_common_sensitive_key_variants() {
let error = AppError::internal(
"driver failed: PASSWORD:secret-a refresh_token = \"secret-b\" api-key:secret-c authorization: Bearer secret-d sessionid=secret-e",
);
let response = error.into_response();
let body = axum::body::to_bytes(response.into_body(), usize::MAX).await.unwrap();
let payload: serde_json::Value = serde_json::from_slice(&body).unwrap();
let detail = payload["detail"].as_str().unwrap();
for secret in ["secret-a", "secret-b", "secret-c", "secret-d", "secret-e"] {
assert!(!detail.contains(secret), "sensitive value leaked: {secret}; detail={detail}");
}
}
}

View File

@ -1012,7 +1012,7 @@ mod tests {
}
#[test]
fn execute_multi_response_preserves_nested_filtered_error_detail() {
fn execute_multi_response_preserves_nested_original_error_detail() {
let result = dbx_core::query::ExecuteMultiResult {
result: dbx_core::db::QueryResult {
columns: vec!["Error".to_string()],

View File

@ -1,6 +1,6 @@
# 后端异常处理与错误码规范
本文记录 DBX 当前已经落地的后端错误契约、恢复边界和前端展示规则。目标是让恢复逻辑依赖可验证的类型,让对外错误身份稳定,同时保留数据库服务端返回的、经过安全过滤的真实诊断信息。
本文记录 DBX 当前已经落地的后端错误契约、恢复边界和前端展示规则。目标是让恢复逻辑依赖可验证的类型,让对外错误身份稳定,同时保留经过公共边界脱敏的数据库驱动诊断信息。
本文描述的是现有实现,不引入新的 Agent Protocol V3。结构化错误是 Agent Protocol v2 的可选 capability`structured_error_v1`。
@ -13,6 +13,7 @@
5. 查询层通过 `QueryExecutionError::into_backend_error` 生成公共错误对象Tauri、HTTP 和多语句结果只负责携带该对象,不重复分类。
6. 前端通过 `normalizeBackendError``translateBackendError` 生成本地化摘要,并在可用时追加服务端 `detail`
## Agent 调用契约
Agent runtime 必须完成 Protocol v2 handshake并支持 `multi_session`。如果声明 `structured_error_v1``call_typed` 在 RPC 失败时返回 `AgentCallError::Structured`;否则进入 `Legacy` 兼容路径。超时、取消、传输失败和契约不满足分别使用 `Timeout`、`Canceled`、`Transport` 和 `ContractViolation`
@ -40,6 +41,7 @@ Rust `BackendError` 的字段由 catalog 构造字段定义如下JSON 使
"messageKey": "backendErrors.jdbc.sqlFailed",
"messageParams": { "stage": "execute" },
"source": "jdbcAgent",
"origin": { "subsystem": "database", "adapter": "native" },
"operationOutcome": "unknown",
"detail": "relation missing_table does not exist",
"diagnostics": {
@ -54,8 +56,11 @@ Rust `BackendError` 的字段由 catalog 构造字段定义如下JSON 使
约束:
- `version` 当前为 `1``code`、`messageKey` 和字段含义发布后不可复用或改义。
- `source` 只能是 `jdbcAgent`、`jdbcAgentLegacy` 或 `legacyBackend`
- `version` 当前为 `1`。新增可选字段可以保持 v1改变已有字段类型、必填性、语义或删除字段时必须升级版本。
- `code``messageKey` 发布后永久保留,不能复用或改义;废弃错误码只能停止新增使用,不能重新分配给其他含义。
- `source` 是 v1 兼容字段,表示旧的错误来源;新代码使用 `origin.subsystem``origin.adapter` 描述数据库、隧道、插件、AI、消息队列等子系统。客户端不能因为未知的 source/origin 值而丢弃整个 envelope。
- `origin` 是可扩展元数据,至少包含 `subsystem``adapter`,可选 `driver`;它不参与错误分类、恢复或重试决策。
- `diagnostics.adapterCode` 是适配器协议提供的可选错误码(例如 DuckDB worker 的 `duckdb_execute_failed`),仅用于诊断展示,不替代稳定的 DBX `code`
- `operationOutcome` 只能是 `not_started``unknown`。结果未知时不能自动重放用户操作。
- `messageParams` 只能包含 catalog 声明的 string、number、boolean 标量,不得携带 SQL、URL、凭据或任意对象。
- Rust 字段保持私有,新增错误必须通过 catalog 构造,避免 code、key 和参数声明漂移。
@ -86,25 +91,29 @@ Rust `BackendError` 的字段由 catalog 构造字段定义如下JSON 使
## detail 与安全边界
`detail`服务端诊断的可选补充不是分类依据。Agent 错误映射会调用 `safe_detail`
`detail`数据库/驱动诊断的可选补充,不是分类依据。已类型化的 SQL 错误会保留数据库/驱动返回的原始正文;未知或连接类错误才使用 DBX 的凭据和 Session 清洗兜底
- 最多保留 512 字节的 UTF-8 文本;换行、制表符和连续空白会折叠为单个空格,空内容会被丢弃。
- 过滤 JDBC URL、密码、token、授权头、密钥、Session 标识等敏感标记。
- 过滤包含 SQL 语句关键字的内容,避免把完整 SQL 回显给用户。
- `agentSessionId`、重试标记和内部恢复字段不会进入公共 envelope。
- Rust 查询执行器生成的查询超时会使用 `DBX-JDBC-2002`(阶段 `execute`)摘要,同时保留安全的超时诊断 detail它不会作为 `DBX-LEGACY-0001` 展示。
- PostgreSQL native driver 返回的标准服务端 `ERROR:` 诊断会使用 `DBX-JDBC-4001`(阶段 `execute`)摘要并保留安全 detail连接、超时、取消和清理错误不使用该分类。
- 超时和取消没有服务端 detail 时只返回摘要;被过滤的 detail 也不会使用替代文本冒充原始错误。
- 最多保留 64 KiB 的 UTF-8 文本;超出部分按字符边界截断,空内容会被丢弃。
- 查询层需要补充上下文(例如说明 SQL 文本未随错误返回)时,使用独立换行符(`\n`)追加,不以空格拼接;消费者和测试应保留该换行边界。
- 数据库厂商错误正文(例如 `ERROR: relation ... does not exist`、`ORA-00942`、约束冲突中的值和驱动返回的 statement 文本)会原样保留;连接配置和未知错误文本中的 JDBC URL、密码、token、授权头、密钥和 Session 标识会被替换或在只剩敏感内容时删除。
- DBX 不解析、抽取或改写 SQL payload也不会主动把执行 SQL 追加到错误;因此 SQL 方言、嵌套括号、引号和业务字面量不会被错误的通用字符串规则破坏。需要内部诊断时应单独记录原始请求,不得把内部日志对象直接复用为公共 envelope。
- `AgentErrorContext` 中的 `agentSessionId`、重试标记和内部恢复字段不会作为结构化字段进入公共 envelope`connection` 等非 SQL 类别的驱动错误正文如果包含 Session 或凭据文本,公共 detail 仍会脱敏。
- Rust 查询执行器生成的查询超时会使用 `DBX-JDBC-2002`(阶段 `execute`)摘要,同时保留超时诊断 detail它不会作为 `DBX-LEGACY-0001` 展示。
- PostgreSQL native driver 返回的标准服务端 `ERROR:` 诊断会使用 `DBX-JDBC-4001`(阶段 `execute`)摘要并保留原始 detail连接、超时、取消和清理错误不使用该分类。
- DuckDB worker 返回的 `Parser Error`、`Catalog Error` 等厂商正文会保留在 `detail`worker code 会放入 `diagnostics.adapterCode`,并在前端详情前显示。
- 超时和取消没有服务端 detail 时只返回摘要;`without_detail()` 只有在调用方明确要求隐藏 detail 时才会移除原文。
## 传输边界
### Tauri Desktop
查询命令将 `QueryExecutionError` 映射为 `BackendError`。单语句和事务查询即使通过 `execute_multi` 命令执行,`dbx-core` 也会在整个 multi-query 核心链路中保留 `QueryExecutionError`,直到 Tauri 边界才转换为 `BackendError`;不得先降级为字符串再重建 envelope。`apps/desktop/src/lib/backend/tauri.ts` 在查询失败时抛出 `BackendErrorException`,前端因此可以同时取得 `messageKey`安全 `detail`
查询命令将 `QueryExecutionError` 映射为 `BackendError`。单语句和事务查询即使通过 `execute_multi` 命令执行,`dbx-core` 也会在整个 multi-query 核心链路中保留 `QueryExecutionError`,直到 Tauri 边界才转换为 `BackendError`;不得先降级为字符串再重建 envelope。`apps/desktop/src/lib/backend/tauri.ts` 在查询失败时抛出 `BackendErrorException`,前端因此可以同时取得 `messageKey`原始 `detail`。Tauri 的连接、传输、导入和导出边界也统一将拒绝结果转换为 `BackendErrorException`;未知对象只提取有长度上限的 `message`、`reason` 或 `detail`,内容为空时使用稳定摘要
### HTTP Web
`crates/dbx-web` 的 multi-query 路由也消费 typed 核心入口,并将 `AppError` 序列化为同一套 envelope当前响应使用 `BackendError::without_detail()`,因此 HTTP 客户端只获得稳定摘要身份,不获得 detail。HTTP status 只表示传输结果,不能替代或改变 `BackendError.code`
`crates/dbx-web` 的 multi-query 路由也消费 typed 核心入口,并将 `AppError` 序列化为同一套 envelope正常 HTTP 错误响应会保留按上述规则生成的 `detail`。`BackendError::without_detail()` 仅用于需要主动隐藏详情的兼容场景不是默认响应路径。HTTP status 只表示传输结果,不能替代或改变 `BackendError.code`
桌面端 HTTP 失败(包括 multipart、SSE、上传、下载和 Nacos 特殊接口)必须调用 `backendResponseError`,不能直接构造 `new Error(await response.text())`,否则会丢失 `BackendError v1` envelope。
### 多语句查询
@ -112,7 +121,7 @@ Rust `BackendError` 的字段由 catalog 构造字段定义如下JSON 使
## 前端展示规则
`normalizeBackendError` 只接受完整且类型正确的 envelope`detail` 如果存在必须是 string。`translateBackendError` 的结构化路径为:
`normalizeBackendError` 只接受完整且类型正确的 envelope`detail` 如果存在必须是 string,兼容 fallback 的单次上限为 64 KiB。解析嵌套的 `{ error }`、`{ backendError }`、`BackendErrorException` 和跨 realm 的 Error-like 对象时使用有限深度和循环检测;无法识别的对象只保留有界的 `message`、`reason` 或 `detail` 文本,空对象使用稳定摘要。`translateBackendError` 的结构化路径为:
1. 使用 `messageKey``messageParams` 生成当前 locale 的自定义摘要。
2. 若 `detail` 非空且不同于摘要,在摘要后追加空行和 detail。
@ -124,7 +133,18 @@ catch 到异常时必须把原始对象传给翻译器:
translateBackendError(t, error)
```
不要先执行 `error.message || String(error)`,否则会丢失 `messageKey`、参数和服务端 detail。`BackendErrorException`、嵌套的 `{ error }`/`{ backendError }` 和普通 `Error` 都由 `normalizeBackendError` 统一处理。
不要先执行 `error.message || String(error)`,否则会丢失 `messageKey`、参数和服务端 detail。旧版非 i18n 页面可以使用 `formatError`,但绝不能把结构化 envelope 直接转换成 `[object Object]`
## 协议演进与兼容规则
- `version` 表示 envelope 版本,不表示 Agent Protocol 版本。未知的大版本不能按旧字段强行解析;客户端应保留安全 fallback并记录原始版本用于诊断。
- 新增可选字段属于向后兼容变更;改变字段类型、必填性、枚举语义、错误码含义或安全边界时,必须发布新版本并保留旧版本适配器。
- 客户端应忽略未知的可选字段和未知的 `source`/`origin` 枚举值,但仍严格校验 `version`、`code`、`messageKey`、`messageParams`、`operationOutcome` 和 `detail` 的基本类型。
- `code` 是稳定机器标识,不能复用;`messageKey` 是稳定本地化标识,文案可以调整,但 key 的语义不能改变。错误码废弃时保留旧 locale 和兼容映射。
- 结构化 envelope 可生成本地化摘要并追加按错误来源处理的 `detail`;旧字符串或 malformed object 使用有界文本 fallback空响应只显示稳定摘要不伪造数据库原因。
- `operationOutcome=unknown` 不能因为 fallback 文本、source、origin 或 detail 推断为可重试;恢复决策只依赖 Rust 中的类型化事实。
兼容代码的退役门槛包括:不再存在直接读取 HTTP 响应文本并抛错的路径;迁移后的后端错误展示调用点不再在 `translateBackendError` 前预先提取 `.message`/`String(error)`;在所有消费者接受 `BackendError v1` 且线协议测试通过前,不移除旧字符串或旧版错误行。
## 恢复规则