fix(query): preserve structured backend errors

This commit is contained in:
miracle
2026-08-02 16:37:46 +08:00
committed by GitHub
parent 2e9dcdc26b
commit bcafc4a474
58 changed files with 4182 additions and 554 deletions
+1
View File
@@ -23,6 +23,7 @@ docs/.next/
docs/out/
docs/.source/
docs/superpowers/
/docs/aegis/
src-tauri/*.dylib
# OS files
@@ -113,6 +113,7 @@ public final class AgentProtocol {
public static final String CAPABILITY_ETCD_LEASE = "etcd_lease";
public static final String CAPABILITY_ETCD_AUTH = "etcd_auth";
public static final String CAPABILITY_MULTI_SESSION = "multi_session";
public static final String CAPABILITY_STRUCTURED_ERROR_V1 = "structured_error_v1";
public static final List<String> CAPABILITIES = Collections.unmodifiableList(Arrays.asList(
CAPABILITY_CONNECT,
@@ -147,6 +148,8 @@ public final class AgentProtocol {
public static final List<String> MULTI_SESSION_CAPABILITIES;
public static final List<String> MULTI_SESSION_ALL_CAPABILITIES;
public static final List<String> MULTI_SESSION_JDBC_CAPABILITIES;
public static final List<String> MULTI_SESSION_JDBC_ALL_CAPABILITIES;
public static final List<String> COMMON_METHODS = Collections.unmodifiableList(Arrays.asList(
METHOD_HANDSHAKE,
@@ -194,6 +197,14 @@ public final class AgentProtocol {
allCapabilities.add(CAPABILITY_MULTI_SESSION);
MULTI_SESSION_ALL_CAPABILITIES = Collections.unmodifiableList(allCapabilities);
List<String> jdbcCapabilities = new java.util.ArrayList<>(MULTI_SESSION_CAPABILITIES);
jdbcCapabilities.add(CAPABILITY_STRUCTURED_ERROR_V1);
MULTI_SESSION_JDBC_CAPABILITIES = Collections.unmodifiableList(jdbcCapabilities);
List<String> jdbcAllCapabilities = new java.util.ArrayList<>(MULTI_SESSION_ALL_CAPABILITIES);
jdbcAllCapabilities.add(CAPABILITY_STRUCTURED_ERROR_V1);
MULTI_SESSION_JDBC_ALL_CAPABILITIES = Collections.unmodifiableList(jdbcAllCapabilities);
List<String> methods = new java.util.ArrayList<>(COMMON_METHODS);
int insertAt = methods.indexOf(METHOD_CONNECT) + 1;
methods.addAll(insertAt, Arrays.asList(
@@ -270,6 +281,18 @@ public final class AgentProtocol {
);
}
/**
* Handshake for pooled JDBC Agents that emit the v1 structured error contract.
* Generic/custom v2 handlers must continue using multiSessionHandshakeResult().
*/
public static HandshakeResult multiSessionJdbcHandshakeResult() {
return new HandshakeResult(
MULTI_SESSION_PROTOCOL_VERSION,
MULTI_SESSION_PROTOCOL_VERSION,
MULTI_SESSION_JDBC_CAPABILITIES
);
}
public static final class HandshakeResult {
private final int protocolVersion;
private final int agentProtocolVersion;
@@ -3,15 +3,21 @@ package com.dbx.agent;
import com.google.gson.JsonObject;
import java.sql.SQLException;
import java.sql.SQLTimeoutException;
import java.sql.SQLRecoverableException;
import java.sql.SQLTransientConnectionException;
import java.util.Locale;
import java.util.concurrent.CancellationException;
final class AgentRpcError extends RuntimeException {
private final String category;
private final boolean retryable;
private final String disposition;
private final String stage;
private final String operationOutcome;
private final String sqlState;
private final Integer vendorCode;
private final String exceptionClass;
private AgentRpcError(
String message,
@@ -19,6 +25,10 @@ final class AgentRpcError extends RuntimeException {
boolean retryable,
String disposition,
String stage,
String operationOutcome,
String sqlState,
Integer vendorCode,
String exceptionClass,
Throwable cause
) {
super(message, cause);
@@ -26,6 +36,10 @@ final class AgentRpcError extends RuntimeException {
this.retryable = retryable;
this.disposition = disposition;
this.stage = stage;
this.operationOutcome = operationOutcome;
this.sqlState = sqlState;
this.vendorCode = vendorCode;
this.exceptionClass = exceptionClass;
}
static AgentRpcError resource(String stage, Throwable cause) {
@@ -35,6 +49,10 @@ final class AgentRpcError extends RuntimeException {
false,
"replace_runtime",
stage,
operationOutcome(stage),
null,
null,
cause == null ? null : cause.getClass().getName(),
cause
);
}
@@ -46,6 +64,10 @@ final class AgentRpcError extends RuntimeException {
true,
"keep",
stage,
operationOutcome(stage),
null,
null,
cause == null ? null : cause.getClass().getName(),
cause
);
}
@@ -60,6 +82,13 @@ final class AgentRpcError extends RuntimeException {
data.addProperty("retryable", classified.retryable);
data.addProperty("sessionDisposition", classified.disposition);
data.addProperty("stage", classified.stage);
data.addProperty("contractVersion", 1);
data.addProperty("operationOutcome", classified.operationOutcome);
addDiagnostic(data, "sqlState", classified.sqlState);
if (classified.vendorCode != null) {
data.addProperty("vendorCode", classified.vendorCode);
}
addDiagnostic(data, "exceptionClass", classified.exceptionClass);
if (agentSessionId != null && !agentSessionId.trim().isEmpty()) {
data.addProperty("agentSessionId", agentSessionId);
}
@@ -72,34 +101,69 @@ final class AgentRpcError extends RuntimeException {
if (explicit != null) {
return explicit;
}
String stage = stage(method);
if (find(error, CancellationException.class) != null || find(error, InterruptedException.class) != null) {
return new AgentRpcError(
message(error),
"canceled",
false,
"quarantine",
stage,
operationOutcome(stage),
null,
null,
safeClassName(error),
error
);
}
SQLException sqlError = find(error, SQLException.class);
if (sqlError != null) {
String sqlState = sqlError.getSQLState();
String stage = stage(method);
String category = sqlError instanceof SQLTimeoutException ? "timeout" : null;
boolean connectionError = "connect".equals(stage)
|| "validate".equals(stage)
|| sqlError instanceof SQLRecoverableException
|| sqlError instanceof SQLTransientConnectionException
|| (sqlState != null && sqlState.toUpperCase(Locale.ROOT).startsWith("08"));
boolean operationRetryable = connectionError && ("connect".equals(stage) || "validate".equals(stage));
String disposition = connectionError && !"connect".equals(stage) ? "quarantine" : "keep";
String disposition = category != null || (connectionError && !"connect".equals(stage)) ? "quarantine" : "keep";
return new AgentRpcError(
message(error),
connectionError ? "connection" : "sql",
category == null ? (connectionError ? "connection" : "sql") : category,
operationRetryable,
disposition,
stage,
operationOutcome(stage),
safeSqlState(sqlState),
sqlError.getErrorCode(),
safeClassName(sqlError),
error
);
}
return new AgentRpcError(message(error), "protocol", false, "keep", stage(method), error);
return new AgentRpcError(
message(error),
"protocol",
false,
"keep",
stage,
operationOutcome(stage),
null,
null,
safeClassName(error),
error
);
}
private static String stage(String method) {
if (method == null) {
return "request";
}
if (AgentProtocol.METHOD_CONNECT.equals(method) || AgentProtocol.METHOD_OPEN_SESSION.equals(method)) {
if (AgentProtocol.METHOD_HANDSHAKE.equals(method)) {
return "request";
}
if (AgentProtocol.METHOD_CONNECT.equals(method)
|| AgentProtocol.METHOD_OPEN_SESSION.equals(method)
|| AgentProtocol.METHOD_TEST_CONNECTION.equals(method)) {
return "connect";
}
if (AgentProtocol.METHOD_VALIDATE_CONNECTION.equals(method) || AgentProtocol.METHOD_VALIDATE_SESSION.equals(method)) {
@@ -108,7 +172,11 @@ final class AgentRpcError extends RuntimeException {
if (AgentProtocol.METHOD_CANCEL_SESSION.equals(method)) {
return "cancel";
}
if (AgentProtocol.METHOD_CLOSE_SESSION.equals(method) || AgentProtocol.METHOD_DISCONNECT.equals(method)) {
if (AgentProtocol.METHOD_CLOSE_SESSION.equals(method)
|| AgentProtocol.METHOD_DISCONNECT.equals(method)
|| AgentProtocol.METHOD_CLOSE_QUERY_SESSION.equals(method)
|| AgentProtocol.METHOD_CLOSE_TABLE_READ_SESSION.equals(method)
|| AgentProtocol.METHOD_SHUTDOWN.equals(method)) {
return "close";
}
if (AgentProtocol.METHOD_FETCH_QUERY_PAGE.equals(method)
@@ -122,6 +190,41 @@ final class AgentRpcError extends RuntimeException {
return error.getMessage() == null ? error.toString() : error.getMessage();
}
private static String operationOutcome(String stage) {
return switch (stage) {
case "request", "checkout", "connect", "validate" -> "not_started";
default -> "unknown";
};
}
private static String safeSqlState(String sqlState) {
return safeDiagnostic(sqlState, 16);
}
private static String safeClassName(Throwable error) {
return error == null ? null : safeDiagnostic(error.getClass().getName(), 160);
}
private static String safeDiagnostic(String value, int maxLength) {
if (value == null) {
return null;
}
StringBuilder safe = new StringBuilder(Math.min(value.length(), maxLength));
for (int index = 0; index < value.length() && safe.length() < maxLength; index++) {
char character = value.charAt(index);
if (character >= 0x21 && character <= 0x7e) {
safe.append(character);
}
}
return safe.isEmpty() ? null : safe.toString();
}
private static void addDiagnostic(JsonObject data, String name, String value) {
if (value != null && !value.isBlank()) {
data.addProperty(name, value);
}
}
private static <T extends Throwable> T find(Throwable error, Class<T> type) {
Throwable current = error;
while (current != null) {
@@ -27,7 +27,7 @@ import java.util.function.Supplier;
public final class MultiSessionJsonRpcServer implements AutoCloseable {
private static final String LEGACY_SESSION_ID = "__legacy__";
private static final int MAX_SESSIONS = 256;
static final int MAX_SESSIONS = 256;
private static final int MAX_REQUEST_THREADS = 64;
private static final int MAX_CLEANUP_THREADS = 16;
private static final long MAINTENANCE_INTERVAL_MILLIS = 60_000L;
@@ -137,7 +137,7 @@ public final class MultiSessionJsonRpcServer implements AutoCloseable {
try {
Object result;
if (AgentProtocol.METHOD_HANDSHAKE.equals(method)) {
result = sessionHandlerFactory == null ? AgentProtocol.multiSessionHandshakeResult() : customHandshake();
result = sessionHandlerFactory == null ? AgentProtocol.multiSessionJdbcHandshakeResult() : customHandshake();
} else if (AgentProtocol.METHOD_OPEN_SESSION.equals(method)) {
result = openSession(requiredSessionId(params), params);
} else if (AgentProtocol.METHOD_CLOSE_SESSION.equals(method)) {
@@ -171,7 +171,10 @@ public final class MultiSessionJsonRpcServer implements AutoCloseable {
private Object openSession(String sessionId, JsonObject params) throws Exception {
if (sessions.size() >= MAX_SESSIONS && !sessions.containsKey(sessionId)) {
throw new IllegalStateException("Agent session limit reached: " + MAX_SESSIONS);
throw AgentRpcError.backpressure(
"connect",
new IllegalStateException("Agent session limit reached: " + MAX_SESSIONS)
);
}
Session session;
if (sessionHandlerFactory != null) {
@@ -2,9 +2,9 @@
"protocolVersion": 2,
"handshakeMethod": "handshake",
"handshakeResponseFields": ["protocolVersion", "agentProtocolVersion", "capabilities"],
"allCapabilities": ["connect", "test_connection", "metadata", "query", "paged_query", "transaction", "ddl", "kv", "kv_ttl", "kv_cas", "kv_list_values", "kv_status", "kv_history", "etcd_compaction", "etcd_defrag", "etcd_watch", "etcd_lease", "etcd_auth", "multi_session"],
"allCapabilities": ["connect", "test_connection", "metadata", "query", "paged_query", "transaction", "ddl", "kv", "kv_ttl", "kv_cas", "kv_list_values", "kv_status", "kv_history", "etcd_compaction", "etcd_defrag", "etcd_watch", "etcd_lease", "etcd_auth", "multi_session", "structured_error_v1"],
"capabilities": ["connect", "test_connection", "metadata", "query", "paged_query", "transaction", "ddl", "multi_session"],
"defaultSqlCapabilities": ["connect", "test_connection", "metadata", "query", "paged_query", "transaction", "ddl", "multi_session"],
"defaultSqlCapabilities": ["connect", "test_connection", "metadata", "query", "paged_query", "transaction", "ddl", "multi_session", "structured_error_v1"],
"commonMethods": [
"handshake",
"connect",
@@ -0,0 +1,97 @@
package com.dbx.agent;
import com.google.gson.JsonObject;
import org.junit.jupiter.api.Test;
import java.sql.SQLException;
import java.sql.SQLTimeoutException;
import java.util.concurrent.CancellationException;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
class AgentRpcErrorTest {
@Test
void serializesVersionedSqlDiagnosticsAndUnknownExecuteOutcome() {
SQLException cause = new SQLException("statement failed", "42000", 1064);
JsonObject data = errorData(cause, AgentProtocol.METHOD_EXECUTE_QUERY, "session-1");
assertEquals(1, data.get("contractVersion").getAsInt());
assertEquals("sql", data.get("category").getAsString());
assertEquals("execute", data.get("stage").getAsString());
assertEquals("unknown", data.get("operationOutcome").getAsString());
assertEquals("keep", data.get("sessionDisposition").getAsString());
assertEquals("42000", data.get("sqlState").getAsString());
assertEquals(1064, data.get("vendorCode").getAsInt());
assertEquals(SQLException.class.getName(), data.get("exceptionClass").getAsString());
assertEquals("session-1", data.get("agentSessionId").getAsString());
}
@Test
void classifiesTimeoutAndCanceledErrorsWithoutClaimingSafeReplay() {
JsonObject timeout = errorData(
new SQLTimeoutException("timed out", "HYT00", 0),
AgentProtocol.METHOD_EXECUTE_QUERY,
"session-1"
);
JsonObject canceled = errorData(
new CancellationException("canceled"),
AgentProtocol.METHOD_CANCEL_SESSION,
"session-1"
);
assertEquals("timeout", timeout.get("category").getAsString());
assertEquals("unknown", timeout.get("operationOutcome").getAsString());
assertFalse(timeout.get("retryable").getAsBoolean());
assertEquals("quarantine", timeout.get("sessionDisposition").getAsString());
assertEquals("canceled", canceled.get("category").getAsString());
assertEquals("cancel", canceled.get("stage").getAsString());
assertEquals("unknown", canceled.get("operationOutcome").getAsString());
}
@Test
void marksConnectionSetupFailureAsNotStartedAndBoundsSqlState() {
SQLException cause = new SQLException("connect failed", "12345678901234567890", -7);
JsonObject data = errorData(cause, AgentProtocol.METHOD_CONNECT, null);
assertEquals("connection", data.get("category").getAsString());
assertEquals("connect", data.get("stage").getAsString());
assertEquals("not_started", data.get("operationOutcome").getAsString());
assertEquals(16, data.get("sqlState").getAsString().length());
assertFalse(data.has("agentSessionId"));
}
@Test
void removesNonGraphicCharactersFromStrictDiagnostics() {
SQLException cause = new SQLException("connect failed", "08\n006\u00e9", -7);
JsonObject data = errorData(cause, AgentProtocol.METHOD_CONNECT, null);
assertEquals("08006", data.get("sqlState").getAsString());
}
@Test
void mapsConnectionAndCloseMethodsToTheSameStagesAsTheRustDecoder() {
JsonObject testConnection = errorData(
new SQLException("connect failed", "08001", 0),
AgentProtocol.METHOD_TEST_CONNECTION,
null
);
JsonObject closeQuery = errorData(
new SQLException("close failed", "42000", 0),
AgentProtocol.METHOD_CLOSE_QUERY_SESSION,
"session-1"
);
assertEquals("connect", testConnection.get("stage").getAsString());
assertEquals("not_started", testConnection.get("operationOutcome").getAsString());
assertEquals("close", closeQuery.get("stage").getAsString());
assertEquals("unknown", closeQuery.get("operationOutcome").getAsString());
}
private static JsonObject errorData(Throwable error, String method, String agentSessionId) {
return AgentRpcError.toJson(error, method, agentSessionId).getAsJsonObject("data");
}
}
@@ -72,7 +72,7 @@ class CommonJavaCompatibilityTest {
strings(contract.getAsJsonArray("handshakeResponseFields"))
);
assertEquals(
AgentProtocol.MULTI_SESSION_ALL_CAPABILITIES,
AgentProtocol.MULTI_SESSION_JDBC_ALL_CAPABILITIES,
strings(contract.getAsJsonArray("allCapabilities"))
);
assertEquals(
@@ -80,7 +80,7 @@ class CommonJavaCompatibilityTest {
strings(contract.getAsJsonArray("capabilities"))
);
assertEquals(
AgentProtocol.MULTI_SESSION_CAPABILITIES,
AgentProtocol.MULTI_SESSION_JDBC_CAPABILITIES,
strings(contract.getAsJsonArray("defaultSqlCapabilities"))
);
assertEquals(AgentProtocol.MULTI_SESSION_METHODS, strings(contract.getAsJsonArray("commonMethods")));
@@ -164,6 +164,7 @@ class CommonJavaCompatibilityTest {
)).getAsJsonObject().getAsJsonObject("result");
assertEquals(2, handshake.get("protocolVersion").getAsInt());
assertTrue(containsCapability(handshake.getAsJsonArray("capabilities"), "multi_session"));
assertTrue(containsCapability(handshake.getAsJsonArray("capabilities"), "structured_error_v1"));
server.handleRequest("{\"jsonrpc\":\"2.0\",\"id\":2,\"method\":\"open_session\",\"params\":{\"agentSessionId\":\"a\"}}");
server.handleRequest("{\"jsonrpc\":\"2.0\",\"id\":3,\"method\":\"open_session\",\"params\":{\"agentSessionId\":\"b\"}}");
@@ -177,6 +178,68 @@ class CommonJavaCompatibilityTest {
assertEquals(0, created.get(1).disconnectCount);
}
@Test
void customSessionHandlersDoNotImplicitlyAdvertiseStructuredErrors() {
MultiSessionJsonRpcServer server = MultiSessionJsonRpcServer.forSessionHandlers(() -> new SessionRpcHandler() {
@Override
public Object connect(JsonObject params) {
return Collections.singletonMap("ok", true);
}
@Override
public Object handle(String method, JsonObject params) {
return Collections.singletonMap("ok", true);
}
@Override
public void close() {
}
});
JsonObject handshake = JsonParser.parseString(server.handleRequest(
"{\"jsonrpc\":\"2.0\",\"id\":1,\"method\":\"handshake\",\"params\":{}}"
)).getAsJsonObject().getAsJsonObject("result");
assertTrue(containsCapability(handshake.getAsJsonArray("capabilities"), "multi_session"));
assertFalse(containsCapability(handshake.getAsJsonArray("capabilities"), "structured_error_v1"));
}
@Test
void multiSessionLimitReturnsStructuredBackpressureBeforeConnectStarts() {
try (MultiSessionJsonRpcServer server = MultiSessionJsonRpcServer.forSessionHandlers(() -> new SessionRpcHandler() {
@Override
public Object connect(JsonObject params) {
return Collections.singletonMap("ok", true);
}
@Override
public Object handle(String method, JsonObject params) {
return Collections.singletonMap("ok", true);
}
@Override
public void close() {
}
})) {
for (int index = 0; index < MultiSessionJsonRpcServer.MAX_SESSIONS; index++) {
JsonObject response = JsonParser.parseString(server.handleRequest(openSessionRequest(index, "session-" + index)))
.getAsJsonObject();
assertTrue(response.has("result"), response::toString);
}
JsonObject response = JsonParser.parseString(server.handleRequest(
openSessionRequest(MultiSessionJsonRpcServer.MAX_SESSIONS, "overflow")
)).getAsJsonObject();
JsonObject data = response.getAsJsonObject("error").getAsJsonObject("data");
assertEquals("resource", data.get("category").getAsString());
assertTrue(data.get("retryable").getAsBoolean());
assertEquals("keep", data.get("sessionDisposition").getAsString());
assertEquals("connect", data.get("stage").getAsString());
assertEquals("not_started", data.get("operationOutcome").getAsString());
}
}
@Test
void multiSessionServerKeepsProtocolOutputWhenGlobalStdoutChanges() {
synchronized (System.class) {
@@ -1113,6 +1176,17 @@ class CommonJavaCompatibilityTest {
return false;
}
private static String openSessionRequest(int requestId, String sessionId) {
JsonObject params = new JsonObject();
params.addProperty("agentSessionId", sessionId);
JsonObject request = new JsonObject();
request.addProperty("jsonrpc", "2.0");
request.addProperty("id", requestId);
request.addProperty("method", AgentProtocol.METHOD_OPEN_SESSION);
request.add("params", params);
return request.toString();
}
private static void awaitCondition(java.util.function.BooleanSupplier condition) throws InterruptedException {
long deadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(2);
while (!condition.getAsBoolean() && System.nanoTime() < deadline) {
+9 -2
View File
@@ -1,6 +1,6 @@
# Agent Protocol v2: Multi-session runtimes
Protocol v2 allows one Agent process to serve multiple isolated database sessions. The handshake advertises `protocolVersion: 2` and the `multi_session` capability. DBX falls back to the v1 one-process-per-pool lifecycle when that capability is absent.
Protocol v2 allows one Agent process to serve multiple isolated database sessions. Pooled JDBC Agents that use the common structured error producer advertise `protocolVersion: 2`, `multi_session`, and `structured_error_v1`. Generic/custom v2 handlers may advertise only `multi_session`. DBX falls back to the v1 one-process-per-pool lifecycle when `multi_session` is absent.
## Session lifecycle
@@ -37,14 +37,21 @@ JSON-RPC failures may include structured recovery data:
```json
{
"contractVersion": 1,
"category": "timeout|canceled|connection|protocol|resource|sql",
"retryable": false,
"sessionDisposition": "keep|quarantine|replace_runtime",
"agentSessionId": "optional-session-id",
"stage": "checkout|connect|validate|execute|fetch|cancel|close"
"stage": "request|checkout|connect|validate|execute|fetch|cancel|close",
"operationOutcome": "not_started|unknown",
"sqlState": "optional-jdbc-sql-state",
"vendorCode": 0,
"exceptionClass": "optional-java-exception-class"
}
```
`contractVersion: 1` is guaranteed only when the handshake advertises `structured_error_v1`. Unknown extra fields are allowed, but unknown enum values, missing required fields, invalid types, or an `agentSessionId` that does not match the current request are contract violations. `operationOutcome` describes whether the user operation may have reached the database; `retryable` is an internal hint and never authorizes automatic SQL replay.
`keep` preserves the logical session, `quarantine` removes only that session from routing, and `replace_runtime` requires DBX to atomically remove every pool sharing the runtime before terminating it. Agent code reports the disposition but must not independently terminate a shared runtime because it does not own DBX routing state. Temporary workload checkout backpressure uses `category=resource`, `retryable=true`, and `sessionDisposition=keep`; only unrecoverable runtime or cleanup saturation requests `replace_runtime`.
The complete JDBC pool checkout runs under a bounded runtime executor, including HikariCP idle-connection validation, physical connection creation, and driver setup. Workload admission, the runtime-wide physical connection budget, physical creation, and checkout consume one absolute deadline rather than restarting the timeout at each stage. Connection return, eviction, and physical close use separate bounded executors so they cannot deadlock checkout or creation. If a driver call outlives its boundary, or cleanup cannot confirm the physical connection state, the connection identity is poisoned and returns `category=resource` with `sessionDisposition=replace_runtime` on the current or next checkout. A late connection must be evicted and closed instead of published, and DBX must not replay the timed-out user operation automatically.
+9 -9
View File
@@ -1309,7 +1309,7 @@ async function newQuery() {
} catch (e: any) {
toast(
t("connection.connectFailed", {
message: translateBackendError(t, e?.message || String(e)),
message: translateBackendError(t, e),
}),
5000,
);
@@ -1344,7 +1344,7 @@ async function newQuery() {
} catch (e: any) {
toast(
t("connection.connectFailed", {
message: translateBackendError(t, e?.message || String(e)),
message: translateBackendError(t, e),
}),
5000,
);
@@ -1367,7 +1367,7 @@ async function openConnectionQuery(connectionId: string) {
} catch (e: any) {
toast(
t("connection.connectFailed", {
message: translateBackendError(t, e?.message || String(e)),
message: translateBackendError(t, e),
}),
5000,
);
@@ -1381,7 +1381,7 @@ async function openConnectionQuery(connectionId: string) {
} catch (e: any) {
toast(
t("connection.connectFailed", {
message: translateBackendError(t, e?.message || String(e)),
message: translateBackendError(t, e),
}),
5000,
);
@@ -1399,7 +1399,7 @@ async function openConnectionQuery(connectionId: string) {
} catch (e: any) {
toast(
t("connection.connectFailed", {
message: translateBackendError(t, e?.message || String(e)),
message: translateBackendError(t, e),
}),
5000,
);
@@ -1471,7 +1471,7 @@ async function onClickTable(table: SqlObjectNavigationTarget) {
try {
await openTableTarget(target, { tableInfoTab: "ddl" });
} catch (e: any) {
toast(t("connection.connectFailed", { message: translateBackendError(t, e?.message || String(e)) }), 5000);
toast(t("connection.connectFailed", { message: translateBackendError(t, e) }), 5000);
}
}
@@ -1481,7 +1481,7 @@ async function onViewTableData(table: SqlObjectNavigationTarget) {
try {
await openTableTarget(target);
} catch (e: any) {
toast(t("connection.connectFailed", { message: translateBackendError(t, e?.message || String(e)) }), 5000);
toast(t("connection.connectFailed", { message: translateBackendError(t, e) }), 5000);
}
}
@@ -1509,7 +1509,7 @@ async function onOpenObjectSource(table: SqlObjectNavigationTarget, initialEditi
queryEditorObjectSourceTarget.value = { connectionId: target.connectionId, database: target.database, schema: target.schema, name: target.tableName, objectType, initialEditing };
showQueryEditorObjectSourceDialog.value = true;
} catch (e: any) {
toast(t("connection.connectFailed", { message: translateBackendError(t, e?.message || String(e)) }), 5000);
toast(t("connection.connectFailed", { message: translateBackendError(t, e) }), 5000);
}
}
@@ -1547,7 +1547,7 @@ async function changeActiveConnection(connectionId: string) {
} catch (e: any) {
toast(
t("connection.connectFailed", {
message: translateBackendError(t, e?.message || String(e)),
message: translateBackendError(t, e),
}),
5000,
);
@@ -1502,7 +1502,7 @@ function finishAgentDriverInstall() {
function failAgentDriverInstall(error: unknown) {
agentInstallOperationId.value = null;
agentInstallRunning.value = false;
agentInstallError.value = translateBackendError(t, errorMessage(error));
agentInstallError.value = translateBackendError(t, error);
showAgentInstallDialog.value = true;
}
@@ -1559,7 +1559,7 @@ async function ensureRequiredAgentDriverInstalled(config: ConnectionConfig): Pro
await refreshLocalAgentDrivers();
finishAgentDriverInstall();
} catch (error) {
testResult.value = { ok: false, message: errorMessage(error) };
testResult.value = { ok: false, message: translateBackendError(t, error) };
failAgentDriverInstall(error);
throw error;
}
@@ -1601,7 +1601,7 @@ async function installSqlServerLegacyCompatibilityComponentIfNeeded(): Promise<b
await refreshLocalAgentDrivers();
finishAgentDriverInstall();
} catch (error) {
testResult.value = { ok: false, message: errorMessage(error) };
testResult.value = { ok: false, message: translateBackendError(t, error) };
failAgentDriverInstall(error);
throw error;
}
@@ -79,6 +79,7 @@ import type { ConnectionConfig, QueryTab, SavedSqlFile, TableInfo } from "@/type
import { fetchNamespaceOptionsForConnection, useDatabaseOptions } from "@/composables/useDatabaseOptions";
import { decodeSelectableDatabaseValue, encodeSelectableDatabaseValue, formatDatabaseLabel, resolveDefaultDatabase } from "@/lib/database/defaultDatabase";
import { normalizeSqliteNamespace } from "@/lib/database/sqliteNamespace";
import { isQueryExecutionErrorResult } from "@/lib/query/queryResultError";
import { isSchemaAware } from "@/lib/database/databaseCapabilities";
import ExplainPlanViewer from "@/components/explain/ExplainPlanViewer.vue";
import { parseExplainResult, parseOracleExplainText, type ParsedExplainPlan } from "@/lib/diagram/explainPlan";
@@ -662,8 +663,7 @@ watch(
function selectAction(action: AiAction) {
activeAction.value = action;
if (action === "fix" && props.tab?.result) {
const cols = props.tab.result.columns;
if (cols.includes("Error")) {
if (isQueryExecutionErrorResult(props.tab.result)) {
const errVal = props.tab.result.rows[0]?.[0];
if (errVal != null) prompt.value = String(errVal);
}
@@ -67,6 +67,7 @@ import DataGridQueryControls from "@/components/grid/DataGridQueryControls.vue";
import TemporalCellEditor from "@/components/grid/TemporalCellEditor.vue";
import EnumCellEditor from "@/components/grid/EnumCellEditor.vue";
import type { QueryResult, ColumnInfo, DatabaseType, ForeignKeyInfo, IndexInfo, TriggerInfo, TableInfoTab } from "@/types/database";
import { isQueryExecutionErrorResult } from "@/lib/query/queryResultError";
import { tableObjectSourceKind } from "@/lib/table/tableObjectSourceKind";
import { tableColumnDefaultDisplayValue } from "@/lib/table/tableColumnDefaultPresentation";
import { shouldNavigateFromTableInfoColumnClick } from "@/lib/table/tableInfoColumnNavigation";
@@ -3577,7 +3578,7 @@ watch(
},
{ immediate: true },
);
const isErrorResult = computed(() => props.result.columns.length === 1 && props.result.columns[0] === "Error" && props.result.rows.length > 0);
const isErrorResult = computed(() => isQueryExecutionErrorResult(props.result) && props.result.rows.length > 0);
const errorMessage = computed(() => (isErrorResult.value ? String(props.result.rows[0]?.[0] ?? "") : ""));
// --- Selection composable ---
const selection = useDataGridSelection({
@@ -3,6 +3,7 @@ import { computed, ref, defineAsyncComponent, watch, nextTick, onMounted, onUnmo
import { safeLocalStorageGet, safeLocalStorageSet } from "@/lib/backend/safeStorage";
import { appendDebugLog, isDebugLoggingEnabled } from "@/lib/backend/debugLog";
import { canReloadUnavailableDataTab } from "@/lib/table/tableDataRefresh";
import { isQueryExecutionErrorResult } from "@/lib/query/queryResultError";
import type { CSSProperties } from "vue";
import { useI18n } from "vue-i18n";
import { Check, Columns3Cog, EyeOff, Loader2, Search, TableProperties, ChevronDown, ChevronUp, Inbox, RefreshCcw, Wrench, Toolbox, Database, Download, Upload, X, Pin, Rows3, SquareDashed, Minus, Plus, ShieldAlert, AlignLeft, AlignRight, PanelsTopLeft } from "@lucide/vue";
@@ -340,7 +341,7 @@ const hasNumericData = computed(() => {
const activeQueryError = computed(() => {
const result = props.activeTab.result;
if (!result?.columns.includes("Error")) return "";
if (!result || !isQueryExecutionErrorResult(result)) return "";
return String(result.rows[0]?.[0] ?? "");
});
const hasQueryOutput = computed(
@@ -1480,7 +1481,7 @@ defineExpose({ focusSearch, refreshData, refreshQueryEditorCompletionCache, hand
@export-archive="exportResultArchive"
/>
</template>
<template v-if="activeTab.result?.columns.includes('Error')" #error-actions="{ errorMessage }">
<template v-if="activeTab.result && isQueryExecutionErrorResult(activeTab.result)" #error-actions="{ errorMessage }">
<QueryErrorActions :error-message="String(errorMessage)" :connection-id="activeTab.connectionId" @change-query-timeout="activeTab.connectionId && emit('openConnectionSettings', activeTab.connectionId, 'advanced')" @fix-with-ai="(message) => emit('fixWithAi', message)" />
</template>
</DataGrid>
@@ -1777,7 +1778,7 @@ defineExpose({ focusSearch, refreshData, refreshQueryEditorCompletionCache, hand
@paginate="(offset: number, limit: number, whereInput?: string, orderBy?: string) => emit('paginate', offset, limit, whereInput, orderBy)"
@sort="(column: string, columnIndex: number, direction: 'asc' | 'desc' | null, whereInput?: string, mode?: DataGridSortMode) => emit('sort', column, columnIndex, direction, whereInput, mode)"
>
<template v-if="activeTab.result?.columns.includes('Error')" #error-actions="{ errorMessage }">
<template v-if="activeTab.result && isQueryExecutionErrorResult(activeTab.result)" #error-actions="{ errorMessage }">
<QueryErrorActions :error-message="String(errorMessage)" :connection-id="activeTab.connectionId" @change-query-timeout="activeTab.connectionId && emit('openConnectionSettings', activeTab.connectionId, 'advanced')" @fix-with-ai="(message) => emit('fixWithAi', message)" />
</template>
</DataGrid>
@@ -425,14 +425,14 @@ describe("useSqlExecution", () => {
expect(addHistory).toHaveBeenCalledWith(expect.objectContaining({ success: true, error: undefined }));
});
it("continues to record active non-MySQL errors as failures", async () => {
it("continues to record explicitly marked non-MySQL errors as failures", async () => {
const activeTab = ref<QueryTab | undefined>(queryTab("app"));
const activeConnection = ref<ConnectionConfig | undefined>(connection("postgres"));
const activeOutputView = ref<"result" | "summary" | "explain" | "chart">("result");
const queryStore = useQueryStore();
const historyStore = useHistoryStore();
vi.spyOn(queryStore, "executeCurrentSql").mockImplementation(async () => {
if (activeTab.value) activeTab.value.result = { columns: ["Error"], rows: [["relation does not exist"]], affected_rows: 0, execution_time_ms: 1 };
if (activeTab.value) activeTab.value.result = { columns: ["Error"], rows: [["relation does not exist"]], affected_rows: 0, execution_time_ms: 1, execution_error: true };
});
const addHistory = vi.spyOn(historyStore, "add").mockResolvedValue(undefined);
@@ -448,6 +448,29 @@ describe("useSqlExecution", () => {
expect(addHistory).toHaveBeenCalledWith(expect.objectContaining({ success: false, error: "relation does not exist" }));
});
it("does not treat an unmarked PostgreSQL Error alias as a failure", async () => {
const activeTab = ref<QueryTab | undefined>(queryTab("app"));
const activeConnection = ref<ConnectionConfig | undefined>(connection("postgres"));
const activeOutputView = ref<"result" | "summary" | "explain" | "chart">("result");
const queryStore = useQueryStore();
const historyStore = useHistoryStore();
vi.spyOn(queryStore, "executeCurrentSql").mockImplementation(async () => {
if (activeTab.value) activeTab.value.result = { columns: ["Error"], rows: [[2]], affected_rows: 0, execution_time_ms: 1 };
});
const addHistory = vi.spyOn(historyStore, "add").mockResolvedValue(undefined);
const execution = useSqlExecution({
activeTab: computed(() => activeTab.value),
activeConnection: computed(() => activeConnection.value),
executableSql: computed(() => "SELECT 2 AS Error"),
activeOutputView,
});
await execution.tryExecute();
expect(addHistory).toHaveBeenCalledWith(expect.objectContaining({ success: true, error: undefined }));
});
it("keeps the full dangerous script and new-result-tab intent through confirmation", async () => {
const activeTab = ref<QueryTab | undefined>(queryTab("app"));
const activeConnection = ref<ConnectionConfig | undefined>(connection("mysql"));
@@ -2,7 +2,7 @@ import * as api from "@/lib/backend/api";
import { connectionObjectTreeNodeSchema, effectiveDatabaseTypeForConnection, metadataSchemaForConnection } from "@/lib/database/jdbcDialect";
import { invalidateTableMetadataCache, loadTableMetadata } from "@/lib/metadata/tableMetadataCache";
import { canApplyDataTabMetadata } from "@/lib/sidebar/dataTabOpenPolicy";
import { isNoSnapshotErrorResult } from "@/lib/query/queryResultError";
import { isNoSnapshotErrorResult, isQueryExecutionErrorResult } from "@/lib/query/queryResultError";
import { buildTableSelectSql } from "@/lib/table/tableSelectSql";
import { editableRowIdentifierColumns, usesSyntheticRowIdKey } from "@/lib/table/tableEditing";
import { tableOpenPageLimit } from "@/lib/table/tableOpenPageLimit";
@@ -169,7 +169,7 @@ async function openTableTarget(target: NavigationTarget, options: { tableInfoTab
const tabAfterFirstExecute = queryStore.tabs.find((tab) => tab.id === tabId);
const firstResult = tabAfterFirstExecute?.result;
const cancelRequestedDuringExecute = (tabAfterFirstExecute?.cancelRequestCount ?? 0) > cancelCountBeforeExecute;
const firstQueryFailed = cancelRequestedDuringExecute || tabAfterFirstExecute?.isCancelling === true || (firstResult?.columns.length === 1 && firstResult.columns[0] === "Error");
const firstQueryFailed = cancelRequestedDuringExecute || tabAfterFirstExecute?.isCancelling === true || (firstResult !== undefined && isQueryExecutionErrorResult(firstResult));
// executeTabSql surfaces query failures as an "Error" result instead of throwing.
// A snapshot-less lake table fails the data preview above but its metadata still
// reads fine — retry with LIMIT 0 so the user sees the table structure (columns +
@@ -9,7 +9,7 @@ import { isSingleDatabase, usesTreeSchemaMode } from "@/lib/database/databaseCap
import { supportsConnectionLevelSqlExecution } from "@/lib/connection/connectionLevelDatabaseBootstrap";
import { classifySqlActivityKind } from "@/lib/history/historyActivityKind";
import { sqlMetadataRefreshTarget } from "@/lib/sql/sqlMetadataRefresh";
import { isQueryExecutionErrorResult, usesMysqlProtocolDatabaseType } from "@/lib/query/queryResultError";
import { isQueryExecutionErrorResult } from "@/lib/query/queryResultError";
import { classifyRedisCommandSafety } from "@/lib/redis/redisCommandSafety";
import { isSqlExecutionSnapshot, resolveExecutableSql, type SqlExecutionOverride, type SqlExecutionSnapshot } from "@/lib/sql/sqlExecutionTarget";
import { isElasticsearchRestRequestText, parseElasticsearchRestRequestTarget, splitSqlStatementRanges } from "@/lib/sql/sqlStatementRanges";
@@ -63,10 +63,9 @@ function primarySqlOperation(sql: string): string {
return statement?.match(/^([a-z]+)/i)?.[1]?.toUpperCase() || "SQL";
}
function firstQueryExecutionError(tab: Pick<QueryTab, "result" | "results">, databaseType: DatabaseType | undefined) {
function firstQueryExecutionError(tab: Pick<QueryTab, "result" | "results">) {
const activeResult = tab.result;
if (activeResult && isQueryExecutionErrorResult(activeResult)) return activeResult;
if (!usesMysqlProtocolDatabaseType(databaseType) && activeResult?.columns.includes("Error")) return activeResult;
const results = tab.results?.length ? tab.results : tab.result ? [tab.result] : [];
return results.find((result) => isQueryExecutionErrorResult(result));
@@ -233,7 +232,7 @@ export function useSqlExecution(deps: {
deps.activeOutputView.value = "summary";
}
const elapsed = Date.now() - start;
const failure = firstQueryExecutionError(tab, executionDatabaseType);
const failure = firstQueryExecutionError(tab);
const success = !failure;
historyStore.add({
connection_id: tab.connectionId,
+84
View File
@@ -2,6 +2,7 @@ import { readFileSync } from "node:fs";
import { describe, expect, it as test } from "vitest";
import { createI18n } from "vue-i18n";
import { translateBackendError, type BackendErrorTranslate } from "@/i18n/backend-errors";
import { BackendErrorException, normalizeBackendError } from "@/lib/backend/errorUtils";
import en from "@/i18n/locales/en";
import es from "@/i18n/locales/es";
import it from "@/i18n/locales/it";
@@ -24,6 +25,21 @@ const LOCALES = {
type LocaleKey = keyof typeof LOCALES;
const STRUCTURED_BACKEND_ERROR_KEYS = [
"backendErrors.jdbc.connectionFailed",
"backendErrors.jdbc.connectionInterrupted",
"backendErrors.jdbc.operationTimedOut",
"backendErrors.jdbc.operationCanceled",
"backendErrors.jdbc.busyRetryLater",
"backendErrors.jdbc.runtimeReplaced",
"backendErrors.jdbc.sqlFailed",
"backendErrors.jdbc.protocolFailed",
"backendErrors.jdbc.contractInvalid",
"backendErrors.jdbc.legacyFailure",
"backendErrors.legacy",
"backendErrors.unknown",
] as const;
// Reproduces the exact string crates/dbx-core/src/agent_service.rs builds on
// Windows: `\` line continuations strip the newline plus the following indent.
const WINDOWS_JRE_REMOVE_ERROR = [
@@ -125,6 +141,12 @@ describe("backend error translation", () => {
// alongside the other non-English locales.
const localeKeys = Object.keys(LOCALES) as LocaleKey[];
test.each(localeKeys)("all structured catalog keys exist in %s", (locale) => {
for (const key of STRUCTURED_BACKEND_ERROR_KEYS) {
expect(lookup(LOCALES[locale] as unknown as Record<string, unknown>, key), `${locale}:${key}`).toEqual(expect.any(String));
}
});
describe.each(localeKeys)("in %s", (locale) => {
const t = translatorFor(locale);
@@ -157,6 +179,68 @@ describe("backend error translation", () => {
expect(translateBackendError(t, new Error(message))).toBe(expected);
expect(translateBackendError(t, { message })).toBe(expected);
});
test("preserves and translates structured BackendError envelopes", () => {
const t = translatorFor("zh-CN");
const error = {
version: 1,
code: "DBX-JDBC-2002",
messageKey: "backendErrors.jdbc.operationTimedOut",
messageParams: { stage: "execute" },
source: "jdbcAgent",
operationOutcome: "unknown",
detail: "relation dbx_table_that_does_not_exist does not exist",
} as const;
expect(normalizeBackendError(error)).toEqual(error);
const expected = `${t(error.messageKey, error.messageParams)}\n\n${error.detail}`;
expect(translateBackendError(t, error)).toBe(expected);
expect(translateBackendError(t, new BackendErrorException(error))).toBe(expected);
});
test("shows the database detail after an unclassified Agent summary", () => {
const t = translatorFor("zh-CN");
const error = {
version: 1,
code: "DBX-JDBC-9001",
messageKey: "backendErrors.jdbc.legacyFailure",
messageParams: {},
source: "jdbcAgentLegacy",
operationOutcome: "unknown",
detail: "Table dbx_table_that_does_not_exist does not exist",
} as const;
expect(translateBackendError(t, error)).toBe(`${t(error.messageKey)}\n\n${error.detail}`);
});
test.each([
["array params", { messageParams: ["execute"] }],
["nested params", { messageParams: { stage: { name: "execute" } } }],
["non-finite params", { messageParams: { retryAfter: Number.POSITIVE_INFINITY } }],
["non-string detail", { detail: 42 }],
["object detail", { detail: { message: "database failure" } }],
["unknown source", { source: "http" }],
["unknown outcome", { operationOutcome: "completed" }],
])("rejects malformed structured envelopes with %s", (_name, override) => {
expect(
normalizeBackendError({
version: 1,
code: "DBX-JDBC-2002",
messageKey: "backendErrors.jdbc.operationTimedOut",
messageParams: { stage: "execute" },
source: "jdbcAgent",
operationOutcome: "unknown",
...override,
}),
).toBeNull();
});
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`);
});
});
// Matching on message text only works while both sides agree on the wording, so
+12
View File
@@ -1,3 +1,5 @@
import { normalizeBackendError, type BackendError } from "@/lib/backend/errorUtils";
/**
* Minimal shape of a translate function, satisfied by both `useI18n().t` inside
* components and `i18n.global.t` in stores and composables. Using the full
@@ -118,7 +120,17 @@ function backendErrorMessage(error: unknown): string {
return String(error);
}
function translateStructuredBackendError(t: BackendErrorTranslate, error: BackendError): string {
const translated = t(error.messageKey, error.messageParams);
const summary = translated !== error.messageKey ? translated : t("backendErrors.unknown");
const detail = error.detail?.trim();
return detail && detail !== summary ? `${summary}\n\n${detail}` : summary;
}
export function translateBackendError(t: BackendErrorTranslate, error: unknown): string {
const structured = normalizeBackendError(error);
if (structured) return translateStructuredBackendError(t, structured);
const message = backendErrorMessage(error);
const tagged = message.match(/^\[([A-Za-z][A-Za-z0-9]+)\]\s*([\s\S]*)$/);
if (tagged) {
+16
View File
@@ -1517,6 +1517,22 @@ export default {
mcpDescription: "Beyond the built-in AI assistant, you can also use Claude Code, Cursor, and other coding agents to query your databases via MCP.",
mcpLearnMore: "Learn more",
},
backendErrors: {
jdbc: {
connectionFailed: "Database connection failed before the operation started (stage: {stage}).",
connectionInterrupted: "Database connection was interrupted while the operation was running (stage: {stage}).",
operationTimedOut: "Database operation timed out (stage: {stage}).",
operationCanceled: "Database operation was canceled (stage: {stage}).",
busyRetryLater: "Database agent is busy; try again later (stage: {stage}).",
runtimeReplaced: "Database agent runtime was replaced (stage: {stage}).",
sqlFailed: "Database returned an SQL error (stage: {stage}).",
protocolFailed: "Database agent protocol failed.",
contractInvalid: "Database agent returned an invalid error contract.",
legacyFailure: "Database agent returned an unclassified error.",
},
legacy: "Backend returned an error.",
unknown: "An unexpected backend error occurred.",
},
common: {
fileNotFound: "File does not exist: {path}",
language: "Language",
+16
View File
@@ -1459,6 +1459,22 @@ export default withEnglishFallback({
mcpDescription: "Además del asistente de IA integrado, puedes usar Claude Code, Cursor y otros agentes de programación para consultar tus bases de datos mediante MCP.",
mcpLearnMore: "Más información",
},
backendErrors: {
jdbc: {
connectionFailed: "La conexión a la base de datos falló antes de iniciar la operación (fase: {stage}).",
connectionInterrupted: "La conexión a la base de datos se interrumpió durante la operación (fase: {stage}).",
operationTimedOut: "La operación de la base de datos agotó el tiempo de espera (fase: {stage}).",
operationCanceled: "La operación de la base de datos fue cancelada (fase: {stage}).",
busyRetryLater: "El agente de la base de datos está ocupado; inténtalo más tarde (fase: {stage}).",
runtimeReplaced: "Se reemplazó el entorno del agente de la base de datos (fase: {stage}).",
sqlFailed: "La base de datos devolvió un error SQL (fase: {stage}).",
protocolFailed: "Falló el protocolo del agente de la base de datos.",
contractInvalid: "El agente de la base de datos devolvió un contrato de error no válido.",
legacyFailure: "El agente de la base de datos devolvió un error no clasificado.",
},
legacy: "El backend devolvió un error.",
unknown: "Ocurrió un error inesperado del backend.",
},
common: {
fileNotFound: "El archivo no existe: {path}",
language: "Idioma",
+16
View File
@@ -1457,6 +1457,22 @@ export default withEnglishFallback({
mcpDescription: "Oltre all'assistente AI integrato, puoi utilizzare Claude Code, Cursor e altri agenti di sviluppo per interrogare i tuoi database tramite MCP.",
mcpLearnMore: "Scopri di più",
},
backendErrors: {
jdbc: {
connectionFailed: "La connessione al database è fallita prima dell'operazione (fase: {stage}).",
connectionInterrupted: "La connessione al database è stata interrotta durante l'operazione (fase: {stage}).",
operationTimedOut: "L'operazione del database è scaduta (fase: {stage}).",
operationCanceled: "L'operazione del database è stata annullata (fase: {stage}).",
busyRetryLater: "L'agente del database è occupato; riprova più tardi (fase: {stage}).",
runtimeReplaced: "Il runtime dell'agente del database è stato sostituito (fase: {stage}).",
sqlFailed: "Il database ha restituito un errore SQL (fase: {stage}).",
protocolFailed: "Il protocollo dell'agente del database è fallito.",
contractInvalid: "L'agente del database ha restituito un contratto di errore non valido.",
legacyFailure: "L'agente del database ha restituito un errore non classificato.",
},
legacy: "Il backend ha restituito un errore.",
unknown: "Si è verificato un errore backend imprevisto.",
},
common: {
fileNotFound: "Il file non esiste: {path}",
language: "Lingua",
+16
View File
@@ -1484,6 +1484,22 @@ export default withEnglishFallback({
mcpDescription: "内蔵AIアシスタントに加えて、Claude Code、Cursor、その他のコーディングエージェントをMCP経由でデータベースクエリに使用できます。",
mcpLearnMore: "詳細を見る",
},
backendErrors: {
jdbc: {
connectionFailed: "操作開始前にデータベース接続に失敗しました(段階: {stage})。",
connectionInterrupted: "操作中にデータベース接続が中断されました(段階: {stage})。",
operationTimedOut: "データベース操作がタイムアウトしました(段階: {stage})。",
operationCanceled: "データベース操作をキャンセルしました(段階: {stage})。",
busyRetryLater: "データベース Agent が混雑しています。後でもう一度お試しください(段階: {stage})。",
runtimeReplaced: "データベース Agent ランタイムを置き換えました(段階: {stage})。",
sqlFailed: "データベースが SQL エラーを返しました(段階: {stage})。",
protocolFailed: "データベース Agent プロトコルに失敗しました。",
contractInvalid: "データベース Agent が無効なエラー契約を返しました。",
legacyFailure: "データベース Agent が分類できないエラーを返しました。",
},
legacy: "バックエンドがエラーを返しました。",
unknown: "予期しないバックエンドエラーが発生しました。",
},
common: {
fileNotFound: "ファイルが存在しません: {path}",
language: "言語",
+16
View File
@@ -1485,6 +1485,22 @@ export default withEnglishFallback({
mcpDescription: "내장된 AI 어시스턴트 외에도 Claude Code, Cursor 등의 코딩 에이전트를 MCP로 연결하여 데이터베이스를 쿼리할 수 있습니다.",
mcpLearnMore: "더 알아보기",
},
backendErrors: {
jdbc: {
connectionFailed: "작업 시작 전에 데이터베이스 연결에 실패했습니다(단계: {stage}).",
connectionInterrupted: "작업 중 데이터베이스 연결이 중단되었습니다(단계: {stage}).",
operationTimedOut: "데이터베이스 작업 시간이 초과되었습니다(단계: {stage}).",
operationCanceled: "데이터베이스 작업이 취소되었습니다(단계: {stage}).",
busyRetryLater: "데이터베이스 Agent가 사용 중입니다. 나중에 다시 시도하세요(단계: {stage}).",
runtimeReplaced: "데이터베이스 Agent 런타임이 교체되었습니다(단계: {stage}).",
sqlFailed: "데이터베이스에서 SQL 오류를 반환했습니다(단계: {stage}).",
protocolFailed: "데이터베이스 Agent 프로토콜에 실패했습니다.",
contractInvalid: "데이터베이스 Agent가 잘못된 오류 계약을 반환했습니다.",
legacyFailure: "데이터베이스 Agent가 분류되지 않은 오류를 반환했습니다.",
},
legacy: "백엔드에서 오류를 반환했습니다.",
unknown: "예상하지 못한 백엔드 오류가 발생했습니다.",
},
common: {
fileNotFound: "파일이 존재하지 않습니다: {path}",
language: "언어",
+16
View File
@@ -1459,6 +1459,22 @@ export default withEnglishFallback({
mcpDescription: "Além do assistente de IA integrado, você também pode usar Claude Code, Cursor e outros agentes de codificação para consultar seus bancos de dados via MCP.",
mcpLearnMore: "Saiba mais",
},
backendErrors: {
jdbc: {
connectionFailed: "A conexão com o banco falhou antes do início da operação (etapa: {stage}).",
connectionInterrupted: "A conexão com o banco foi interrompida durante a operação (etapa: {stage}).",
operationTimedOut: "A operação do banco excedeu o tempo limite (etapa: {stage}).",
operationCanceled: "A operação do banco foi cancelada (etapa: {stage}).",
busyRetryLater: "O Agent do banco está ocupado; tente novamente mais tarde (etapa: {stage}).",
runtimeReplaced: "O runtime do Agent do banco foi substituído (etapa: {stage}).",
sqlFailed: "O banco retornou um erro SQL (etapa: {stage}).",
protocolFailed: "O protocolo do Agent do banco falhou.",
contractInvalid: "O Agent do banco retornou um contrato de erro inválido.",
legacyFailure: "O Agent do banco retornou um erro não classificado.",
},
legacy: "O backend retornou um erro.",
unknown: "Ocorreu um erro inesperado no backend.",
},
common: {
fileNotFound: "O arquivo não existe: {path}",
language: "Idioma",
+16
View File
@@ -1518,6 +1518,22 @@ export default withEnglishFallback({
mcpDescription: "除了内置 AI 助手,还可以通过 MCP 让 Claude Code、Cursor 等编程助手直接使用这里的数据库连接。",
mcpLearnMore: "了解更多",
},
backendErrors: {
jdbc: {
connectionFailed: "数据库连接在操作开始前失败(阶段:{stage})。",
connectionInterrupted: "数据库连接在操作执行期间中断(阶段:{stage})。",
operationTimedOut: "数据库操作超时(阶段:{stage})。",
operationCanceled: "数据库操作已取消(阶段:{stage})。",
busyRetryLater: "数据库 Agent 忙碌,请稍后重试(阶段:{stage})。",
runtimeReplaced: "数据库 Agent 运行时已替换(阶段:{stage})。",
sqlFailed: "数据库返回了 SQL 错误(阶段:{stage})。",
protocolFailed: "数据库 Agent 协议失败。",
contractInvalid: "数据库 Agent 返回了无效的错误契约。",
legacyFailure: "数据库 Agent 返回了未分类错误。",
},
legacy: "后端返回了错误。",
unknown: "发生了未预期的后端错误。",
},
common: {
fileNotFound: "文件不存在:{path}",
language: "语言",
+16
View File
@@ -1458,6 +1458,22 @@ export default withEnglishFallback({
mcpDescription: "除了內建 AI 助理,也可以藉由 MCP 讓 Claude Code、Cursor 等程式設計助理查詢你的資料庫。",
mcpLearnMore: "了解更多",
},
backendErrors: {
jdbc: {
connectionFailed: "資料庫連線在操作開始前失敗(階段:{stage})。",
connectionInterrupted: "資料庫連線在操作執行期間中斷(階段:{stage})。",
operationTimedOut: "資料庫操作逾時(階段:{stage})。",
operationCanceled: "資料庫操作已取消(階段:{stage})。",
busyRetryLater: "資料庫 Agent 忙碌,請稍後再試(階段:{stage})。",
runtimeReplaced: "資料庫 Agent 執行環境已替換(階段:{stage})。",
sqlFailed: "資料庫回傳了 SQL 錯誤(階段:{stage})。",
protocolFailed: "資料庫 Agent 通訊協定失敗。",
contractInvalid: "資料庫 Agent 回傳了無效的錯誤契約。",
legacyFailure: "資料庫 Agent 回傳了未分類錯誤。",
},
legacy: "後端回傳了錯誤。",
unknown: "發生未預期的後端錯誤。",
},
common: {
fileNotFound: "檔案不存在:{path}",
language: "語言",
@@ -14,29 +14,30 @@ function dataResult(columns: string[]): QueryResult {
describe("isNoSnapshotErrorResult", () => {
it("matches the StarRocks Paimon no-snapshot error surfaced via executeTabSql", () => {
const result = errorResult("Server error: `ERROR HY000 (1064): There is currently no snapshot.`");
const result = { ...errorResult("Server error: `ERROR HY000 (1064): There is currently no snapshot.`"), execution_error: true as const };
expect(isNoSnapshotErrorResult(result)).toBe(true);
});
it("matches case-insensitively", () => {
const result = errorResult("there IS currently NO snapshot");
const result = { ...errorResult("there IS currently NO snapshot"), execution_error: true as const };
expect(isNoSnapshotErrorResult(result)).toBe(true);
});
it("does not match unrelated query errors", () => {
expect(isNoSnapshotErrorResult(errorResult("Unknown table 'tag_test.record_tag_t'"))).toBe(false);
expect(isNoSnapshotErrorResult(errorResult("ERROR 1142: SELECT command denied"))).toBe(false);
expect(isNoSnapshotErrorResult({ ...errorResult("Unknown table 'tag_test.record_tag_t'"), execution_error: true })).toBe(false);
expect(isNoSnapshotErrorResult({ ...errorResult("ERROR 1142: SELECT command denied"), execution_error: true })).toBe(false);
});
it("does not match successful data results", () => {
expect(isNoSnapshotErrorResult(dataResult(["id", "name"]))).toBe(false);
expect(isNoSnapshotErrorResult(dataResult(["Error"]))).toBe(false); // data column literally named Error, no rows
expect(isNoSnapshotErrorResult({ ...errorResult("There is currently no snapshot."), rows: [["There is currently no snapshot."]] })).toBe(false);
});
it("returns false for missing or empty results", () => {
expect(isNoSnapshotErrorResult(undefined)).toBe(false);
expect(isNoSnapshotErrorResult(null)).toBe(false);
expect(isNoSnapshotErrorResult({ ...errorResult("There is currently no snapshot."), rows: [] })).toBe(false);
expect(isNoSnapshotErrorResult({ ...errorResult("There is currently no snapshot."), execution_error: true, rows: [] })).toBe(false);
});
});
@@ -60,11 +60,17 @@ describe("SQL Server explain plan", () => {
});
it("surfaces a SQL Server batch error instead of treating it as a plan", () => {
expect(sqlServerExplainResult([result(["Error"], [["Invalid object name 'missing_table'"]])])).toEqual({
expect(sqlServerExplainResult([{ ...result(["Error"], [["Invalid object name 'missing_table'"]]), execution_error: true }])).toEqual({
error: "Invalid object name 'missing_table'",
});
});
it("does not mistake a successful Error column for a SQL Server batch failure", () => {
expect(sqlServerExplainResult([result(["Error"], [["valid query value"]])])).toEqual({
error: "SQL Server did not return ShowPlan XML",
});
});
it("rejects malformed SHOWPLAN XML", () => {
const malformed = result(["Microsoft SQL Server 2005 XML Showplan"], [["<ShowPlanXML><RelOp></ShowPlanXML>"]]);
+13 -1
View File
@@ -1,7 +1,7 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { createPinia, setActivePinia } from "pinia";
import { useConnectionStore } from "@/stores/connectionStore";
import { connectionGroupDisplayName, middleEllipsis, queryResultBaseSql, queryResultExecutionSql, resultSourceRange, statementExecutionMarkers, tabTooltipLines, tabularResultItems } from "@/lib/tabs/tabPresentation";
import { connectionGroupDisplayName, executionSummaryItems, middleEllipsis, queryResultBaseSql, queryResultExecutionSql, resultSourceRange, statementExecutionMarkers, tabTooltipLines, tabularResultItems } from "@/lib/tabs/tabPresentation";
import { sqlTextFingerprint } from "@/lib/sql/sqlTextFingerprint";
import type { ConnectionConfig, QueryTab } from "@/types/database";
@@ -224,6 +224,18 @@ describe("query result source ranges", () => {
});
});
describe("execution summary", () => {
it("uses the explicit execution marker instead of the result column name", () => {
const successfulAlias = { columns: ["Error"], rows: [[2]], affected_rows: 0, execution_time_ms: 1 };
const markedFailure = { columns: ["Error"], rows: [["failed"]], affected_rows: 0, execution_time_ms: 1, execution_error: true as const };
expect(executionSummaryItems({ results: [successfulAlias, markedFailure] }).map(({ status, isError }) => ({ status, isError }))).toEqual([
{ status: "success", isError: false },
{ status: "error", isError: true },
]);
});
});
describe("statement execution markers", () => {
it("renders a live marker for a single statement", () => {
const sql = "SELECT 1";
+3 -2
View File
@@ -9,6 +9,7 @@ import { aiSkillForAction } from "@/lib/ai/aiSkills";
import { isSchemaAware } from "@/lib/database/databaseCapabilities";
import { effectiveDatabaseTypeForConnection } from "@/lib/database/jdbcDialect";
import { normalizeSqliteNamespace } from "@/lib/database/sqliteNamespace";
import { isQueryExecutionErrorResult } from "@/lib/query/queryResultError";
import type { AgentEvent } from "@/lib/backend/tauri";
@@ -674,12 +675,12 @@ function prioritizeSchemas(schemas: string[]): string[] {
}
function extractLastError(result?: QueryResult): string | undefined {
if (!result?.columns.includes("Error")) return undefined;
if (!result || !isQueryExecutionErrorResult(result)) return undefined;
return result.rows[0]?.[0] == null ? undefined : String(result.rows[0][0]);
}
function formatResultPreview(result?: QueryResult): string | undefined {
if (!result || result.columns.includes("Error") || !result.rows.length) return undefined;
if (!result || isQueryExecutionErrorResult(result) || !result.rows.length) return undefined;
const MAX_VALUE_CHARS = 200;
const rows = result.rows.slice(0, 5).map((row) => {
return result.columns
@@ -2,6 +2,87 @@
* Utility functions for consistent error handling across the application.
*/
export type BackendErrorParam = string | number | boolean;
export interface BackendError {
version: 1;
code: string;
messageKey: string;
messageParams: Record<string, BackendErrorParam>;
source: "jdbcAgent" | "jdbcAgentLegacy" | "legacyBackend";
operationOutcome: "not_started" | "unknown";
detail?: string;
diagnostics?: Record<string, unknown>;
helpUrl?: string;
}
function isBackendError(value: unknown): value is BackendError {
if (!value || typeof value !== "object") return false;
const candidate = value as Record<string, unknown>;
if (
candidate.version !== 1 ||
typeof candidate.code !== "string" ||
!/^DBX-[A-Z][A-Z0-9]*-\d{4}$/.test(candidate.code) ||
typeof candidate.messageKey !== "string" ||
!candidate.messageKey.startsWith("backendErrors.") ||
!candidate.messageParams ||
typeof candidate.messageParams !== "object" ||
Array.isArray(candidate.messageParams) ||
!["jdbcAgent", "jdbcAgentLegacy", "legacyBackend"].includes(String(candidate.source)) ||
!["not_started", "unknown"].includes(String(candidate.operationOutcome))
) {
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;
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;
}
if (error && typeof error === "object" && "error" in error) {
const nested = (error as { error: unknown }).error;
if (isBackendError(nested)) return nested;
}
return null;
}
export class BackendErrorException extends Error {
readonly backendError: BackendError;
constructor(error: unknown) {
const backendError = normalizeRawBackendError(error);
const fallbackMessage = typeof error === "string" ? error : error instanceof Error ? error.message : "Backend request failed";
super(backendError?.detail || fallbackMessage);
this.name = "BackendErrorException";
this.backendError = backendError ?? {
version: 1,
code: "DBX-LEGACY-0001",
messageKey: "backendErrors.legacy",
messageParams: {},
source: "legacyBackend",
operationOutcome: "unknown",
detail: fallbackMessage,
};
}
}
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);
}
/**
* Formats an unknown error value into a human-readable string.
* Handles Error objects, strings, null/undefined, and other types.
@@ -17,6 +98,9 @@
* }
*/
export function formatError(e: unknown): string {
const backendError = normalizeBackendError(e);
if (backendError?.detail) return backendError.detail;
if (e instanceof Error) {
return e.message;
}
+18 -6
View File
@@ -45,6 +45,7 @@ import type {
TunnelProfile,
} from "@/types/database";
import { normalizeRustMongoCommand, type MongoCommand } from "@/lib/mongo/mongoShellCommand";
import { BackendErrorException, type BackendError } from "@/lib/backend/errorUtils";
import type { CollectionInfo } from "@/types/database";
import type { SchemaDiffPreparation, SchemaDiffPreparationOptions, TableDiff, FunctionDiff, SequenceDiff, RuleDiff, OwnerDiff } from "@/lib/schema/schemaDiff";
import type { SidebarObjectKind } from "@/lib/database/databaseObjectCapabilities";
@@ -223,19 +224,19 @@ async function post<T>(url: string, body: unknown): Promise<T> {
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
});
if (!res.ok) throw new Error(await res.text());
if (!res.ok) throw await backendResponseError(res);
return res.json();
}
async function get<T>(url: string): Promise<T> {
const res = await fetch(apiUrl(url));
if (!res.ok) throw new Error(await res.text());
if (!res.ok) throw await backendResponseError(res);
return res.json();
}
async function del<T>(url: string): Promise<T> {
const res = await fetch(apiUrl(url), { method: "DELETE" });
if (!res.ok) throw new Error(await res.text());
if (!res.ok) throw await backendResponseError(res);
return res.json();
}
@@ -245,10 +246,21 @@ async function put<T>(url: string, body: unknown): Promise<T> {
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
});
if (!res.ok) throw new Error(await res.text());
if (!res.ok) throw await backendResponseError(res);
return res.json();
}
async function backendResponseError(response: Response): Promise<BackendErrorException> {
const text = await response.text();
let payload: unknown = text;
try {
payload = JSON.parse(text);
} catch {
// Preserve legacy plain-text responses at the same compatibility boundary.
}
return new BackendErrorException(payload);
}
function qs(params: Record<string, string | number | boolean | undefined>): string {
const sp = new URLSearchParams();
for (const [k, v] of Object.entries(params)) {
@@ -880,7 +892,7 @@ export interface ExecuteMultiProgress {
success: boolean;
executionTimeMs: number;
affectedRows: number;
error?: string;
error?: BackendError;
}
export async function executeMultiWithProgress(
@@ -918,7 +930,7 @@ export async function executeMultiWithProgress(
success,
executionTimeMs: result.execution_time_ms,
affectedRows: result.affected_rows,
error: success ? undefined : String(result.rows[0]?.[0] ?? ""),
error: success ? undefined : result.error,
});
});
return results;
+28 -17
View File
@@ -1,4 +1,5 @@
import { invoke } from "@tauri-apps/api/core";
import { BackendErrorException, type BackendError } from "@/lib/backend/errorUtils";
import { listen, type UnlistenFn } from "@tauri-apps/api/event";
import { normalizeRustMongoCommand, type MongoCommand } from "@/lib/mongo/mongoShellCommand";
import { ExternalSqlFileTooLargeError } from "@/lib/sql/sqlFileOpen";
@@ -1060,14 +1061,18 @@ export async function executeQuery(
executionMode?: "simple";
},
): Promise<QueryResult> {
return invoke("execute_query", {
connectionId,
database,
sql,
schema,
executionId,
...options,
});
try {
return await invoke("execute_query", {
connectionId,
database,
sql,
schema,
executionId,
...options,
});
} catch (error) {
throw new BackendErrorException(error);
}
}
export async function executeMulti(
@@ -1089,14 +1094,18 @@ export async function executeMulti(
executionMode?: "simple";
},
): Promise<QueryResult[]> {
return invoke("execute_multi", {
connectionId,
database,
sql,
schema,
executionId,
...options,
});
try {
return await invoke("execute_multi", {
connectionId,
database,
sql,
schema,
executionId,
...options,
});
} catch (error) {
throw new BackendErrorException(error);
}
}
export interface ExecuteMultiProgress {
@@ -1107,7 +1116,7 @@ export interface ExecuteMultiProgress {
success: boolean;
executionTimeMs: number;
affectedRows: number;
error?: string;
error?: BackendError;
}
export async function executeMultiWithProgress(
@@ -1137,6 +1146,8 @@ export async function executeMultiWithProgress(
});
try {
return await invoke("execute_multi", { connectionId, database, sql, schema, executionId, ...invokeOptions });
} catch (error) {
throw new BackendErrorException(error);
} finally {
unlisten();
}
+2 -1
View File
@@ -1,6 +1,7 @@
import type { DatabaseType, QueryResult } from "@/types/database";
import * as api from "@/lib/backend/api";
import { supportsDatabaseFeature } from "@/lib/database/databaseDriverManifest";
import { isQueryExecutionErrorResult } from "@/lib/query/queryResultError";
export interface ExplainPlanNode {
id: string;
@@ -376,7 +377,7 @@ export function flattenExplainPlanNodes(nodes: ExplainPlanNode[]): ExplainPlanNo
}
export function sqlServerExplainResult(results: QueryResult[]): { result?: QueryResult; error?: string } {
const errorResult = results.find((result) => result.columns.length === 1 && result.columns[0] === "Error" && result.rows.length > 0);
const errorResult = results.find((result) => isQueryExecutionErrorResult(result) && result.rows.length > 0);
if (errorResult) return { error: String(errorResult.rows[0]?.[0] ?? "") };
const result = results.find((candidate) => firstSqlServerShowplanXml(candidate) !== undefined);
@@ -25,6 +25,6 @@ export function isQueryExecutionErrorResult(result: QueryResult): boolean {
}
export function isNoSnapshotErrorResult(result: QueryResult | undefined | null): boolean {
if (!result || !result.columns.includes("Error") || result.rows.length === 0) return false;
if (!result || !isQueryExecutionErrorResult(result) || result.rows.length === 0) return false;
return NO_SNAPSHOT_ERROR_PATTERN.test(String(result.rows[0]?.[0] ?? ""));
}
@@ -1,14 +1,11 @@
import type { QueryResult, QueryTab } from "@/types/database";
import type { QueryTab } from "@/types/database";
import { isQueryExecutionErrorResult } from "@/lib/query/queryResultError";
export type DataTableDoubleClickAction = "activate" | "open" | "none";
function isErrorResult(result: QueryResult | undefined): boolean {
return result?.columns.length === 1 && result.columns[0] === "Error";
}
export function canActivateExistingDataTableTab(tab: QueryTab, options: { activateExecuting?: boolean } = {}): boolean {
if (tab.isExecuting) return options.activateExecuting !== false;
if (isErrorResult(tab.result)) return false;
if (tab.result && isQueryExecutionErrorResult(tab.result)) return false;
return !!tab.result || !!tab.results?.length;
}
+2 -1
View File
@@ -4,6 +4,7 @@ import { findConnectionGroupPath } from "@/lib/sidebar/sidebarLayout";
import { splitMongoCommandRanges } from "@/lib/mongo/mongoShellCommand";
import { executableStatementRanges, splitSqlStatementRanges, type SqlTextRange } from "@/lib/sql/sqlStatementRanges";
import { sqlTextFingerprint } from "@/lib/sql/sqlTextFingerprint";
import { isQueryExecutionErrorResult } from "@/lib/query/queryResultError";
import type { BatchSqlExecution, ConnectionConfig, DatabaseType, QueryResult, QueryTab } from "@/types/database";
type Translate = (key: string, params?: Record<string, unknown>) => string;
@@ -378,7 +379,7 @@ export function executionSummaryItems(tab: Pick<QueryTab, "result" | "results" |
});
}
return results.map((result, index) => {
const isError = result.execution_error === true || result.columns.includes("Error");
const isError = isQueryExecutionErrorResult(result);
return {
result,
index,
@@ -76,6 +76,17 @@ function deferred<T>() {
return { promise, resolve, reject };
}
function structuredTimeoutError() {
return {
version: 1 as const,
code: "DBX-JDBC-2002",
messageKey: "backendErrors.jdbc.operationTimedOut",
messageParams: { stage: "execute" },
source: "jdbcAgent" as const,
operationOutcome: "unknown" as const,
};
}
describe("queryStore multi-statement errors", () => {
beforeEach(() => {
vi.clearAllMocks();
@@ -115,7 +126,7 @@ describe("queryStore multi-statement errors", () => {
success,
executionTimeMs: result.execution_time_ms,
affectedRows: result.affected_rows,
error: success ? undefined : String(result.rows[0]?.[0] ?? ""),
error: success ? undefined : result.error,
});
});
return results;
@@ -123,9 +134,10 @@ describe("queryStore multi-statement errors", () => {
});
it("opens the first error result from a mixed result batch", async () => {
const structuredError = structuredTimeoutError();
mocks.executeMulti.mockResolvedValue([
{ columns: ["value"], rows: [[1]], affected_rows: 0, execution_time_ms: 1 },
{ columns: ["Error"], execution_error: true, rows: [["no such table: missing"]], affected_rows: 0, execution_time_ms: 1 },
{ columns: ["Error"], execution_error: true, error: structuredError, rows: [["no such table: missing"]], affected_rows: 0, execution_time_ms: 1 },
]);
const { useQueryStore } = await import("@/stores/queryStore");
const store = useQueryStore();
@@ -136,6 +148,10 @@ describe("queryStore multi-statement errors", () => {
const tab = store.tabs.find((item) => item.id === tabId)!;
expect(tab.activeResultIndex).toBe(1);
expect(tab.result?.columns).toEqual(["Error"]);
expect(tab.result?.error).toEqual(structuredError);
expect(tab.batchSqlExecution?.items[1]?.errorDetails).toEqual(structuredError);
expect(tab.batchSqlExecution?.items[1]?.error).not.toBe(structuredError.code);
expect(tab.batchSqlExecution?.items[1]?.error).not.toBe("[object Object]");
});
it("updates live per-statement progress before the batch promise resolves", async () => {
@@ -195,6 +211,24 @@ describe("queryStore multi-statement errors", () => {
});
});
it("preserves a structured top-level batch failure", async () => {
const structuredError = structuredTimeoutError();
mocks.executeMultiWithProgress.mockRejectedValueOnce(structuredError);
const { useQueryStore } = await import("@/stores/queryStore");
const store = useQueryStore();
const tabId = store.createTab("mysql-1", "app", "Query", "query", undefined, "SELECT 1;\nSELECT 2");
await store.executeTabSql(tabId, "SELECT 1;\nSELECT 2");
const tab = store.tabs.find((item) => item.id === tabId)!;
expect(tab.result?.error).toEqual(structuredError);
expect(tab.result?.rows[0]?.[0]).toEqual(expect.any(String));
expect(tab.result?.rows[0]?.[0]).not.toBe(structuredError.code);
expect(tab.result?.rows[0]?.[0]).not.toBe("[object Object]");
expect(tab.batchSqlExecution?.items[0]?.errorDetails).toEqual(structuredError);
expect(tab.batchSqlExecution?.items[0]?.error).toBe(tab.result?.rows[0]?.[0]);
});
it("keeps completed progress and records a later top-level batch failure", async () => {
mocks.executeMultiWithProgress.mockImplementationOnce((_connectionId, _database, _sql, onProgress, _schema, options) => {
onProgress({
@@ -359,7 +393,7 @@ describe("queryStore multi-statement errors", () => {
database: "FooDB",
query_timeout_secs: 30,
});
mocks.executeMulti.mockResolvedValueOnce([{ columns: [], rows: [], affected_rows: 0, execution_time_ms: 1 }]).mockResolvedValueOnce([{ columns: ["Error"], rows: [["Database does not exist"]], affected_rows: 0, execution_time_ms: 1 }]);
mocks.executeMulti.mockResolvedValueOnce([{ columns: [], rows: [], affected_rows: 0, execution_time_ms: 1 }]).mockResolvedValueOnce([{ columns: ["Error"], rows: [["Database does not exist"]], affected_rows: 0, execution_time_ms: 1, execution_error: true }]);
const { useQueryStore } = await import("@/stores/queryStore");
const store = useQueryStore();
const tabA = store.createTab("sqlserver-1", "FooDB", "Tab A", "query", "dbo");
+17 -15
View File
@@ -61,7 +61,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 } from "@/lib/backend/errorUtils";
import { 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";
@@ -278,7 +278,7 @@ function applyBatchSqlProgress(
success: boolean;
executionTimeMs: number;
affectedRows: number;
error?: string;
error?: BackendError;
},
continueOnError: boolean,
) {
@@ -289,7 +289,8 @@ function applyBatchSqlProgress(
item.status = progress.success ? "success" : "error";
item.executionTimeMs = progress.executionTimeMs;
item.affectedRows = progress.affectedRows;
item.error = progress.error;
item.errorDetails = progress.error;
item.error = progress.error ? translateBackendError(i18n.global.t, progress.error) : undefined;
batch.completed = Math.max(batch.completed, progress.completed);
if ((progress.success || continueOnError) && progress.completed < batch.total) {
const next = batch.items[progress.statementIndex + 1];
@@ -310,7 +311,8 @@ function reconcileBatchSqlResults(tab: QueryTab, executionId: string, results: Q
item.status = failed ? "error" : "success";
item.executionTimeMs = result.execution_time_ms;
item.affectedRows = result.affected_rows;
item.error = failed ? String(result.rows[0]?.[0] ?? "") : undefined;
item.errorDetails = failed ? result.error : undefined;
item.error = failed ? (result.error ? translateBackendError(i18n.global.t, result.error) : String(result.rows[0]?.[0] ?? "")) : undefined;
}
batch.completed = batch.items.filter((item) => item.status === "success" || item.status === "error").length;
}
@@ -321,7 +323,8 @@ function failBatchSqlExecution(tab: QueryTab, executionId: string, error: unknow
const item = batch.items.find((candidate) => candidate.status === "running") ?? batch.items.find((candidate) => candidate.status === "pending");
if (!item) return;
item.status = cancelled ? "cancelled" : "error";
item.error = cancelled ? undefined : error instanceof Error ? error.message : String(error);
item.errorDetails = cancelled ? undefined : (normalizeBackendError(error) ?? undefined);
item.error = cancelled ? undefined : translateBackendError(i18n.global.t, error);
batch.completed = batch.items.filter((candidate) => candidate.status === "success" || candidate.status === "error").length;
}
@@ -329,7 +332,7 @@ function finishBatchSqlExecution(tab: QueryTab, executionId: string, cancelled:
const batch = batchSqlExecutionFor(tab, executionId);
if (!batch) return;
if (cancelled) {
const cancelledError = [...batch.items].reverse().find((item) => item.status === "error" && /cancel|取消/i.test(item.error ?? ""));
const cancelledError = [...batch.items].reverse().find((item) => item.status === "error" && (item.errorDetails?.code === "DBX-JDBC-2003" || /cancel|取消/i.test(item.error ?? "")));
if (cancelledError) {
cancelledError.status = "cancelled";
cancelledError.error = undefined;
@@ -384,9 +387,7 @@ function sqlServerUseDatabaseFromStatement(statement: string | undefined): strin
}
function isSqlServerBatchErrorResult(result: QueryResult): boolean {
// SQL Server batch errors can arrive without execution_error metadata.
// A standalone USE statement cannot legitimately return an Error column.
return result.execution_error === true || (result.columns.length === 1 && result.columns[0] === "Error" && result.rows.length > 0);
return result.execution_error === true;
}
function sapHanaCurrentSchemaFromResult(result: QueryResult): string | undefined {
@@ -2717,13 +2718,14 @@ export const useQueryStore = defineStore("query", () => {
}
function toErrorResult(e: any): NonNullable<QueryTab["result"]> {
const raw = e instanceof Error ? e.message : String(e);
// Single funnel for every query execution failure, so backend messages DBX
// knows about are shown in the active locale rather than as raw English.
const message = translateBackendError(i18n.global.t, raw);
const error = normalizeBackendError(e) ?? undefined;
const message = translateBackendError(i18n.global.t, e);
return markQueryResultRowsRaw({
columns: ["Error"],
execution_error: true,
error,
rows: [[message]],
affected_rows: 0,
execution_time_ms: 0,
@@ -3422,7 +3424,7 @@ export const useQueryStore = defineStore("query", () => {
connStore.invalidateCompletionCache(tab.connectionId, String(currentDb));
}
} catch (e: any) {
allResults.push(annotateQueryResultSource({ columns: ["Error"], rows: [[e?.message ?? String(e)]], affected_rows: 0, execution_time_ms: 0 }, command, undefined, undefined, sourceRange));
allResults.push(annotateQueryResultSource(toErrorResult(e), command, undefined, undefined, sourceRange));
}
}
queryExecutionLog("info", "redis:done", { traceId, commandCount: commands.length, elapsed: elapsed() });
@@ -3431,7 +3433,7 @@ export const useQueryStore = defineStore("query", () => {
if (current?.executionId === executionId) {
if (openInNewResultTab && current.isCancelling && restorePendingResultRun(current, executionId)) return false;
if (allResults.length > 1) {
const activeResultIndex = allResults.findIndex((r) => !r.columns.includes("Error"));
const activeResultIndex = allResults.findIndex((result) => !isQueryExecutionErrorResult(result));
const resultIndex = preservedResultIndex(allResults, current.activeResultIndex, options?.preserveActiveResultIndex) ?? (activeResultIndex >= 0 ? activeResultIndex : 0);
current.results = allResults;
current.activeResultIndex = resultIndex;
@@ -3770,7 +3772,7 @@ export const useQueryStore = defineStore("query", () => {
} else if (allResults.length > 1) {
// Open grouped output on the first non-error result when possible so
// mixed success/error batches land on the most useful table first.
const activeResultIndex = allResults.findIndex((result) => !result.columns.includes("Error"));
const activeResultIndex = allResults.findIndex((result) => !isQueryExecutionErrorResult(result));
const resultIndex = preservedResultIndex(allResults, current.activeResultIndex, options?.preserveActiveResultIndex) ?? (activeResultIndex >= 0 ? activeResultIndex : 0);
current.results = allResults;
current.activeResultIndex = resultIndex;
@@ -3839,7 +3841,7 @@ export const useQueryStore = defineStore("query", () => {
if (current?.executionId === executionId && openInNewResultTab && current.isCancelling && restorePendingResultRun(current, executionId)) return false;
if (current?.executionId === executionId && allResults.length > 0) {
clearResultNavigationState(current);
const errorResultIndex = allResults.findIndex((result) => result.columns.includes("Error") || elasticsearchHttpErrorStatus(result) !== undefined);
const errorResultIndex = allResults.findIndex((result) => isQueryExecutionErrorResult(result) || elasticsearchHttpErrorStatus(result) !== undefined);
const resultIndex = errorResultIndex >= 0 ? errorResultIndex : 0;
current.results = allResults.length > 1 ? allResults : undefined;
current.activeResultIndex = allResults.length > 1 ? resultIndex : undefined;
+5
View File
@@ -1,3 +1,5 @@
import type { BackendError } from "@/lib/backend/errorUtils";
export type DatabaseType =
| "mysql"
| "postgres"
@@ -574,6 +576,8 @@ export interface QueryResult {
appended_from_row_count?: number;
/** Set for synthesized query execution failures. */
execution_error?: true;
/** Structured backend error; authoritative when execution_error is true. */
error?: BackendError;
/** Zero-based index of the submitted statement that produced this result. */
statement_index?: number;
/** Internal row identifiers appended to editable query results. */
@@ -630,6 +634,7 @@ export interface BatchStatementExecutionItem {
executionTimeMs?: number;
affectedRows?: number;
error?: string;
errorDetails?: BackendError;
}
export interface BatchSqlExecution {
@@ -2,9 +2,9 @@
"protocolVersion": 2,
"handshakeMethod": "handshake",
"handshakeResponseFields": ["protocolVersion", "agentProtocolVersion", "capabilities"],
"allCapabilities": ["connect", "test_connection", "metadata", "query", "paged_query", "transaction", "ddl", "kv", "kv_ttl", "kv_cas", "kv_list_values", "kv_status", "kv_history", "etcd_compaction", "etcd_defrag", "etcd_watch", "etcd_lease", "etcd_auth", "multi_session"],
"allCapabilities": ["connect", "test_connection", "metadata", "query", "paged_query", "transaction", "ddl", "kv", "kv_ttl", "kv_cas", "kv_list_values", "kv_status", "kv_history", "etcd_compaction", "etcd_defrag", "etcd_watch", "etcd_lease", "etcd_auth", "multi_session", "structured_error_v1"],
"capabilities": ["connect", "test_connection", "metadata", "query", "paged_query", "transaction", "ddl", "multi_session"],
"defaultSqlCapabilities": ["connect", "test_connection", "metadata", "query", "paged_query", "transaction", "ddl", "multi_session"],
"defaultSqlCapabilities": ["connect", "test_connection", "metadata", "query", "paged_query", "transaction", "ddl", "multi_session", "structured_error_v1"],
"commonMethods": [
"handshake",
"connect",
+87
View File
@@ -0,0 +1,87 @@
//! Recovery decisions derived from typed JDBC Agent failures.
use crate::db::agent_driver::{AgentCallError, AgentErrorCategory, AgentSessionDisposition};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum RecoveryScope {
UserOperation,
ReadOnlyMetadata { retried: bool },
Keepalive,
ConnectionOpen,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum RecoveryDecision {
KeepSession,
RetryReadOnlyMetadata,
QuarantineSession,
ReplaceRuntime,
}
impl RecoveryDecision {
pub fn discards_session(self) -> bool {
!matches!(self, Self::KeepSession)
}
pub fn replaces_runtime(self) -> bool {
matches!(self, Self::ReplaceRuntime)
}
}
pub struct RecoveryPolicy;
impl RecoveryPolicy {
pub fn decide(error: &AgentCallError, scope: RecoveryScope) -> RecoveryDecision {
match error {
AgentCallError::ContractViolation { .. } => RecoveryDecision::QuarantineSession,
AgentCallError::Transport { .. } => RecoveryDecision::ReplaceRuntime,
AgentCallError::Timeout { .. } | AgentCallError::Canceled { .. } => RecoveryDecision::QuarantineSession,
AgentCallError::Structured { context, .. } => {
if matches!(context.category, AgentErrorCategory::Timeout | AgentErrorCategory::Canceled) {
return RecoveryDecision::QuarantineSession;
}
Self::from_disposition(Some(context.session_disposition), Some(context.category), scope)
}
AgentCallError::Legacy { hints, .. } => {
Self::from_disposition(hints.session_disposition, hints.category, scope)
}
}
}
fn from_disposition(
disposition: Option<AgentSessionDisposition>,
category: Option<AgentErrorCategory>,
scope: RecoveryScope,
) -> RecoveryDecision {
match disposition {
Some(AgentSessionDisposition::ReplaceRuntime) => RecoveryDecision::ReplaceRuntime,
Some(AgentSessionDisposition::Quarantine)
if category == Some(AgentErrorCategory::Connection)
&& matches!(scope, RecoveryScope::ReadOnlyMetadata { retried: false }) =>
{
RecoveryDecision::RetryReadOnlyMetadata
}
Some(AgentSessionDisposition::Quarantine) => RecoveryDecision::QuarantineSession,
Some(AgentSessionDisposition::Keep) | None => RecoveryDecision::KeepSession,
}
}
}
#[cfg(test)]
mod tests {
#[test]
fn business_modules_do_not_consume_legacy_marker_fields() {
let business_sources = [
("query.rs", include_str!("query.rs")),
("schema.rs", include_str!("schema.rs")),
("connection.rs", include_str!("connection.rs")),
];
let retired_consumers = ["agent_rpc_error_category", "agent_rpc_error_session_id", "agent_session_disposition"];
for (name, source) in business_sources {
for consumer in retired_consumers {
assert!(!source.contains(consumer), "{name} still consumes {consumer}");
}
}
}
}
+16 -10
View File
@@ -2,10 +2,9 @@ use serde::de::DeserializeOwned;
use std::time::Duration;
use crate::agent_manager::{AgentManager, DEFAULT_JRE_KEY};
use crate::agent_recovery::{RecoveryPolicy, RecoveryScope};
use crate::database_capabilities;
use crate::db::agent_driver::{
agent_session_disposition, AgentDriverClient, AgentMethod, AgentRuntimeClient, AgentSessionDisposition,
};
use crate::db::agent_driver::{AgentCallError, AgentDriverClient, AgentMethod, AgentRuntimeClient};
use crate::models::connection::DatabaseType;
pub struct SharedConnectionOpenError {
@@ -113,7 +112,7 @@ pub async fn spawn_shared_connection_client(
}
};
if let Err(err) = runtime
.call::<serde_json::Value>(AgentMethod::OpenSession.as_str(), session_params, Some(connect_timeout), None)
.call_typed::<serde_json::Value>(AgentMethod::OpenSession.as_str(), session_params, Some(connect_timeout), None)
.await
{
let open_error = shared_connection_open_error(err, runtime.clone());
@@ -124,12 +123,11 @@ pub async fn spawn_shared_connection_client(
}
fn shared_connection_open_error(
message: String,
error: AgentCallError,
runtime: std::sync::Arc<AgentRuntimeClient>,
) -> SharedConnectionOpenError {
let runtime =
(agent_session_disposition(&message) == Some(AgentSessionDisposition::ReplaceRuntime)).then_some(runtime);
SharedConnectionOpenError { message, runtime }
let runtime = RecoveryPolicy::decide(&error, RecoveryScope::ConnectionOpen).replaces_runtime().then_some(runtime);
SharedConnectionOpenError { message: error.into_legacy_string(), runtime }
}
async fn forget_unused_runtime_after_failed_open(
@@ -412,9 +410,17 @@ for line in sys.stdin:
#[tokio::test]
async fn replace_runtime_open_error_reports_runtime_without_killing_it_directly() {
let (manager, _cell, runtime, script_path) = test_shared_runtime("replace-runtime-open-error").await;
let error = "Agent RPC error (-1): capacity exhausted\nDBX_AGENT_ERROR_DATA:{\"category\":\"resource\",\"sessionDisposition\":\"replace_runtime\"}";
let error = AgentCallError::Legacy {
rpc_code: Some(-1),
message: "capacity exhausted".to_string(),
hints: crate::db::agent_driver::LegacyAgentHints {
category: Some(crate::db::agent_driver::AgentErrorCategory::Resource),
session_disposition: Some(crate::db::agent_driver::AgentSessionDisposition::ReplaceRuntime),
..Default::default()
},
};
let open_error = shared_connection_open_error(error.to_string(), runtime.clone());
let open_error = shared_connection_open_error(error, runtime.clone());
assert!(open_error.runtime.as_ref().is_some_and(|failed| std::sync::Arc::ptr_eq(failed, &runtime)));
assert!(!runtime.is_failed());
+803
View File
@@ -0,0 +1,803 @@
//! Stable, safe backend error envelopes and the v1 JDBC Agent catalog.
use std::collections::BTreeMap;
use serde::{Deserialize, Serialize};
use crate::db::agent_driver::{
category_name, stage_name, valid_agent_error_combination, AgentCallError, AgentErrorCategory, AgentErrorContext,
AgentErrorStage, AgentOperationOutcome, AgentSessionDisposition,
};
const MAX_DETAIL_BYTES: usize = 512;
/// The origin of a backend error as exposed on the wire.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub enum BackendErrorSource {
JdbcAgent,
JdbcAgentLegacy,
LegacyBackend,
}
/// 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")]
pub enum BackendOperationOutcome {
NotStarted,
Unknown,
}
/// Scalar values permitted in catalog-owned message parameters.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(untagged)]
pub enum BackendMessageParam {
String(String),
Integer(i64),
Boolean(bool),
}
/// Allowlisted diagnostics safe for a public error envelope.
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct BackendErrorDiagnostics {
#[serde(skip_serializing_if = "Option::is_none")]
category: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
stage: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
sql_state: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
vendor_code: Option<i32>,
#[serde(skip_serializing_if = "Option::is_none")]
exception_class: Option<String>,
}
/// Public v1 backend error envelope.
///
/// The fields are private on purpose: callers create envelopes through the
/// catalog adapters, so code/messageKey/params cannot drift independently.
#[derive(Debug, Clone, PartialEq, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct BackendError {
version: u8,
code: String,
message_key: String,
message_params: BTreeMap<String, BackendMessageParam>,
source: BackendErrorSource,
operation_outcome: BackendOperationOutcome,
#[serde(skip_serializing_if = "Option::is_none")]
detail: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
diagnostics: Option<BackendErrorDiagnostics>,
#[serde(skip_serializing_if = "Option::is_none")]
help_url: Option<String>,
}
impl BackendError {
/// Convert a typed Agent error into the stable v1 JDBC catalog envelope.
pub fn from_agent_call_error(error: &AgentCallError) -> Self {
match error {
AgentCallError::Structured { message, context, .. } => {
let (entry, outcome) = structured_entry(context);
Self::new(
entry,
BackendErrorSource::JdbcAgent,
outcome,
stage_param(entry, context.stage),
safe_detail(message),
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::ContractViolation { message, .. } => Self::new(
catalog_entry(CatalogCode::ContractInvalid),
BackendErrorSource::JdbcAgent,
BackendOperationOutcome::Unknown,
BTreeMap::new(),
safe_detail(message),
None,
),
AgentCallError::Transport { message } => Self::new(
catalog_entry(CatalogCode::ProtocolFailed),
BackendErrorSource::JdbcAgent,
BackendOperationOutcome::Unknown,
BTreeMap::new(),
safe_detail(message),
None,
),
AgentCallError::Timeout { stage, operation_outcome } => {
let entry = catalog_entry(match operation_outcome {
AgentOperationOutcome::NotStarted => CatalogCode::TimeoutNotStarted,
AgentOperationOutcome::Unknown => CatalogCode::TimeoutUnknown,
});
Self::new(
entry,
BackendErrorSource::JdbcAgent,
map_outcome(*operation_outcome),
stage_param(entry, *stage),
None,
Some(diagnostics_for_local("timeout", *stage)),
)
}
AgentCallError::Canceled { stage, operation_outcome } => Self::new(
catalog_entry(CatalogCode::Canceled),
BackendErrorSource::JdbcAgent,
map_outcome(*operation_outcome),
stage_param(catalog_entry(CatalogCode::Canceled), *stage),
None,
Some(diagnostics_for_local("canceled", *stage)),
),
}
}
/// Adapt a non-Agent legacy backend string without attempting text classification.
pub fn from_legacy_backend(message: &str) -> Self {
Self::new(
catalog_entry(CatalogCode::LegacyBackend),
BackendErrorSource::LegacyBackend,
BackendOperationOutcome::Unknown,
BTreeMap::new(),
safe_detail(message),
None,
)
}
/// Create a timeout envelope while retaining the bounded diagnostic detail.
pub fn from_timeout_detail(message: &str) -> Self {
let entry = catalog_entry(CatalogCode::TimeoutUnknown);
Self::new(
entry,
BackendErrorSource::JdbcAgent,
BackendOperationOutcome::Unknown,
stage_param(entry, AgentErrorStage::Execute),
safe_detail(message),
Some(diagnostics_for_local("timeout", AgentErrorStage::Execute)),
)
}
/// Create a SQL failure envelope while retaining the bounded diagnostic detail.
pub fn from_sql_detail(message: &str) -> Self {
let entry = catalog_entry(CatalogCode::SqlFailed);
Self::new(
entry,
BackendErrorSource::JdbcAgent,
BackendOperationOutcome::Unknown,
stage_param(entry, AgentErrorStage::Execute),
safe_detail(message),
Some(diagnostics_for_local("sql", AgentErrorStage::Execute)),
)
}
/// 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,
map_outcome(operation_outcome),
stage_param(entry, stage),
None,
Some(diagnostics_for_local("canceled", stage)),
)
}
/// Convert a legacy boundary string while preserving Agent data when the
/// compatibility adapter can prove that the string came from an Agent.
pub fn from_legacy_string(message: &str) -> Self {
crate::db::agent_driver::try_agent_error_from_legacy(message)
.map_or_else(|| Self::from_legacy_backend(message), |error| Self::from_agent_call_error(&error))
}
pub fn version(&self) -> u8 {
self.version
}
pub fn code(&self) -> &str {
&self.code
}
pub fn message_key(&self) -> &str {
&self.message_key
}
pub fn message_params(&self) -> &BTreeMap<String, BackendMessageParam> {
&self.message_params
}
pub fn source(&self) -> BackendErrorSource {
self.source
}
pub fn operation_outcome(&self) -> BackendOperationOutcome {
self.operation_outcome
}
pub fn detail(&self) -> Option<&str> {
self.detail.as_deref()
}
pub fn without_detail(mut self) -> Self {
self.detail = None;
self
}
pub fn diagnostics(&self) -> Option<&BackendErrorDiagnostics> {
self.diagnostics.as_ref()
}
fn new(
entry: &'static CatalogEntry,
source: BackendErrorSource,
operation_outcome: BackendOperationOutcome,
message_params: BTreeMap<String, BackendMessageParam>,
detail: Option<String>,
diagnostics: Option<BackendErrorDiagnostics>,
) -> Self {
debug_assert_eq!(message_params.len(), entry.params.len());
debug_assert!(message_params.iter().all(|(key, value)| entry.allows_value(key, value)));
Self {
version: 1,
code: entry.code.to_string(),
message_key: entry.message_key.to_string(),
message_params,
source,
operation_outcome,
detail,
diagnostics,
help_url: entry.help_url.map(str::to_string),
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum CatalogCode {
ConnectionFailed,
ConnectionInterrupted,
TimeoutNotStarted,
TimeoutUnknown,
Canceled,
BusyRetryLater,
RuntimeReplaced,
SqlFailed,
ProtocolFailed,
ContractInvalid,
JdbcLegacyFailure,
LegacyBackend,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum ParamKind {
String,
Integer,
Boolean,
}
#[derive(Debug, Clone, Copy)]
struct ParamSpec {
name: &'static str,
kind: ParamKind,
}
#[derive(Debug, Clone, Copy)]
struct CatalogEntry {
code: &'static str,
message_key: &'static str,
params: &'static [ParamSpec],
help_url: Option<&'static str>,
}
const STAGE_PARAM: &[ParamSpec] = &[ParamSpec { name: "stage", kind: ParamKind::String }];
const NO_PARAMS: &[ParamSpec] = &[];
fn catalog_entry(code: CatalogCode) -> &'static CatalogEntry {
const ENTRIES: &[(CatalogCode, CatalogEntry)] = &[
(
CatalogCode::ConnectionFailed,
CatalogEntry {
code: "DBX-JDBC-1001",
message_key: "backendErrors.jdbc.connectionFailed",
params: STAGE_PARAM,
help_url: None,
},
),
(
CatalogCode::ConnectionInterrupted,
CatalogEntry {
code: "DBX-JDBC-1002",
message_key: "backendErrors.jdbc.connectionInterrupted",
params: STAGE_PARAM,
help_url: None,
},
),
(
CatalogCode::TimeoutNotStarted,
CatalogEntry {
code: "DBX-JDBC-2001",
message_key: "backendErrors.jdbc.operationTimedOut",
params: STAGE_PARAM,
help_url: None,
},
),
(
CatalogCode::TimeoutUnknown,
CatalogEntry {
code: "DBX-JDBC-2002",
message_key: "backendErrors.jdbc.operationTimedOut",
params: STAGE_PARAM,
help_url: None,
},
),
(
CatalogCode::Canceled,
CatalogEntry {
code: "DBX-JDBC-2003",
message_key: "backendErrors.jdbc.operationCanceled",
params: STAGE_PARAM,
help_url: None,
},
),
(
CatalogCode::BusyRetryLater,
CatalogEntry {
code: "DBX-JDBC-3001",
message_key: "backendErrors.jdbc.busyRetryLater",
params: STAGE_PARAM,
help_url: None,
},
),
(
CatalogCode::RuntimeReplaced,
CatalogEntry {
code: "DBX-JDBC-3002",
message_key: "backendErrors.jdbc.runtimeReplaced",
params: STAGE_PARAM,
help_url: None,
},
),
(
CatalogCode::SqlFailed,
CatalogEntry {
code: "DBX-JDBC-4001",
message_key: "backendErrors.jdbc.sqlFailed",
params: STAGE_PARAM,
help_url: None,
},
),
(
CatalogCode::ProtocolFailed,
CatalogEntry {
code: "DBX-JDBC-5001",
message_key: "backendErrors.jdbc.protocolFailed",
params: NO_PARAMS,
help_url: None,
},
),
(
CatalogCode::ContractInvalid,
CatalogEntry {
code: "DBX-JDBC-5002",
message_key: "backendErrors.jdbc.contractInvalid",
params: NO_PARAMS,
help_url: None,
},
),
(
CatalogCode::JdbcLegacyFailure,
CatalogEntry {
code: "DBX-JDBC-9001",
message_key: "backendErrors.jdbc.legacyFailure",
params: NO_PARAMS,
help_url: None,
},
),
(
CatalogCode::LegacyBackend,
CatalogEntry {
code: "DBX-LEGACY-0001",
message_key: "backendErrors.legacy",
params: NO_PARAMS,
help_url: None,
},
),
];
ENTRIES.iter().find(|(kind, _)| *kind == code).map(|(_, entry)| entry).expect("catalog entry missing")
}
fn structured_entry(context: &AgentErrorContext) -> (&'static CatalogEntry, BackendOperationOutcome) {
if !valid_agent_error_combination(context) {
return (catalog_entry(CatalogCode::ContractInvalid), BackendOperationOutcome::Unknown);
}
let code = match context.category {
AgentErrorCategory::Connection => match (context.stage, context.operation_outcome) {
(
AgentErrorStage::Request
| AgentErrorStage::Checkout
| AgentErrorStage::Connect
| AgentErrorStage::Validate,
AgentOperationOutcome::NotStarted,
) => CatalogCode::ConnectionFailed,
(
AgentErrorStage::Execute | AgentErrorStage::Fetch | AgentErrorStage::Cancel | AgentErrorStage::Close,
AgentOperationOutcome::Unknown,
) => CatalogCode::ConnectionInterrupted,
_ => CatalogCode::ContractInvalid,
},
AgentErrorCategory::Timeout => match context.operation_outcome {
AgentOperationOutcome::NotStarted => CatalogCode::TimeoutNotStarted,
AgentOperationOutcome::Unknown => CatalogCode::TimeoutUnknown,
},
AgentErrorCategory::Canceled => CatalogCode::Canceled,
AgentErrorCategory::Resource => {
if context.session_disposition == AgentSessionDisposition::ReplaceRuntime {
CatalogCode::RuntimeReplaced
} else if context.operation_outcome == AgentOperationOutcome::NotStarted {
CatalogCode::BusyRetryLater
} else {
CatalogCode::ContractInvalid
}
}
AgentErrorCategory::Sql => {
if matches!(
context.stage,
AgentErrorStage::Execute | AgentErrorStage::Fetch | AgentErrorStage::Cancel | AgentErrorStage::Close
) && context.operation_outcome == AgentOperationOutcome::Unknown
{
CatalogCode::SqlFailed
} else {
CatalogCode::ContractInvalid
}
}
AgentErrorCategory::Protocol => CatalogCode::ProtocolFailed,
};
let entry = catalog_entry(code);
let outcome = if matches!(code, CatalogCode::ContractInvalid) {
BackendOperationOutcome::Unknown
} else {
map_outcome(context.operation_outcome)
};
(entry, outcome)
}
fn map_outcome(outcome: AgentOperationOutcome) -> BackendOperationOutcome {
match outcome {
AgentOperationOutcome::NotStarted => BackendOperationOutcome::NotStarted,
AgentOperationOutcome::Unknown => BackendOperationOutcome::Unknown,
}
}
fn stage_param(entry: &'static CatalogEntry, stage: AgentErrorStage) -> BTreeMap<String, BackendMessageParam> {
let mut params = BTreeMap::new();
let value = BackendMessageParam::String(stage_name(stage).to_string());
if entry.allows_value("stage", &value) {
params.insert("stage".to_string(), value);
}
params
}
impl CatalogEntry {
fn allows_value(self, name: &str, value: &BackendMessageParam) -> bool {
self.params.iter().any(|param| param.name == name && param.kind == value.kind())
}
}
impl BackendMessageParam {
fn kind(&self) -> ParamKind {
match self {
Self::String(_) => ParamKind::String,
Self::Integer(_) => ParamKind::Integer,
Self::Boolean(_) => ParamKind::Boolean,
}
}
}
fn diagnostics_from_context(context: &AgentErrorContext) -> BackendErrorDiagnostics {
BackendErrorDiagnostics {
category: Some(category_name(context.category).to_string()),
stage: Some(stage_name(context.stage).to_string()),
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)),
}
}
fn diagnostics_for_local(category: &str, stage: AgentErrorStage) -> BackendErrorDiagnostics {
BackendErrorDiagnostics {
category: Some(category.to_string()),
stage: Some(stage_name(stage).to_string()),
..Default::default()
}
}
fn bounded_ascii(value: &str, max: usize) -> String {
value.chars().filter(|ch| ch.is_ascii_graphic() || *ch == ' ').take(max).collect()
}
fn bounded_text(value: &str, max_bytes: usize) -> String {
let end = value
.char_indices()
.map(|(index, ch)| (index, index + ch.len_utf8()))
.take_while(|(_, next)| *next <= max_bytes)
.map(|(_, next)| next)
.last()
.unwrap_or(0);
value[..end].to_string()
}
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)) {
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)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::db::agent_driver::{AgentErrorStage, AgentSessionDisposition};
use std::collections::BTreeSet;
fn context(
category: AgentErrorCategory,
stage: AgentErrorStage,
outcome: AgentOperationOutcome,
) -> AgentErrorContext {
AgentErrorContext {
contract_version: 1,
category,
retryable: true,
session_disposition: if matches!(category, AgentErrorCategory::Timeout | AgentErrorCategory::Canceled) {
AgentSessionDisposition::Quarantine
} else {
AgentSessionDisposition::Keep
},
stage,
operation_outcome: outcome,
agent_session_id: Some("private-session".to_string()),
sql_state: Some("08006".to_string()),
vendor_code: Some(-7),
exception_class: Some("java.sql.SQLException".to_string()),
}
}
#[test]
fn catalog_owns_exact_code_and_message_key_pairs() {
let cases = [
(CatalogCode::ConnectionFailed, "DBX-JDBC-1001", "backendErrors.jdbc.connectionFailed"),
(CatalogCode::ConnectionInterrupted, "DBX-JDBC-1002", "backendErrors.jdbc.connectionInterrupted"),
(CatalogCode::TimeoutNotStarted, "DBX-JDBC-2001", "backendErrors.jdbc.operationTimedOut"),
(CatalogCode::TimeoutUnknown, "DBX-JDBC-2002", "backendErrors.jdbc.operationTimedOut"),
(CatalogCode::Canceled, "DBX-JDBC-2003", "backendErrors.jdbc.operationCanceled"),
(CatalogCode::BusyRetryLater, "DBX-JDBC-3001", "backendErrors.jdbc.busyRetryLater"),
(CatalogCode::RuntimeReplaced, "DBX-JDBC-3002", "backendErrors.jdbc.runtimeReplaced"),
(CatalogCode::SqlFailed, "DBX-JDBC-4001", "backendErrors.jdbc.sqlFailed"),
(CatalogCode::ProtocolFailed, "DBX-JDBC-5001", "backendErrors.jdbc.protocolFailed"),
(CatalogCode::ContractInvalid, "DBX-JDBC-5002", "backendErrors.jdbc.contractInvalid"),
(CatalogCode::JdbcLegacyFailure, "DBX-JDBC-9001", "backendErrors.jdbc.legacyFailure"),
(CatalogCode::LegacyBackend, "DBX-LEGACY-0001", "backendErrors.legacy"),
];
let mut codes = BTreeSet::new();
for (kind, expected_code, expected_key) in cases {
let entry = catalog_entry(kind);
assert_eq!(entry.code, expected_code);
assert_eq!(entry.message_key, expected_key);
assert!(codes.insert(entry.code), "duplicate catalog code: {}", entry.code);
}
}
#[test]
fn catalog_maps_valid_variants_and_emits_allowlisted_params() {
let cases = [
(
AgentErrorCategory::Connection,
AgentErrorStage::Request,
AgentOperationOutcome::NotStarted,
"DBX-JDBC-1001",
),
(AgentErrorCategory::Connection, AgentErrorStage::Execute, AgentOperationOutcome::Unknown, "DBX-JDBC-1002"),
(AgentErrorCategory::Timeout, AgentErrorStage::Connect, AgentOperationOutcome::NotStarted, "DBX-JDBC-2001"),
(AgentErrorCategory::Timeout, AgentErrorStage::Execute, AgentOperationOutcome::Unknown, "DBX-JDBC-2002"),
(AgentErrorCategory::Canceled, AgentErrorStage::Cancel, AgentOperationOutcome::Unknown, "DBX-JDBC-2003"),
(
AgentErrorCategory::Resource,
AgentErrorStage::Request,
AgentOperationOutcome::NotStarted,
"DBX-JDBC-3001",
),
(AgentErrorCategory::Resource, AgentErrorStage::Execute, AgentOperationOutcome::Unknown, "DBX-JDBC-5002"),
(AgentErrorCategory::Sql, AgentErrorStage::Execute, AgentOperationOutcome::Unknown, "DBX-JDBC-4001"),
(AgentErrorCategory::Protocol, AgentErrorStage::Request, AgentOperationOutcome::Unknown, "DBX-JDBC-5002"),
];
for (category, stage, outcome, expected_code) in cases {
let error = AgentCallError::Structured {
rpc_code: -1,
message: "safe failure".to_string(),
context: context(category, stage, outcome),
};
let envelope = BackendError::from_agent_call_error(&error);
assert_eq!(envelope.code(), expected_code);
assert_eq!(envelope.version(), 1);
if expected_code != "DBX-JDBC-5002" {
assert_eq!(
envelope.message_params().get("stage"),
Some(&BackendMessageParam::String(stage_name(stage).to_string()))
);
}
}
}
#[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",
] {
assert!(safe_detail(message).is_none(), "detail leaked: {message}");
}
let error = BackendError::from_agent_call_error(&AgentCallError::Structured {
rpc_code: -1,
message: "jdbc:postgresql://host/db?password=secret".to_string(),
context: context(
AgentErrorCategory::Connection,
AgentErrorStage::Request,
AgentOperationOutcome::NotStarted,
),
});
let value = serde_json::to_value(error).unwrap();
assert_eq!(value["source"], "jdbcAgent");
assert!(value.get("retryable").is_none());
assert!(value.get("sessionDisposition").is_none());
assert!(value.get("agentSessionId").is_none());
assert!(value.get("detail").is_none());
}
#[test]
fn invalid_structured_combinations_use_contract_catalog_code() {
let invalid = [
(
AgentErrorCategory::Timeout,
AgentErrorStage::Request,
AgentOperationOutcome::Unknown,
AgentSessionDisposition::Keep,
),
(
AgentErrorCategory::Protocol,
AgentErrorStage::Request,
AgentOperationOutcome::NotStarted,
AgentSessionDisposition::ReplaceRuntime,
),
(
AgentErrorCategory::Resource,
AgentErrorStage::Execute,
AgentOperationOutcome::Unknown,
AgentSessionDisposition::Keep,
),
(
AgentErrorCategory::Sql,
AgentErrorStage::Connect,
AgentOperationOutcome::NotStarted,
AgentSessionDisposition::Keep,
),
];
for (category, stage, outcome, disposition) in invalid {
let mut ctx = context(category, stage, outcome);
ctx.session_disposition = disposition;
let envelope = BackendError::from_agent_call_error(&AgentCallError::Structured {
rpc_code: -1,
message: "invalid combination".to_string(),
context: ctx,
});
assert_eq!(envelope.code(), "DBX-JDBC-5002");
assert_eq!(envelope.operation_outcome(), BackendOperationOutcome::Unknown);
}
}
#[test]
fn legacy_adapters_use_stable_sources() {
let legacy = BackendError::from_agent_call_error(&AgentCallError::Legacy {
rpc_code: None,
message: "old agent".to_string(),
hints: Default::default(),
});
assert_eq!(legacy.code(), "DBX-JDBC-9001");
assert_eq!(legacy.source, BackendErrorSource::JdbcAgentLegacy);
assert_eq!(BackendError::from_legacy_backend("driver failed").code(), "DBX-LEGACY-0001");
}
#[test]
fn legacy_agent_envelope_keeps_multiline_database_detail() {
let legacy = AgentCallError::Legacy {
rpc_code: Some(-1),
message: "ERROR: relation \"dbx_table_that_does_not_exist\" does not exist\n Position: 15".to_string(),
hints: Default::default(),
}
.into_legacy_string();
let error = BackendError::from_legacy_string(&legacy);
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")
);
}
#[test]
fn legacy_agent_envelope_keeps_dm_chinese_database_detail() {
let legacy = AgentCallError::Legacy {
rpc_code: Some(-1),
message: "无效的表或视图名\n错误码: -2106".to_string(),
hints: Default::default(),
}
.into_legacy_string();
let error = BackendError::from_legacy_string(&legacy);
assert_eq!(error.code(), "DBX-JDBC-9001");
assert_eq!(error.detail(), Some("无效的表或视图名 错误码: -2106"));
}
#[test]
fn strict_marker_round_trip_keeps_catalog_code() {
let error = AgentCallError::Structured {
rpc_code: -1,
message: "connection lost".to_string(),
context: context(AgentErrorCategory::Connection, AgentErrorStage::Execute, AgentOperationOutcome::Unknown),
};
let legacy =
crate::db::agent_driver::append_legacy_error_context(&error.into_legacy_string(), "SQL text omitted");
assert_eq!(BackendError::from_legacy_string(&legacy).code(), "DBX-JDBC-1002");
}
}
+116 -42
View File
@@ -15,9 +15,10 @@ use crate::agent_connection::{
trino_like_jdbc_connection_string, AgentSessionRole,
};
use crate::agent_manager::{JavaRuntimeMode, DEFAULT_JRE_KEY};
use crate::agent_recovery::{RecoveryDecision, RecoveryPolicy, RecoveryScope};
use crate::database_capabilities;
use crate::db;
use crate::db::agent_driver::AgentMethod;
use crate::db::agent_driver::{AgentCallError, AgentMethod};
use crate::db::http_tunnel::HttpTunnelManager;
use crate::db::proxy_tunnel::ProxyTunnelManager;
use crate::db::ssh_tunnel::TunnelManager;
@@ -550,8 +551,7 @@ fn fail_stop_removed_agent_pool(pool_key: &str, pool: &PoolKind) {
}
fn should_replace_agent_runtime(error: &str) -> bool {
crate::db::agent_driver::agent_session_disposition(error)
== Some(crate::db::agent_driver::AgentSessionDisposition::ReplaceRuntime)
crate::db::agent_driver::agent_recovery_decision(error, RecoveryScope::ConnectionOpen).replaces_runtime()
}
pub fn metadata_connection_config(config: &ConnectionConfig) -> ConnectionConfig {
@@ -681,10 +681,10 @@ pub fn sqlserver_uses_legacy_driver(config: &ConnectionConfig) -> bool {
}
pub fn sqlserver_legacy_driver_error(agent_error: &str) -> String {
// AgentManager currently returns launch failures as strings. This exact marker is generated
// internally and only adds guidance for an explicitly selected compatibility driver.
// This mapper handles both AgentManager launch strings and Agent call errors, so context
// must remain before any structured-error compatibility marker.
if agent_error.contains("driver is not installed") {
format!("{agent_error}\n\n{SQLSERVER_LEGACY_DRIVER_INSTALL_HINT}")
crate::db::agent_driver::append_legacy_error_context(agent_error, SQLSERVER_LEGACY_DRIVER_INSTALL_HINT)
} else {
agent_error.to_string()
}
@@ -1462,12 +1462,14 @@ impl AppState {
Ok(Ok(())) => {}
Ok(Err(err)) => {
log::warn!("Connection keepalive failed for '{key}': {err}; invalidating pool");
let replace_runtime =
err.recovery_decision().is_some_and(RecoveryDecision::replaces_runtime);
if !detach_keepalive_target_if_current(
&routing,
&connections,
&key,
target,
should_replace_agent_runtime(&err),
replace_runtime,
)
.await
{
@@ -2904,15 +2906,15 @@ impl AppState {
return false;
};
let timeout = crate::db::connection_timeout();
match agent.validate_connection(Some(timeout)).await {
match agent.validate_connection_typed(Some(timeout)).await {
Ok(_) => false,
Err(err) if is_agent_validate_connection_unsupported(&err) => {
Err(err) if is_agent_validate_connection_unsupported(&err.to_string()) => {
log::debug!(
"Agent connection pool '{pool_key}' does not support validate_connection; keeping pool"
);
false
}
Err(err) if should_replace_agent_runtime(&err) => {
Err(err) if RecoveryPolicy::decide(&err, RecoveryScope::Keepalive).replaces_runtime() => {
log::warn!(
"Agent connection pool '{pool_key}' requested runtime replacement during health probe: {err}"
);
@@ -3143,12 +3145,12 @@ impl AppState {
self.detach_pool_by_key(&pool_key, true).await
}
pub(crate) async fn detach_metadata_pool_after_error(
pub(crate) async fn detach_metadata_pool_after_recovery(
&self,
connection_id: &str,
database: Option<&str>,
client_session_id: Option<&str>,
error: &str,
agent_session_id: Option<&str>,
replace_agent_runtime: bool,
) -> bool {
let config = {
@@ -3159,11 +3161,11 @@ impl AppState {
let base_pool_key = base_pool_key_for(db_type, connection_id, database, false);
let pool_key =
pool_key_for_session_role(config.as_ref(), base_pool_key, client_session_id, AgentSessionRole::Metadata);
if let Some(session_id) = crate::db::agent_driver::agent_rpc_error_session_id(error) {
if let Some(session_id) = agent_session_id {
let expected_client = {
let connections = self.connections.read().await;
match connections.get(&pool_key) {
Some(PoolKind::Agent(client)) if client.matches_session_id(&session_id) => Some(client.clone()),
Some(PoolKind::Agent(client)) if client.matches_session_id(session_id) => Some(client.clone()),
Some(PoolKind::Agent(_)) => return false,
_ => None,
}
@@ -3786,9 +3788,9 @@ impl AppState {
log::debug!("Agent connection pool '{key}' is busy; skipping resume health probe");
continue;
};
match agent.validate_connection(Some(timeout)).await {
match agent.validate_connection_typed(Some(timeout)).await {
Ok(_) => true,
Err(err) if is_agent_validate_connection_unsupported(&err) => {
Err(err) if is_agent_validate_connection_unsupported(&err.to_string()) => {
log::debug!(
"Agent connection pool '{key}' does not support validate_connection; keeping pool"
);
@@ -3796,7 +3798,11 @@ impl AppState {
}
Err(e) => {
log::warn!("Agent connection pool '{key}' is unhealthy: {e}");
failed_agent_checks.push((key.clone(), client.clone(), should_replace_agent_runtime(&e)));
failed_agent_checks.push((
key.clone(),
client.clone(),
RecoveryPolicy::decide(&e, RecoveryScope::Keepalive).replaces_runtime(),
));
false
}
}
@@ -4030,6 +4036,36 @@ enum KeepaliveTarget {
Agent(Arc<db::agent_driver::PooledAgentClient>),
}
#[derive(Debug)]
enum KeepaliveError {
Agent(AgentCallError),
Legacy(String),
}
impl KeepaliveError {
fn recovery_decision(&self) -> Option<RecoveryDecision> {
match self {
Self::Agent(error) => Some(RecoveryPolicy::decide(error, RecoveryScope::Keepalive)),
Self::Legacy(_) => None,
}
}
}
impl std::fmt::Display for KeepaliveError {
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::Agent(error) => error.fmt(formatter),
Self::Legacy(error) => formatter.write_str(error),
}
}
}
impl From<String> for KeepaliveError {
fn from(error: String) -> Self {
Self::Legacy(error)
}
}
impl KeepaliveTarget {
fn matches_pool(&self, pool: &PoolKind) -> bool {
match (self, pool) {
@@ -4095,41 +4131,55 @@ fn keepalive_target_from_pool(pool: &PoolKind, config: &ConnectionConfig) -> Opt
}
}
async fn ping_keepalive_target(target: &mut KeepaliveTarget, timeout: Duration) -> Result<(), String> {
async fn ping_keepalive_target(target: &mut KeepaliveTarget, timeout: Duration) -> Result<(), KeepaliveError> {
match target {
KeepaliveTarget::Mysql(pool) => {
let mut conn = db::mysql::get_conn_with_health_check(pool).await?;
conn.ping().await.map_err(|e| e.to_string())
conn.ping().await.map_err(|error| KeepaliveError::Legacy(error.to_string()))
}
KeepaliveTarget::Postgres(pool) => {
let client = pool.get().await.map_err(|e| format!("PostgreSQL pool error: {e}"))?;
client.simple_query("SELECT 1").await.map(|_| ()).map_err(|e| e.to_string())
client.simple_query("SELECT 1").await.map(|_| ()).map_err(|error| KeepaliveError::Legacy(error.to_string()))
}
KeepaliveTarget::Rqlite(client) => db::rqlite_driver::test_connection(client, timeout).await,
KeepaliveTarget::Turso(client) => db::turso_driver::test_connection(client, timeout).await,
KeepaliveTarget::Rqlite(client) => {
db::rqlite_driver::test_connection(client, timeout).await.map_err(Into::into)
}
KeepaliveTarget::Turso(client) => db::turso_driver::test_connection(client, timeout).await.map_err(Into::into),
KeepaliveTarget::MongoDb { client, database } => {
db::mongo_driver::test_connection(client, timeout, database.as_deref()).await
db::mongo_driver::test_connection(client, timeout, database.as_deref()).await.map_err(Into::into)
}
KeepaliveTarget::ClickHouse(client) => {
db::clickhouse_driver::test_connection(client, timeout).await.map_err(Into::into)
}
KeepaliveTarget::ClickHouse(client) => db::clickhouse_driver::test_connection(client, timeout).await,
KeepaliveTarget::SqlServer(client) => {
let Ok(mut client) = client.try_lock() else {
return Ok(());
};
db::sqlserver::test_connection(&mut client).await
db::sqlserver::test_connection(&mut client).await.map_err(Into::into)
}
KeepaliveTarget::Elasticsearch(client) => {
db::elasticsearch_driver::test_connection(client, timeout).await.map_err(Into::into)
}
KeepaliveTarget::Easysearch(client) => {
db::easysearch_driver::test_connection(client, timeout).await.map_err(Into::into)
}
KeepaliveTarget::HBase(client) => {
db::hbase_driver::test_connection(client, timeout).await.map(|_| ()).map_err(Into::into)
}
KeepaliveTarget::VectorDb(client) => {
db::vector_driver::test_connection(client, timeout).await.map_err(Into::into)
}
KeepaliveTarget::InfluxDb(client) => {
db::influxdb_driver::test_connection(client, timeout).await.map_err(Into::into)
}
KeepaliveTarget::Elasticsearch(client) => db::elasticsearch_driver::test_connection(client, timeout).await,
KeepaliveTarget::Easysearch(client) => db::easysearch_driver::test_connection(client, timeout).await,
KeepaliveTarget::HBase(client) => db::hbase_driver::test_connection(client, timeout).await.map(|_| ()),
KeepaliveTarget::VectorDb(client) => db::vector_driver::test_connection(client, timeout).await,
KeepaliveTarget::InfluxDb(client) => db::influxdb_driver::test_connection(client, timeout).await,
KeepaliveTarget::Agent(client) => {
let Ok(mut client) = client.try_lock() else {
return Ok(());
};
match client.validate_connection(Some(timeout)).await {
match client.validate_connection_typed(Some(timeout)).await {
Ok(_) => Ok(()),
Err(err) if is_agent_validate_connection_unsupported(&err) => Ok(()),
Err(err) => Err(err),
Err(error) if is_agent_validate_connection_unsupported(&error.to_string()) => Ok(()),
Err(error) => Err(KeepaliveError::Agent(error)),
}
}
}
@@ -4949,6 +4999,34 @@ mod tests {
assert!(message.contains("enable SQL Server legacy compatibility mode again"));
}
#[test]
fn sqlserver_legacy_driver_hint_preserves_structured_agent_error() {
let error = crate::db::agent_driver::AgentCallError::Structured {
rpc_code: -6007,
message: "driver is not installed".to_string(),
context: crate::db::agent_driver::AgentErrorContext {
contract_version: 1,
category: crate::db::agent_driver::AgentErrorCategory::Connection,
retryable: false,
session_disposition: crate::db::agent_driver::AgentSessionDisposition::Quarantine,
stage: crate::db::agent_driver::AgentErrorStage::Connect,
operation_outcome: crate::db::agent_driver::AgentOperationOutcome::NotStarted,
agent_session_id: None,
sql_state: None,
vendor_code: None,
exception_class: None,
},
}
.into_legacy_string();
let message = sqlserver_legacy_driver_error(&error);
assert!(matches!(
crate::db::agent_driver::agent_error_from_legacy(&message, None),
crate::db::agent_driver::AgentCallError::Structured { .. }
));
}
#[test]
fn agent_connect_params_include_url_params() {
let mut config = mysql_config(Some("testdb"));
@@ -6788,13 +6866,12 @@ for line in sys.stdin:
state.configs.write().await.insert(config.id.clone(), config);
let pool_key = "conn:analytics:role:metadata";
state.connections.write().await.insert(pool_key.to_string(), PoolKind::Agent(current_client.clone()));
let stale_error = concat!(
"Agent RPC error (-1): stale metadata failure\nDBX_AGENT_ERROR_DATA:",
r#"{"category":"resource","sessionDisposition":"replace_runtime","agentSessionId":"stale-session"}"#
assert!(
!state
.detach_metadata_pool_after_recovery("conn", Some("analytics"), None, Some("stale-session"), true,)
.await
);
assert!(!state.detach_metadata_pool_after_error("conn", Some("analytics"), None, stale_error, true).await);
let connections = state.connections.read().await;
let PoolKind::Agent(routed_client) = connections.get(pool_key).expect("new metadata generation must remain")
else {
@@ -6816,10 +6893,7 @@ for line in sys.stdin:
let error = super::ping_keepalive_target(&mut target, Duration::from_secs(1)).await.unwrap_err();
assert_eq!(
crate::db::agent_driver::agent_session_disposition(&error),
Some(crate::db::agent_driver::AgentSessionDisposition::ReplaceRuntime)
);
assert!(matches!(error.recovery_decision(), Some(crate::agent_recovery::RecoveryDecision::ReplaceRuntime)));
assert!(!runtime.is_failed());
runtime.kill();
let _ = std::fs::remove_dir_all(dir);
File diff suppressed because it is too large Load Diff
+2
View File
@@ -5,6 +5,7 @@ pub mod agent_explain;
pub mod agent_kv;
pub mod agent_loop;
pub mod agent_manager;
pub mod agent_recovery;
pub mod agent_runtime;
pub mod agent_service;
pub mod agent_tools;
@@ -15,6 +16,7 @@ pub mod ai_codex_cli;
pub mod ai_effort;
mod ai_model_filter;
pub mod ai_pi_agent_cli;
pub mod backend_error;
pub mod changelog;
pub mod cloud_sync;
pub mod config;
+4 -5
View File
@@ -337,7 +337,7 @@ impl PluginDriverSession {
process.child.id()
}
#[cfg(test)]
#[cfg(all(test, unix))]
pub(crate) async fn start_for_test(
plugin: InstalledPlugin,
driver_id: String,
@@ -513,10 +513,9 @@ fn resolve_plugin_executable(plugin_dir: &Path, executable: &str) -> PathBuf {
#[cfg(test)]
mod tests {
use super::{
read_decoded_plugin_response, read_plugin_line, InstalledPlugin, PluginDriverManifest, PluginDriverSession,
PluginManifest, PluginRuntimeEnv,
};
use super::{read_decoded_plugin_response, read_plugin_line, InstalledPlugin, PluginManifest};
#[cfg(unix)]
use super::{PluginDriverManifest, PluginDriverSession, PluginRuntimeEnv};
use std::path::PathBuf;
use tokio::io::BufReader;
File diff suppressed because it is too large Load Diff
+66 -37
View File
@@ -1,3 +1,4 @@
use crate::agent_recovery::{RecoveryDecision, RecoveryPolicy, RecoveryScope};
use crate::connection::{
connection_url_for_endpoint, database_connection_config, gaussdb_uses_m_jdbc_driver, task_client_session_id,
AppState, MysqlMode, PoolKind,
@@ -793,8 +794,9 @@ async fn list_schemas_once(
.await
.map(|schemas| filter_visible_schema_names(schemas, visible_schema_filter.as_deref()))
.map_err(|fallback_error| {
format!(
"{agent_error}\n\nNative PostgreSQL metadata fallback failed: {fallback_error}"
crate::db::agent_driver::append_legacy_error_context(
&agent_error,
&format!("Native PostgreSQL metadata fallback failed: {fallback_error}"),
)
});
}
@@ -2043,7 +2045,10 @@ async fn list_tables_once(
db::postgres::list_tables_filtered(&pool, schema, filter, limit, offset).await
};
return result.map_err(|fallback_error| {
format!("{agent_error}\n\nNative PostgreSQL metadata fallback failed: {fallback_error}")
crate::db::agent_driver::append_legacy_error_context(
&agent_error,
&format!("Native PostgreSQL metadata fallback failed: {fallback_error}"),
)
});
}
}
@@ -3052,7 +3057,10 @@ for line in sys.stdin:
let error = super::get_table_ddl_core(&state, "conn", "analytics", "APP", "EVENTS", None).await.unwrap_err();
assert_eq!(crate::db::agent_driver::agent_rpc_error_category(&error).as_deref(), Some("timeout"));
assert_eq!(
crate::db::agent_driver::try_agent_error_from_legacy(&error).and_then(|error| error.category()),
Some(crate::db::agent_driver::AgentErrorCategory::Timeout)
);
assert_eq!(std::fs::read_to_string(call_count_path).unwrap(), "1");
assert!(!state.connections.read().await.contains_key(pool_key));
runtime.kill();
@@ -4686,8 +4694,9 @@ async fn list_objects_once(
{
return db::postgres::list_objects(&pool, schema).await.map(unpaged_object_list).map_err(
|fallback_error| {
format!(
"{agent_error}\n\nNative PostgreSQL metadata fallback failed: {fallback_error}"
crate::db::agent_driver::append_legacy_error_context(
&agent_error,
&format!("Native PostgreSQL metadata fallback failed: {fallback_error}"),
)
},
);
@@ -4819,8 +4828,9 @@ async fn list_completion_objects_once(
.await
.map(filter_completion_objects)
.map_err(|fallback_error| {
format!(
"{agent_error}\n\nNative PostgreSQL metadata fallback failed: {fallback_error}"
crate::db::agent_driver::append_legacy_error_context(
&agent_error,
&format!("Native PostgreSQL metadata fallback failed: {fallback_error}"),
)
});
}
@@ -4924,19 +4934,16 @@ where
let mut retried = false;
loop {
let result = operation().await;
let action = result
.as_ref()
.err()
.map(|error| metadata_error_action(db_type, error, retried))
.unwrap_or(MetadataErrorAction::Return);
match action {
let recovery =
result.as_ref().err().map(|error| metadata_recovery(db_type, error, retried)).unwrap_or_default();
match recovery.action {
MetadataErrorAction::ReplaceRuntime => {
state
.detach_metadata_pool_after_error(
.detach_metadata_pool_after_recovery(
connection_id,
database,
client_session_id,
result.as_ref().err().expect("replace-runtime action requires an error"),
recovery.agent_session_id.as_deref(),
true,
)
.await;
@@ -4944,11 +4951,11 @@ where
}
MetadataErrorAction::Discard => {
state
.detach_metadata_pool_after_error(
.detach_metadata_pool_after_recovery(
connection_id,
database,
client_session_id,
result.as_ref().err().expect("discard action requires an error"),
recovery.agent_session_id.as_deref(),
false,
)
.await;
@@ -4959,25 +4966,26 @@ where
if let Err(error) =
state.reconnect_metadata_pool_for_session(connection_id, database, client_session_id).await
{
match metadata_error_action(db_type, &error, true) {
let reconnect_recovery = metadata_recovery(db_type, &error, true);
match reconnect_recovery.action {
MetadataErrorAction::ReplaceRuntime => {
state
.detach_metadata_pool_after_error(
.detach_metadata_pool_after_recovery(
connection_id,
database,
client_session_id,
&error,
reconnect_recovery.agent_session_id.as_deref(),
true,
)
.await;
}
MetadataErrorAction::Retry | MetadataErrorAction::Discard => {
state
.detach_metadata_pool_after_error(
.detach_metadata_pool_after_recovery(
connection_id,
database,
client_session_id,
&error,
reconnect_recovery.agent_session_id.as_deref(),
false,
)
.await;
@@ -4992,26 +5000,48 @@ where
}
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
enum MetadataErrorAction {
Retry,
Discard,
ReplaceRuntime,
#[default]
Return,
}
#[derive(Debug, Default)]
struct MetadataRecovery {
action: MetadataErrorAction,
agent_session_id: Option<String>,
}
#[cfg(test)]
fn metadata_error_action(db_type: Option<DatabaseType>, error: &str, retried: bool) -> MetadataErrorAction {
if crate::db::agent_driver::agent_session_disposition(error)
== Some(crate::db::agent_driver::AgentSessionDisposition::ReplaceRuntime)
{
MetadataErrorAction::ReplaceRuntime
} else if !retried && is_retryable_metadata_error(error) {
metadata_recovery(db_type, error, retried).action
}
fn metadata_recovery(db_type: Option<DatabaseType>, error: &str, retried: bool) -> MetadataRecovery {
if db_type.is_some_and(|db_type| crate::database_capabilities::is_agent_type(&db_type)) {
if let Some(error) = crate::db::agent_driver::try_agent_error_from_legacy(error) {
let agent_session_id = error.session_id().map(str::to_string);
let action = match RecoveryPolicy::decide(&error, RecoveryScope::ReadOnlyMetadata { retried }) {
RecoveryDecision::RetryReadOnlyMetadata => MetadataErrorAction::Retry,
RecoveryDecision::QuarantineSession => MetadataErrorAction::Discard,
RecoveryDecision::ReplaceRuntime => MetadataErrorAction::ReplaceRuntime,
RecoveryDecision::KeepSession => MetadataErrorAction::Return,
};
return MetadataRecovery { action, agent_session_id };
}
}
let action = if !retried && is_retryable_metadata_error(error) {
MetadataErrorAction::Retry
} else if should_discard_pool_after_error(db_type, error) {
MetadataErrorAction::Discard
} else {
MetadataErrorAction::Return
}
};
MetadataRecovery { action, agent_session_id: None }
}
#[cfg(test)]
@@ -5025,11 +5055,9 @@ async fn replace_metadata_runtime(
}
fn is_retryable_metadata_error(error: &str) -> bool {
let category = crate::db::agent_driver::agent_rpc_error_category(error);
if let Some(category) = category {
return category == "connection"
&& crate::db::agent_driver::agent_session_disposition(error)
== Some(crate::db::agent_driver::AgentSessionDisposition::Quarantine);
if let Some(error) = crate::db::agent_driver::try_agent_error_from_legacy(error) {
return RecoveryPolicy::decide(&error, RecoveryScope::ReadOnlyMetadata { retried: false })
== RecoveryDecision::RetryReadOnlyMetadata;
}
error == "Pool not found" || crate::query::is_connection_error(error)
}
@@ -5294,8 +5322,9 @@ async fn get_columns_core_for_session_inner(
.await
.map(deduplicate_column_infos)
.map_err(|fallback_error| {
format!(
"{agent_error}\n\nNative PostgreSQL metadata fallback failed: {fallback_error}"
crate::db::agent_driver::append_legacy_error_context(
&agent_error,
&format!("Native PostgreSQL metadata fallback failed: {fallback_error}"),
)
});
}
+99 -6
View File
@@ -1,11 +1,14 @@
use axum::http::StatusCode;
use axum::response::{IntoResponse, Response};
use axum::Json;
use dbx_core::backend_error::BackendError;
use std::fmt;
#[derive(Debug)]
pub struct AppError {
pub message: String,
pub status: StatusCode,
pub error: Box<BackendError>,
}
impl fmt::Display for AppError {
@@ -16,32 +19,122 @@ impl fmt::Display for AppError {
impl AppError {
pub fn internal(msg: impl Into<String>) -> Self {
AppError { message: msg.into(), status: StatusCode::INTERNAL_SERVER_ERROR }
Self::with_status(msg.into(), StatusCode::INTERNAL_SERVER_ERROR)
}
pub fn bad_request(msg: impl Into<String>) -> Self {
AppError { message: msg.into(), status: StatusCode::BAD_REQUEST }
Self::with_status(msg.into(), StatusCode::BAD_REQUEST)
}
pub fn not_found(msg: impl Into<String>) -> Self {
AppError { message: msg.into(), status: StatusCode::NOT_FOUND }
Self::with_status(msg.into(), StatusCode::NOT_FOUND)
}
fn with_status(message: String, status: StatusCode) -> Self {
let error = BackendError::from_legacy_string(&message);
Self { message, status, error: Box::new(error) }
}
pub fn from_backend_error(error: BackendError) -> Self {
Self { message: error.code().to_string(), status: StatusCode::INTERNAL_SERVER_ERROR, error: Box::new(error) }
}
}
impl IntoResponse for AppError {
fn into_response(self) -> Response {
(self.status, self.message).into_response()
(self.status, Json(*self.error)).into_response()
}
}
impl From<String> for AppError {
fn from(s: String) -> Self {
AppError { message: s, status: StatusCode::INTERNAL_SERVER_ERROR }
Self::internal(s)
}
}
impl From<&str> for AppError {
fn from(s: &str) -> Self {
AppError { message: s.to_string(), status: StatusCode::INTERNAL_SERVER_ERROR }
Self::internal(s)
}
}
impl From<BackendError> for AppError {
fn from(error: BackendError) -> Self {
Self::from_backend_error(error)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn legacy_errors_are_exposed_as_the_shared_backend_envelope() {
let error = AppError::internal("database failed");
assert_eq!(error.status, StatusCode::INTERNAL_SERVER_ERROR);
assert_eq!(error.error.version(), 1);
assert_eq!(error.error.code(), "DBX-LEGACY-0001");
assert_eq!(error.message, "database failed");
}
#[test]
fn app_error_stays_small_for_route_result_types() {
assert!(std::mem::size_of::<AppError>() <= 64);
}
#[test]
fn structured_agent_text_keeps_catalog_identity_at_http_boundary() {
let error = AppError::from(
"Agent RPC error (-1): timed out\nDBX_AGENT_ERROR_DATA:{\"category\":\"timeout\",\"stage\":\"execute\"}",
);
assert_eq!(error.error.code(), "DBX-JDBC-9001");
assert_eq!(error.error.source(), dbx_core::backend_error::BackendErrorSource::JdbcAgentLegacy);
}
#[tokio::test]
async fn http_response_preserves_filtered_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");
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["version"], 1);
assert_eq!(payload["code"], "DBX-LEGACY-0001");
assert_eq!(payload["messageKey"], "backendErrors.legacy");
assert_eq!(payload["detail"], "database failed");
}
#[tokio::test]
async fn http_response_preserves_filtered_structured_agent_detail() {
use dbx_core::db::agent_driver::{
AgentCallError, AgentErrorCategory, AgentErrorContext, AgentErrorStage, AgentOperationOutcome,
AgentSessionDisposition,
};
let error = BackendError::from_agent_call_error(&AgentCallError::Structured {
rpc_code: -1,
message: "relation customer_orders does not exist".to_string(),
context: AgentErrorContext {
contract_version: 1,
category: AgentErrorCategory::Sql,
retryable: false,
session_disposition: AgentSessionDisposition::Keep,
stage: AgentErrorStage::Execute,
operation_outcome: AgentOperationOutcome::Unknown,
agent_session_id: Some("session-1".to_string()),
sql_state: Some("42P01".to_string()),
vendor_code: None,
exception_class: Some("java.sql.SQLException".to_string()),
},
});
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["code"], "DBX-JDBC-4001");
assert_eq!(payload["source"], "jdbcAgent");
assert_eq!(payload["detail"], "relation customer_orders does not exist");
assert!(payload["diagnostics"].get("agentSessionId").is_none());
}
}
+42 -5
View File
@@ -344,7 +344,7 @@ pub async fn execute_query(
tracing::debug!(connection_id = %req.connection_id, "execute_query");
let result = dbx_core::query::execute_sql_statement_with_options(
let result = dbx_core::query::execute_sql_statement_with_options_typed(
&state.app,
&req.connection_id,
&req.database,
@@ -366,7 +366,7 @@ pub async fn execute_query(
},
)
.await
.map_err(AppError::from)?;
.map_err(|error| AppError::from(error.into_backend_error()))?;
drop(registered);
Ok(Json(result))
@@ -390,7 +390,7 @@ pub async fn execute_multi(
tracing::debug!(connection_id = %req.connection_id, "execute_multi");
let result = dbx_core::query::execute_multi_core_with_options_for_client(
let result = dbx_core::query::execute_multi_core_with_options_for_client_typed(
&state.app,
&req.connection_id,
&req.database,
@@ -412,10 +412,16 @@ pub async fn execute_multi(
},
)
.await
.map_err(AppError::from)?;
.map_err(|error| AppError::from(error.into_backend_error()))?;
drop(registered);
Ok(Json(result))
Ok(execute_multi_response(result))
}
fn execute_multi_response(
result: Vec<dbx_core::query::ExecuteMultiResult>,
) -> Json<Vec<dbx_core::query::ExecuteMultiResult>> {
Json(result)
}
pub async fn execute_batch(
@@ -1004,4 +1010,35 @@ mod tests {
// Missing connection cannot have applied statements.
assert_eq!(log.executed_count, 0);
}
#[test]
fn execute_multi_response_preserves_nested_filtered_error_detail() {
let result = dbx_core::query::ExecuteMultiResult {
result: dbx_core::db::QueryResult {
columns: vec!["Error".to_string()],
column_types: vec![],
column_sortables: vec![],
spatial_columns: vec![],
spatial_values: vec![],
rows: vec![vec![serde_json::json!("relation customer_orders does not exist")]],
affected_rows: 0,
execution_time_ms: 0,
truncated: false,
session_id: None,
has_more: false,
elasticsearch_raw_body: None,
},
execution_error: true,
statement_index: Some(1),
error: Some(dbx_core::backend_error::BackendError::from_sql_detail(
"relation customer_orders does not exist",
)),
};
let payload = serde_json::to_value(execute_multi_response(vec![result]).0).unwrap();
assert_eq!(payload[0]["statement_index"], 1);
assert_eq!(payload[0]["error"]["code"], "DBX-JDBC-4001");
assert_eq!(payload[0]["error"]["detail"], "relation customer_orders does not exist");
}
}
+147
View File
@@ -0,0 +1,147 @@
# 后端异常处理与错误码规范
本文记录 DBX 当前已经落地的后端错误契约、恢复边界和前端展示规则。目标是让恢复逻辑依赖可验证的类型,让对外错误身份稳定,同时保留数据库服务端返回的、经过安全过滤的真实诊断信息。
本文描述的是现有实现,不引入新的 Agent Protocol V3。结构化错误是 Agent Protocol v2 的可选 capability:`structured_error_v1`。
## 分层职责
1. Agent 只报告事实:`category`、`stage`、`operationOutcome`、`sessionDisposition` 以及 JDBC 诊断字段。
2. Rust `AgentCallError` 负责解码 Agent v2 的结构化错误;旧 Agent 或旧字符串接口只经过 `agent_driver` 中的兼容 adapter。
3. `RecoveryPolicy` 根据类型化错误和操作范围决定保留、隔离 Session 或替换 Runtime;它不从错误文本推断恢复动作。
4. `BackendError` catalog 将类型化错误映射为稳定的 `code`、`messageKey`、白名单参数和安全诊断字段。
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`。
业务代码应使用类型化入口:
```rust
let result = client.call_typed::<Response>(method, params, timeout, cancel).await;
if let Err(error) = &result {
let decision = RecoveryPolicy::decide(error, RecoveryScope::UserOperation);
// 只执行 Session/Runtime 恢复,不重放当前用户 SQL。
}
```
`AgentRuntimeClient::call` 和 `AgentCallError::into_legacy_string` 仅用于尚未迁移的字符串边界。旧字符串只有在 `try_agent_error_from_legacy` 能证明其来自 Agent 调用通道时才恢复为 Agent 错误;不要在 `query`、`schema`、`connection`、`keepalive` 或 UI 中增加新的文本分类规则。
## 公共错误对象
Rust `BackendError` 的字段由 catalog 构造,字段定义如下(JSON 使用 camelCase):
```json
{
"version": 1,
"code": "DBX-JDBC-4001",
"messageKey": "backendErrors.jdbc.sqlFailed",
"messageParams": { "stage": "execute" },
"source": "jdbcAgent",
"operationOutcome": "unknown",
"detail": "relation missing_table does not exist",
"diagnostics": {
"category": "sql",
"stage": "execute",
"sqlState": "42P01",
"vendorCode": 0,
"exceptionClass": "java.sql.SQLException"
}
}
```
约束:
- `version` 当前为 `1`;`code`、`messageKey` 和字段含义发布后不可复用或改义。
- `source` 只能是 `jdbcAgent`、`jdbcAgentLegacy` 或 `legacyBackend`。
- `operationOutcome` 只能是 `not_started` 或 `unknown`。结果未知时不能自动重放用户操作。
- `messageParams` 只能包含 catalog 声明的 string、number、boolean 标量,不得携带 SQL、URL、凭据或任意对象。
- Rust 字段保持私有,新增错误必须通过 catalog 构造,避免 code、key 和参数声明漂移。
## 错误码 catalog
| code | 含义 |
| --- | --- |
| `DBX-JDBC-1001` | 连接建立失败 |
| `DBX-JDBC-1002` | 已建立连接中断 |
| `DBX-JDBC-2001` | 操作超时且尚未开始 |
| `DBX-JDBC-2002` | 操作超时但结果未知 |
| `DBX-JDBC-2003` | 操作取消 |
| `DBX-JDBC-3001` | 资源繁忙,操作尚未开始 |
| `DBX-JDBC-3002` | Runtime 被替换 |
| `DBX-JDBC-4001` | 数据库 SQL 执行失败 |
| `DBX-JDBC-5001` | Agent 传输或协议失败 |
| `DBX-JDBC-5002` | Agent 错误上下文违反契约 |
| `DBX-JDBC-9001` | 旧 Agent 错误无法可靠分类 |
| `DBX-LEGACY-0001` | 非 Agent 或未迁移的字符串错误 |
新增错误码时:
1. 在 `crates/dbx-core/src/backend_error.rs` 的 catalog 中增加唯一 code、`messageKey` 和参数声明。
2. 为所有 locale 增加相同 key,并扩展 catalog 完整性测试。
3. 增加 Rust 映射和序列化测试,以及前端 normalize/翻译测试。
4. 若错误来自 Agent,先在 `AgentErrorContext` 中定义可验证的事实和合法组合,再添加 catalog 映射;不要用错误文本补分类。
## detail 与安全边界
`detail` 是服务端诊断的可选补充,不是分类依据。Agent 错误映射会调用 `safe_detail`:
- 最多保留 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 也不会使用替代文本冒充原始错误。
## 传输边界
### Tauri Desktop
查询命令将 `QueryExecutionError` 映射为 `BackendError`。单语句和事务查询即使通过 `execute_multi` 命令执行,`dbx-core` 也会在整个 multi-query 核心链路中保留 `QueryExecutionError`,直到 Tauri 边界才转换为 `BackendError`;不得先降级为字符串再重建 envelope。`apps/desktop/src/lib/backend/tauri.ts` 在查询失败时抛出 `BackendErrorException`,前端因此可以同时取得 `messageKey` 和安全 `detail`。
### HTTP Web
`crates/dbx-web` 的 multi-query 路由也消费 typed 核心入口,并将 `AppError` 序列化为同一套 envelope;当前响应使用 `BackendError::without_detail()`,因此 HTTP 客户端只获得稳定摘要身份,不获得 detail。HTTP status 只表示传输结果,不能替代或改变 `BackendError.code`。
### 多语句查询
`ExecuteMultiResult.error` 和进度事件中的 `error` 是权威的结构化错误字段,`execution_error` 表示该结果确实失败。已经进入 typed 通用逐语句路径的错误必须直接从 `QueryExecutionError` 生成该字段,不能从兼容字符串反向推断。MySQL 和 SQL Server 的专用 batch executor 当前仍是字符串驱动边界,只有在驱动层提供可验证的 typed failure facts 后才能迁移,不能在 query/UI 层按错误正文补分类。旧的 `Error` 行仅用于兼容;真实查询结果中名为 `Error` 的普通列不能被当作失败。
## 前端展示规则
`normalizeBackendError` 只接受完整且类型正确的 envelope;`detail` 如果存在必须是 string。`translateBackendError` 的结构化路径为:
1. 使用 `messageKey` 和 `messageParams` 生成当前 locale 的自定义摘要。
2. 若 `detail` 非空且不同于摘要,在摘要后追加空行和 detail。
3. 无法识别的旧字符串继续原样展示或按兼容 pattern 翻译。
catch 到异常时必须把原始对象传给翻译器:
```ts
translateBackendError(t, error)
```
不要先执行 `error.message || String(error)`,否则会丢失 `messageKey`、参数和服务端 detail。`BackendErrorException`、嵌套的 `{ error }`/`{ backendError }` 和普通 `Error` 都由 `normalizeBackendError` 统一处理。
## 恢复规则
- `operationOutcome=unknown`:禁止自动重放 SQL、写入、DDL、事务和批处理。
- 用户操作:即使 Agent 声明可重试,也只做 Session/Runtime 恢复并向用户返回原错误。
- 只读 metadata:只有 connection + quarantine 场景可以新建 Session 重试,最多一次。
- `replace_runtime`:移除共享同一 Runtime 的路由;最终决定权在 Rust,不在 Agent 或前端。
- contract violation、timeout、cancel:至少隔离当前 Session;旧 Session 的迟到结果不得影响新的路由代际。
## 提交前检查
```text
cargo fmt --all -- --check
cargo clippy -j 1 -p dbx-core --no-default-features --all-targets -- -D warnings
cargo test -j 1 -p dbx-core --no-default-features --lib backend_error::tests
cargo test -j 1 -p dbx-core --no-default-features --lib agent_recovery::tests
cargo check -j 1 -p dbx-web --no-default-features
pnpm typecheck
pnpm vitest run apps/desktop/src/i18n/__tests__/backendErrors.spec.ts
```
+18
View File
@@ -42,6 +42,22 @@ test("activates an existing data table tab with a usable result", () => {
);
});
test("activates a successful data table tab with a real Error column", () => {
assert.equal(
canActivateExistingDataTableTab(
dataTab({
result: {
columns: ["Error"],
rows: [["valid value"]],
affected_rows: 0,
execution_time_ms: 1,
},
}),
),
true,
);
});
test("reloads restored data table tabs without a result", () => {
assert.equal(canActivateExistingDataTableTab(dataTab()), false);
});
@@ -55,6 +71,7 @@ test("reloads existing data table tabs showing an error result", () => {
rows: [["MySQL connection failed: Input/output error: No route to host (os error 65)"]],
affected_rows: 0,
execution_time_ms: 0,
execution_error: true,
},
}),
),
@@ -132,6 +149,7 @@ test("double activation preserves restored and error recovery behavior", () => {
rows: [["connection failed"]],
affected_rows: 0,
execution_time_ms: 0,
execution_error: true,
},
}),
"double",
+1
View File
@@ -5857,6 +5857,7 @@ test("failed schema session reset blocks query and Oracle explain execution", as
const queryTab = store.tabs.find((tab) => tab.id === queryTabId)!;
assert.equal(executeRequests, 0);
assert.equal(queryTab.result?.execution_error, true);
assert.equal(queryTab.result?.error?.detail, "reset failed");
assert.match(String(queryTab.result?.rows[0]?.[0]), /reset failed/i);
store.updateSchema(explainTabId, undefined);
+1 -1
View File
@@ -320,7 +320,7 @@ test("result grid cache key includes result run id and statement result index",
test("execution summary items include table and non-table statement results", () => {
const items = executionSummaryItems({
results: [result([]), result(["id"]), { ...result(["Error"]), rows: [["boom"]] }],
results: [result([]), result(["id"]), { ...result(["Error"]), rows: [["boom"]], execution_error: true }],
});
assert.deepEqual(
+8 -7
View File
@@ -472,6 +472,7 @@ fn user_shell_node_candidate() -> Option<NodeRuntimeCandidate> {
Some(NodeRuntimeCandidate { node_path })
}
#[cfg(not(windows))]
fn prefixed_output_path(output: &str, prefix: &str) -> Option<PathBuf> {
output
.lines()
@@ -834,7 +835,7 @@ fn path_string(path: &Path) -> String {
pub(crate) fn locate_command(command: &str) -> Option<String> {
#[cfg(windows)]
{
return locate_windows_command(command);
locate_windows_command(command)
}
#[cfg(not(windows))]
{
@@ -909,7 +910,7 @@ fn command_output(command: &str, args: &[&str]) -> Result<CommandOutput, String>
#[cfg(windows)]
{
return run_windows_command_candidates(command, args).or(direct);
run_windows_command_candidates(command, args).or(direct)
}
#[cfg(not(windows))]
@@ -986,7 +987,7 @@ fn windows_common_command_dirs() -> Vec<std::path::PathBuf> {
#[cfg(windows)]
fn run_command_through_user_shell(command: &str, args: &[&str]) -> Result<CommandOutput, String> {
let script = windows_command_script(command, args);
let mut output = run_command("powershell.exe", &["-NoProfile", "-ExecutionPolicy", "Bypass", "-Command", &script])?;
let mut output = run_command("powershell.exe", ["-NoProfile", "-ExecutionPolicy", "Bypass", "-Command", &script])?;
output.stdout = stdout_after_shell_marker(&output.stdout);
Ok(output)
}
@@ -1080,13 +1081,12 @@ mod tests {
#[cfg(windows)]
use super::first_windows_command_path;
#[cfg(not(windows))]
use super::{bash_login_script, NodeRuntimeCandidate};
use super::{bash_login_script, prefixed_output_path, NodeRuntimeCandidate};
use super::{
canonical_runtime_path, is_mcp_compatible_node_version, mcp_command_for_runtime, mcp_native_binary_path_for,
mcp_package, normalized_reported_path, npm_cli_candidates, parse_minimum_node_version, parse_node_version,
prefer_runtime, prefixed_output_path, require_managed_mcp_command, resolve_managed_mcp_command,
stdout_after_shell_marker, NodeRuntime, NodeVersion, MCP_MIN_NODE_VERSION_REQUIREMENT, MCP_PACKAGE_NAME,
SHELL_COMMAND_MARKER,
prefer_runtime, require_managed_mcp_command, resolve_managed_mcp_command, stdout_after_shell_marker,
NodeRuntime, NodeVersion, MCP_MIN_NODE_VERSION_REQUIREMENT, MCP_PACKAGE_NAME, SHELL_COMMAND_MARKER,
};
#[cfg(not(windows))]
use super::{shell_command_script, shell_quote};
@@ -1151,6 +1151,7 @@ mod tests {
}
#[test]
#[cfg(not(windows))]
fn prefixed_output_path_ignores_empty_values() {
let output = "node=/opt/node/bin/node\nmissing=\n";
+9 -6
View File
@@ -3,6 +3,7 @@ use std::time::Instant;
use tauri::{AppHandle, Emitter, State};
use crate::commands::connection::AppState;
use dbx_core::backend_error::BackendError;
use dbx_core::db;
use dbx_core::models::connection::DatabaseType;
use dbx_core::query_cancel::RunningTaskMetadata;
@@ -18,7 +19,8 @@ struct ExecuteMultiProgress {
success: bool,
execution_time_ms: u128,
affected_rows: u64,
error: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
error: Option<BackendError>,
}
#[tauri::command]
@@ -38,7 +40,7 @@ pub async fn execute_query(
client_session_id: Option<String>,
timeout_secs: Option<u64>,
execution_mode: Option<dbx_core::query::QueryExecutionMode>,
) -> Result<db::QueryResult, String> {
) -> Result<db::QueryResult, BackendError> {
let execution_id = execution_id.filter(|id| !id.trim().is_empty());
let registered_query = execution_id.as_ref().map(|id| {
state.running_queries.register_task(
@@ -48,7 +50,7 @@ pub async fn execute_query(
});
let cancel_token = registered_query.as_ref().map(|query| query.token());
dbx_core::query::execute_sql_statement_with_options(
dbx_core::query::execute_sql_statement_with_options_typed(
&state,
&connection_id,
&database,
@@ -69,6 +71,7 @@ pub async fn execute_query(
},
)
.await
.map_err(dbx_core::query::QueryExecutionError::into_backend_error)
}
#[tauri::command]
@@ -91,7 +94,7 @@ pub async fn execute_multi(
use_transaction: Option<bool>,
continue_on_error: Option<bool>,
execution_mode: Option<dbx_core::query::QueryExecutionMode>,
) -> Result<Vec<dbx_core::query::ExecuteMultiResult>, String> {
) -> Result<Vec<dbx_core::query::ExecuteMultiResult>, BackendError> {
let execution_id = execution_id.filter(|id| !id.trim().is_empty());
let registered_query = execution_id.as_ref().map(|id| {
state.running_queries.register_task(
@@ -130,7 +133,7 @@ pub async fn execute_multi(
schema
);
let result = dbx_core::query::execute_multi_core_with_options_for_client_and_progress(
let result = dbx_core::query::execute_multi_core_with_options_for_client_and_progress_typed(
&state,
&connection_id,
&database,
@@ -169,7 +172,7 @@ pub async fn execute_multi(
error
),
}
result
result.map_err(dbx_core::query::QueryExecutionError::into_backend_error)
}
#[tauri::command]