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

@@ -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);
}
}