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

This commit is contained in:
miracle authored and GitHub committed 2026-08-05 17:25:21 +08:00
1 parent f1617bbd87
commit c8c40ba839
24 files changed
+1197 -182

No files matched your search

@@ -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;
}
@@ -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;
}
@@ -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);
}
}
@@ -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) {
@@ -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;
}
@@ -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;
@@ -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;
}
@@ -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);
}
}
@@ -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;
}
+108 -1
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
+4 -1
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 {
@@ -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();
});
});
+98 -15
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);
+22 -22
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();
}
+22 -13
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);
}
}
@@ -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");
});
});
+4 -2
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");
}