1407 lines
53 KiB
Rust
1407 lines
53 KiB
Rust
//! Stable 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 = 64 * 1024;
|
|
|
|
/// The v1 compatibility source of a backend error.
|
|
///
|
|
/// New code should use `origin` for subsystem/adapter information. This field
|
|
/// remains unchanged so older clients can continue to consume v1 envelopes.
|
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
|
#[serde(rename_all = "camelCase")]
|
|
pub enum BackendErrorSource {
|
|
JdbcAgent,
|
|
JdbcAgentLegacy,
|
|
LegacyBackend,
|
|
}
|
|
|
|
/// Extensible subsystem metadata for backend errors.
|
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
|
|
#[serde(rename_all = "camelCase")]
|
|
pub enum BackendSubsystem {
|
|
Database,
|
|
Tunnel,
|
|
Extension,
|
|
Ai,
|
|
MessageQueue,
|
|
Backend,
|
|
}
|
|
|
|
/// The adapter that produced the error within its subsystem.
|
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
|
|
#[serde(rename_all = "camelCase")]
|
|
pub enum BackendErrorAdapter {
|
|
JdbcAgent,
|
|
JdbcAgentLegacy,
|
|
Native,
|
|
Plugin,
|
|
Http,
|
|
Legacy,
|
|
}
|
|
|
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
|
|
#[serde(rename_all = "camelCase")]
|
|
pub struct BackendErrorOrigin {
|
|
subsystem: BackendSubsystem,
|
|
adapter: BackendErrorAdapter,
|
|
#[serde(skip_serializing_if = "Option::is_none")]
|
|
driver: Option<&'static str>,
|
|
}
|
|
|
|
impl BackendErrorOrigin {
|
|
const fn database(adapter: BackendErrorAdapter) -> Self {
|
|
Self { subsystem: BackendSubsystem::Database, adapter, driver: None }
|
|
}
|
|
|
|
const fn database_driver(adapter: BackendErrorAdapter, driver: &'static str) -> Self {
|
|
Self { subsystem: BackendSubsystem::Database, adapter, driver: Some(driver) }
|
|
}
|
|
|
|
const fn backend() -> Self {
|
|
Self { subsystem: BackendSubsystem::Backend, adapter: BackendErrorAdapter::Legacy, driver: None }
|
|
}
|
|
}
|
|
|
|
/// Whether the operation definitely had not started or its result is unknown.
|
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
|
#[serde(rename_all = "snake_case")]
|
|
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>,
|
|
#[serde(skip_serializing_if = "Option::is_none")]
|
|
adapter_code: 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,
|
|
origin: BackendErrorOrigin,
|
|
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);
|
|
let detail = match context.category {
|
|
AgentErrorCategory::Sql => bounded_native_detail(message),
|
|
_ => bounded_detail(message),
|
|
};
|
|
Self::new(
|
|
entry,
|
|
BackendErrorSource::JdbcAgent,
|
|
BackendErrorOrigin::database(BackendErrorAdapter::JdbcAgent),
|
|
outcome,
|
|
stage_param(entry, context.stage),
|
|
detail,
|
|
Some(diagnostics_from_context(context)),
|
|
)
|
|
}
|
|
AgentCallError::Legacy { message, hints, .. } => {
|
|
let detail = match hints.category {
|
|
Some(AgentErrorCategory::Sql) => bounded_native_detail(message),
|
|
_ => bounded_detail(message),
|
|
};
|
|
Self::new(
|
|
catalog_entry(CatalogCode::JdbcLegacyFailure),
|
|
BackendErrorSource::JdbcAgentLegacy,
|
|
BackendErrorOrigin::database(BackendErrorAdapter::JdbcAgentLegacy),
|
|
BackendOperationOutcome::Unknown,
|
|
BTreeMap::new(),
|
|
detail,
|
|
None,
|
|
)
|
|
}
|
|
AgentCallError::ContractViolation { message, .. } => Self::new(
|
|
catalog_entry(CatalogCode::ContractInvalid),
|
|
BackendErrorSource::JdbcAgent,
|
|
BackendErrorOrigin::database(BackendErrorAdapter::JdbcAgent),
|
|
BackendOperationOutcome::Unknown,
|
|
BTreeMap::new(),
|
|
bounded_detail(message),
|
|
None,
|
|
),
|
|
AgentCallError::Transport { message } => Self::new(
|
|
catalog_entry(CatalogCode::ProtocolFailed),
|
|
BackendErrorSource::JdbcAgent,
|
|
BackendErrorOrigin::database(BackendErrorAdapter::JdbcAgent),
|
|
BackendOperationOutcome::Unknown,
|
|
BTreeMap::new(),
|
|
bounded_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,
|
|
BackendErrorOrigin::database(BackendErrorAdapter::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,
|
|
BackendErrorOrigin::database(BackendErrorAdapter::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,
|
|
BackendErrorOrigin::backend(),
|
|
BackendOperationOutcome::Unknown,
|
|
BTreeMap::new(),
|
|
bounded_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,
|
|
BackendErrorOrigin::database(BackendErrorAdapter::Native),
|
|
BackendOperationOutcome::Unknown,
|
|
stage_param(entry, AgentErrorStage::Execute),
|
|
bounded_detail(message),
|
|
Some(diagnostics_for_local("timeout", AgentErrorStage::Execute)),
|
|
)
|
|
}
|
|
|
|
/// Create a retryable envelope for a manual transaction session that DBX
|
|
/// already rolled back before the requested SQL was executed.
|
|
pub fn from_manual_transaction_session_expired(timeout_secs: u64) -> Self {
|
|
let entry = catalog_entry(CatalogCode::TransactionSessionExpired);
|
|
let mut params = BTreeMap::new();
|
|
params
|
|
.insert("timeoutSecs".to_string(), BackendMessageParam::Integer(timeout_secs.min(i64::MAX as u64) as i64));
|
|
Self::new(
|
|
entry,
|
|
BackendErrorSource::LegacyBackend,
|
|
BackendErrorOrigin::database(BackendErrorAdapter::Native),
|
|
BackendOperationOutcome::NotStarted,
|
|
params,
|
|
None,
|
|
Some(diagnostics_for_local("transaction", AgentErrorStage::Execute)),
|
|
)
|
|
}
|
|
|
|
/// Create a SQL failure envelope while retaining bounded native driver detail.
|
|
///
|
|
/// The caller must pass a typed SQL execution error, not a connection or
|
|
/// transport diagnostic. Native SQL text is intentionally not parsed or
|
|
/// rewritten because the database dialect owns its format.
|
|
pub fn from_sql_detail(message: &str) -> Self {
|
|
let entry = catalog_entry(CatalogCode::SqlFailed);
|
|
Self::new(
|
|
entry,
|
|
BackendErrorSource::JdbcAgent,
|
|
BackendErrorOrigin::database(BackendErrorAdapter::Native),
|
|
BackendOperationOutcome::Unknown,
|
|
stage_param(entry, AgentErrorStage::Execute),
|
|
bounded_native_detail(message),
|
|
Some(diagnostics_for_local("sql", AgentErrorStage::Execute)),
|
|
)
|
|
}
|
|
|
|
/// Adapt a DuckDB worker error while retaining both the native detail and
|
|
/// the worker protocol code for diagnostics at the public boundary.
|
|
pub fn from_duckdb_worker_error(code: &str, message: &str) -> Self {
|
|
let is_sql_error = matches!(code, "duckdb_execute_failed" | "duckdb_worker_poisoned");
|
|
let entry = catalog_entry(if is_sql_error { CatalogCode::SqlFailed } else { CatalogCode::LegacyBackend });
|
|
let source = if is_sql_error { BackendErrorSource::JdbcAgent } else { BackendErrorSource::LegacyBackend };
|
|
let category = if is_sql_error { "sql" } else { "backend" };
|
|
Self::new(
|
|
entry,
|
|
source,
|
|
BackendErrorOrigin::database_driver(BackendErrorAdapter::Native, "duckdb"),
|
|
BackendOperationOutcome::Unknown,
|
|
stage_param(entry, AgentErrorStage::Execute),
|
|
if is_sql_error { bounded_native_detail(message) } else { bounded_detail(message) },
|
|
Some(diagnostics_for_local_with_adapter_code(category, AgentErrorStage::Execute, code)),
|
|
)
|
|
}
|
|
|
|
/// Create a cancellation envelope for work canceled by the Rust executor.
|
|
pub fn from_canceled(stage: AgentErrorStage, operation_outcome: AgentOperationOutcome) -> Self {
|
|
let entry = catalog_entry(CatalogCode::Canceled);
|
|
Self::new(
|
|
entry,
|
|
BackendErrorSource::LegacyBackend,
|
|
BackendErrorOrigin::database(BackendErrorAdapter::Native),
|
|
map_outcome(operation_outcome),
|
|
stage_param(entry, stage),
|
|
None,
|
|
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 origin(&self) -> BackendErrorOrigin {
|
|
self.origin
|
|
}
|
|
|
|
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,
|
|
origin: BackendErrorOrigin,
|
|
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,
|
|
origin,
|
|
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,
|
|
TransactionSessionExpired,
|
|
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 TRANSACTION_TIMEOUT_PARAM: &[ParamSpec] = &[ParamSpec { name: "timeoutSecs", kind: ParamKind::Integer }];
|
|
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::TransactionSessionExpired,
|
|
CatalogEntry {
|
|
code: "DBX-TXN-1001",
|
|
message_key: "backendErrors.transaction.sessionExpired",
|
|
params: TRANSACTION_TIMEOUT_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)),
|
|
..Default::default()
|
|
}
|
|
}
|
|
|
|
fn diagnostics_for_local(category: &str, stage: AgentErrorStage) -> BackendErrorDiagnostics {
|
|
diagnostics_for_local_with_adapter_code(category, stage, "")
|
|
}
|
|
|
|
fn diagnostics_for_local_with_adapter_code(
|
|
category: &str,
|
|
stage: AgentErrorStage,
|
|
adapter_code: &str,
|
|
) -> BackendErrorDiagnostics {
|
|
BackendErrorDiagnostics {
|
|
category: Some(category.to_string()),
|
|
stage: Some(stage_name(stage).to_string()),
|
|
adapter_code: (!adapter_code.is_empty()).then(|| bounded_ascii(adapter_code, 64)),
|
|
..Default::default()
|
|
}
|
|
}
|
|
|
|
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 bounded_detail(message: &str) -> Option<String> {
|
|
if message.trim().is_empty() {
|
|
return None;
|
|
}
|
|
let detail = safe_detail(message)?;
|
|
let detail = bounded_text(&detail, MAX_DETAIL_BYTES);
|
|
(!detail.is_empty()).then_some(detail)
|
|
}
|
|
|
|
fn bounded_native_detail(message: &str) -> Option<String> {
|
|
// This path is only for typed native SQL failures. Do not infer or rewrite
|
|
// SQL content here; preserving the driver's diagnostic is the contract.
|
|
let trimmed = message.trim();
|
|
if trimmed.is_empty() {
|
|
return None;
|
|
}
|
|
let detail = bounded_text(trimmed, MAX_DETAIL_BYTES);
|
|
(!detail.is_empty()).then_some(detail)
|
|
}
|
|
|
|
fn safe_detail(message: &str) -> Option<String> {
|
|
let trimmed = message.trim();
|
|
if trimmed.is_empty() {
|
|
return None;
|
|
}
|
|
|
|
let detail = redact_session_identifier(&redact_sensitive_fragments(trimmed));
|
|
if contains_only_redacted_sensitive_tokens(&detail) {
|
|
return None;
|
|
}
|
|
(!detail.is_empty()).then_some(detail)
|
|
}
|
|
|
|
fn contains_only_redacted_sensitive_tokens(value: &str) -> bool {
|
|
let mut has_token = false;
|
|
let all_sensitive = value.split_whitespace().all(|token| {
|
|
has_token = true;
|
|
if token == "[redacted]" || matches!(token, "=" | ":") {
|
|
return true;
|
|
}
|
|
let normalized = token.trim_matches(|ch: char| !ch.is_ascii_alphanumeric() && ch != '_' && ch != '-');
|
|
if sensitive_key_name(normalized) {
|
|
return true;
|
|
}
|
|
['=', ':'].iter().any(|separator| {
|
|
token.split_once(*separator).is_some_and(|(key, value)| {
|
|
sensitive_key_name(key)
|
|
&& value.trim_matches(|ch| matches!(ch, ';' | ',' | ']' | ')' | '}')) == "[redacted]"
|
|
})
|
|
})
|
|
});
|
|
has_token && all_sensitive
|
|
}
|
|
|
|
fn redact_sensitive_fragments(value: &str) -> String {
|
|
let redacted_url = redact_url_userinfo(value);
|
|
let mut redacted = String::with_capacity(redacted_url.len());
|
|
let mut cursor = 0;
|
|
while let Some((value_start, value_end)) = next_sensitive_assignment(&redacted_url, cursor) {
|
|
redacted.push_str(&redacted_url[cursor..value_start]);
|
|
redacted.push_str("[redacted]");
|
|
cursor = value_end;
|
|
}
|
|
redacted.push_str(&redacted_url[cursor..]);
|
|
let source_tokens = redacted.split_whitespace().collect::<Vec<_>>();
|
|
let mut tokens = Vec::with_capacity(source_tokens.len());
|
|
let mut redact_next = false;
|
|
let mut changed = false;
|
|
let mut index = 0;
|
|
while index < source_tokens.len() {
|
|
let token = source_tokens[index];
|
|
if redact_next {
|
|
tokens.push("[redacted]");
|
|
redact_next = false;
|
|
changed = true;
|
|
} else if token.eq_ignore_ascii_case("bearer") {
|
|
tokens.push("[redacted]");
|
|
redact_next = true;
|
|
changed = true;
|
|
} else if token.eq_ignore_ascii_case("authorization:") && source_tokens.get(index + 1) == Some(&"[redacted]") {
|
|
tokens.push("[redacted]");
|
|
index += 1;
|
|
changed = true;
|
|
} else {
|
|
tokens.push(token);
|
|
}
|
|
index += 1;
|
|
}
|
|
if changed {
|
|
tokens.join(" ")
|
|
} else {
|
|
redacted
|
|
}
|
|
}
|
|
|
|
fn redact_url_userinfo(value: &str) -> String {
|
|
let mut ranges = Vec::new();
|
|
let mut search_from = 0;
|
|
while let Some(relative_scheme_end) = value[search_from..].find("://") {
|
|
let authority_start = search_from + relative_scheme_end + "://".len();
|
|
let authority_end = value[authority_start..]
|
|
.char_indices()
|
|
.find(|(_, ch)| matches!(ch, '/' | '?' | '#') || ch.is_ascii_whitespace())
|
|
.map(|(offset, _)| authority_start + offset)
|
|
.unwrap_or(value.len());
|
|
let authority = &value[authority_start..authority_end];
|
|
|
|
if let Some(user_info_end) = authority.rfind('@') {
|
|
let user_info = &authority[..user_info_end];
|
|
if let Some(password_separator) = user_info.find(':') {
|
|
let password_start = authority_start + password_separator + 1;
|
|
let password_end = authority_start + user_info_end;
|
|
if password_start < password_end {
|
|
ranges.push((password_start, password_end));
|
|
}
|
|
}
|
|
}
|
|
|
|
if authority_end == value.len() {
|
|
break;
|
|
}
|
|
search_from = authority_end;
|
|
}
|
|
|
|
if ranges.is_empty() {
|
|
return value.to_string();
|
|
}
|
|
|
|
let mut redacted = String::with_capacity(value.len());
|
|
let mut cursor = 0;
|
|
for (start, end) in ranges {
|
|
redacted.push_str(&value[cursor..start]);
|
|
redacted.push_str("[redacted]");
|
|
cursor = end;
|
|
}
|
|
redacted.push_str(&value[cursor..]);
|
|
redacted
|
|
}
|
|
|
|
fn sensitive_key_name(key: &str) -> bool {
|
|
let key = key.chars().filter(|ch| ch.is_ascii_alphanumeric()).map(|ch| ch.to_ascii_lowercase()).collect::<String>();
|
|
matches!(
|
|
key.as_str(),
|
|
"password"
|
|
| "passwd"
|
|
| "pwd"
|
|
| "token"
|
|
| "accesstoken"
|
|
| "refreshtoken"
|
|
| "secret"
|
|
| "authorization"
|
|
| "apikey"
|
|
| "credential"
|
|
| "auth"
|
|
| "key"
|
|
| "user"
|
|
| "username"
|
|
| "uid"
|
|
| "accesskey"
|
|
| "privatekey"
|
|
| "session"
|
|
| "sessionid"
|
|
| "agentsessionid"
|
|
| "jwt"
|
|
| "cookie"
|
|
)
|
|
}
|
|
|
|
fn next_sensitive_assignment(value: &str, from: usize) -> Option<(usize, usize)> {
|
|
let mut index = from;
|
|
while index < value.len() {
|
|
let ch = value[index..].chars().next()?;
|
|
if !is_sensitive_key_char(ch)
|
|
|| (index > 0 && value[..index].chars().next_back().is_some_and(is_sensitive_key_char))
|
|
{
|
|
index += ch.len_utf8();
|
|
continue;
|
|
}
|
|
|
|
let key_start = index;
|
|
let mut key_end = index;
|
|
while key_end < value.len() {
|
|
let key_char = value[key_end..].chars().next()?;
|
|
if !is_sensitive_key_char(key_char) {
|
|
break;
|
|
}
|
|
key_end += key_char.len_utf8();
|
|
}
|
|
if !sensitive_key_name(&value[key_start..key_end]) {
|
|
index = key_end;
|
|
continue;
|
|
}
|
|
|
|
let mut separator_start = key_end;
|
|
if let Some(quote) = value[..key_start].chars().next_back().filter(|ch| matches!(ch, '\'' | '"')) {
|
|
if value[separator_start..].starts_with(quote) {
|
|
separator_start += quote.len_utf8();
|
|
}
|
|
}
|
|
while separator_start < value.len()
|
|
&& value[separator_start..].chars().next().is_some_and(|ch| ch.is_ascii_whitespace())
|
|
{
|
|
separator_start += value[separator_start..].chars().next()?.len_utf8();
|
|
}
|
|
let separator = value[separator_start..].chars().next()?;
|
|
if !matches!(separator, '=' | ':') {
|
|
index = key_end;
|
|
continue;
|
|
}
|
|
|
|
let mut value_start = separator_start + separator.len_utf8();
|
|
while value_start < value.len()
|
|
&& value[value_start..].chars().next().is_some_and(|ch| ch.is_ascii_whitespace())
|
|
{
|
|
value_start += value[value_start..].chars().next()?.len_utf8();
|
|
}
|
|
let value_end = consume_sensitive_value(value, value_start, &value[key_start..key_end]);
|
|
return Some((value_start, value_end));
|
|
}
|
|
None
|
|
}
|
|
|
|
fn is_sensitive_key_char(ch: char) -> bool {
|
|
ch.is_ascii_alphanumeric() || matches!(ch, '_' | '-')
|
|
}
|
|
|
|
fn consume_sensitive_value(value: &str, start: usize, key: &str) -> usize {
|
|
if start >= value.len() {
|
|
return start;
|
|
}
|
|
let first = value[start..].chars().next().unwrap_or_default();
|
|
if first == '{' {
|
|
return consume_braced_value(value, start);
|
|
}
|
|
if matches!(first, '\'' | '"') {
|
|
let mut escaped = false;
|
|
let mut iter = value[start + first.len_utf8()..].char_indices();
|
|
while let Some((offset, ch)) = iter.next() {
|
|
if escaped {
|
|
escaped = false;
|
|
continue;
|
|
}
|
|
if ch == '\\' {
|
|
escaped = true;
|
|
continue;
|
|
}
|
|
if ch == first {
|
|
let end = start + first.len_utf8() + offset + ch.len_utf8();
|
|
if value[end..].starts_with(first) {
|
|
iter.next();
|
|
continue;
|
|
}
|
|
return end;
|
|
}
|
|
}
|
|
return value.len();
|
|
}
|
|
|
|
let mut end = start;
|
|
while end < value.len() {
|
|
let ch = value[end..].chars().next().unwrap_or_default();
|
|
if ch.is_ascii_whitespace() || matches!(ch, '&' | ';' | ',' | ']' | ')' | '}') {
|
|
break;
|
|
}
|
|
end += ch.len_utf8();
|
|
}
|
|
|
|
let normalized_key =
|
|
key.chars().filter(|ch| ch.is_ascii_alphanumeric()).map(|ch| ch.to_ascii_lowercase()).collect::<String>();
|
|
if normalized_key == "authorization" {
|
|
let scheme = value[start..end].to_ascii_lowercase();
|
|
if matches!(scheme.as_str(), "bearer" | "basic" | "digest") {
|
|
let mut token_start = end;
|
|
while token_start < value.len()
|
|
&& value[token_start..].chars().next().is_some_and(|ch| ch.is_ascii_whitespace())
|
|
{
|
|
token_start += value[token_start..].chars().next().unwrap().len_utf8();
|
|
}
|
|
let mut token_end = token_start;
|
|
while token_end < value.len() {
|
|
let ch = value[token_end..].chars().next().unwrap_or_default();
|
|
if ch.is_ascii_whitespace() || matches!(ch, '&' | ';' | ',' | ']' | ')' | '}') {
|
|
break;
|
|
}
|
|
token_end += ch.len_utf8();
|
|
}
|
|
return token_end;
|
|
}
|
|
}
|
|
end
|
|
}
|
|
|
|
fn consume_braced_value(value: &str, start: usize) -> usize {
|
|
let mut index = start + '{'.len_utf8();
|
|
while index < value.len() {
|
|
let ch = value[index..].chars().next().unwrap_or_default();
|
|
if ch == '}' {
|
|
let next = index + ch.len_utf8();
|
|
if value[next..].starts_with('}') {
|
|
index = next + '}'.len_utf8();
|
|
continue;
|
|
}
|
|
return next;
|
|
}
|
|
index += ch.len_utf8();
|
|
}
|
|
value.len()
|
|
}
|
|
|
|
fn redact_session_identifier(value: &str) -> String {
|
|
let lowered = value.to_ascii_lowercase();
|
|
for marker in ["session id", "session_id", "agentsessionid"] {
|
|
if let Some(marker_start) = lowered.find(marker) {
|
|
let value_start = value[marker_start + marker.len()..]
|
|
.char_indices()
|
|
.find(|(_, ch)| !ch.is_ascii_whitespace() && *ch != ':' && *ch != '=')
|
|
.map(|(offset, _)| marker_start + marker.len() + offset);
|
|
if let Some(value_start) = value_start {
|
|
let value_end = value[value_start..]
|
|
.char_indices()
|
|
.find(|(_, ch)| ch.is_ascii_whitespace())
|
|
.map(|(offset, _)| value_start + offset)
|
|
.unwrap_or(value.len());
|
|
let mut result = value.to_string();
|
|
result.replace_range(value_start..value_end, "[redacted]");
|
|
return result;
|
|
}
|
|
}
|
|
}
|
|
let Some(colon) = value.find(':') else {
|
|
return value.to_string();
|
|
};
|
|
if !lowered[..colon].contains("session") {
|
|
return value.to_string();
|
|
}
|
|
let value_start = value[colon + 1..]
|
|
.char_indices()
|
|
.find(|(_, ch)| !ch.is_ascii_whitespace())
|
|
.map(|(offset, _)| colon + 1 + offset);
|
|
let Some(value_start) = value_start else {
|
|
return value.to_string();
|
|
};
|
|
let value_end = value[value_start..]
|
|
.char_indices()
|
|
.find(|(_, ch)| ch.is_ascii_whitespace())
|
|
.map(|(offset, _)| value_start + offset)
|
|
.unwrap_or(value.len());
|
|
let mut result = value.to_string();
|
|
result.replace_range(value_start..value_end, "[redacted]");
|
|
result
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
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::TransactionSessionExpired, "DBX-TXN-1001", "backendErrors.transaction.sessionExpired"),
|
|
(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 manual_transaction_session_expiry_is_retryable_before_execution() {
|
|
let error = BackendError::from_manual_transaction_session_expired(300);
|
|
let payload = serde_json::to_value(&error).unwrap();
|
|
|
|
assert_eq!(error.code(), "DBX-TXN-1001");
|
|
assert_eq!(error.operation_outcome(), BackendOperationOutcome::NotStarted);
|
|
assert_eq!(payload["messageKey"], "backendErrors.transaction.sessionExpired");
|
|
assert_eq!(payload["messageParams"]["timeoutSecs"], 300);
|
|
assert_eq!(payload["diagnostics"]["category"], "transaction");
|
|
}
|
|
|
|
#[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 sql_errors_expose_generic_database_origin_without_changing_v1_source() {
|
|
let payload =
|
|
serde_json::to_value(BackendError::from_sql_detail("relation missing_table does not exist")).unwrap();
|
|
assert_eq!(payload["source"], "jdbcAgent");
|
|
assert_eq!(payload["origin"]["subsystem"], "database");
|
|
assert_eq!(payload["origin"]["adapter"], "native");
|
|
}
|
|
|
|
#[test]
|
|
fn duckdb_worker_sql_error_preserves_worker_code_detail_and_driver_origin() {
|
|
let payload = serde_json::to_value(BackendError::from_duckdb_worker_error(
|
|
"duckdb_execute_failed",
|
|
"Parser Error: syntax error at or near SELECT",
|
|
))
|
|
.unwrap();
|
|
|
|
assert_eq!(payload["code"], "DBX-JDBC-4001");
|
|
assert_eq!(payload["detail"], "Parser Error: syntax error at or near SELECT");
|
|
assert_eq!(payload["origin"]["subsystem"], "database");
|
|
assert_eq!(payload["origin"]["adapter"], "native");
|
|
assert_eq!(payload["origin"]["driver"], "duckdb");
|
|
assert_eq!(payload["diagnostics"]["adapterCode"], "duckdb_execute_failed");
|
|
}
|
|
|
|
#[test]
|
|
fn public_detail_redacts_credentials_and_preserves_native_sql_text() {
|
|
for (message, expected) in [
|
|
("Incorrect syntax near SELECT", "Incorrect syntax near SELECT"),
|
|
("ERROR: relation missing_table does not exist", "ERROR: relation missing_table does not exist"),
|
|
("ORA-00942: table or view does not exist", "ORA-00942: table or view does not exist"),
|
|
(
|
|
"syntax error in statement [UPDATE customers SET ssn='123']",
|
|
"syntax error in statement [UPDATE customers SET ssn='123']",
|
|
),
|
|
(
|
|
"syntax error at or near 'SELECT email FROM customers WHERE ssn = 123'",
|
|
"syntax error at or near 'SELECT email FROM customers WHERE ssn = 123'",
|
|
),
|
|
(
|
|
"failed executing SELECT * FROM [Users] WHERE email='literal-secret@example.com'",
|
|
"failed executing SELECT * FROM [Users] WHERE email='literal-secret@example.com'",
|
|
),
|
|
(
|
|
"connection failed: jdbc:postgresql://host/db?user=alice&password=secret",
|
|
"connection failed: jdbc:postgresql://host/db?user=[redacted]&password=[redacted]",
|
|
),
|
|
("connection failed: Authorization: Bearer abc123", "connection failed: [redacted]"),
|
|
("Agent session not found: 7f51e7f4-7cee-42db-bfeb-76d1199d1afe", "Agent session not found: [redacted]"),
|
|
("Agent session id private-session", "Agent session id [redacted]"),
|
|
] {
|
|
assert_eq!(bounded_detail(message).as_deref(), Some(expected), "detail changed unexpectedly: {message}");
|
|
}
|
|
for message in ["DROP TABLE users", "SELECT password FROM users", "CALL refresh_cache()"] {
|
|
assert_eq!(bounded_detail(message).as_deref(), Some(message));
|
|
}
|
|
for message in
|
|
["Bearer token-value", "Authorization: Bearer token-value", "password = secret", "password: secret"]
|
|
{
|
|
assert!(bounded_detail(message).is_none(), "credential detail leaked: {message}");
|
|
}
|
|
let error = BackendError::from_agent_call_error(&AgentCallError::Structured {
|
|
rpc_code: -1,
|
|
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_eq!(value["detail"], "jdbc:postgresql://host/db?password=[redacted]");
|
|
}
|
|
|
|
#[test]
|
|
fn public_detail_redacts_sensitive_key_value_variants() {
|
|
let message = concat!(
|
|
"driver failed: PASSWORD:secret-a passwd = 'secret-b' PWD : \"secret-c\" ",
|
|
"refresh_token=secret-d access-token : secret-e api_key='secret-f' ",
|
|
"apikey : secret-g credential=secret-h private-key:secret-i ",
|
|
"authorization: Bearer secret-j cookie = \"session=secret-k\" ",
|
|
"sessionid:secret-l jwt = secret-m secret:secret-n token = secret-o ",
|
|
"access_token:secret-p session=secret-q private_key='secret-r'"
|
|
);
|
|
|
|
let detail = bounded_detail(message).expect("non-sensitive context should remain");
|
|
for secret in [
|
|
"secret-a", "secret-b", "secret-c", "secret-d", "secret-e", "secret-f", "secret-g", "secret-h", "secret-i",
|
|
"secret-j", "secret-k", "secret-l", "secret-m", "secret-n", "secret-o", "secret-p", "secret-q", "secret-r",
|
|
] {
|
|
assert!(!detail.contains(secret), "sensitive value leaked: {secret}; detail={detail}");
|
|
}
|
|
assert!(detail.contains("driver failed"));
|
|
}
|
|
|
|
#[test]
|
|
fn public_detail_redacts_mixed_url_and_dsn_sensitive_fields() {
|
|
let message = concat!(
|
|
"connection failed for jdbc:postgresql://db.example/app?user=alice&password : url-secret&sslmode=require ",
|
|
"host=db.example port=5432 password:\"dsn secret\" session = 'session-secret'"
|
|
);
|
|
|
|
let detail = bounded_detail(message).expect("non-sensitive context should remain");
|
|
for secret in ["alice", "url-secret", "dsn secret", "session-secret"] {
|
|
assert!(!detail.contains(secret), "sensitive value leaked: {secret}; detail={detail}");
|
|
}
|
|
assert!(detail.contains("connection failed"));
|
|
}
|
|
|
|
#[test]
|
|
fn serialized_native_sql_detail_preserves_url_like_text_and_braced_literals() {
|
|
let cases = [
|
|
(
|
|
"syntax error in statement [SELECT 'jdbc:postgresql://alice:url-secret@db.example/app']",
|
|
"syntax error in statement [SELECT 'jdbc:postgresql://alice:url-secret@db.example/app']",
|
|
),
|
|
(
|
|
"syntax error in statement [SELECT 'Driver={PostgreSQL};PWD={dsn;secret};SERVER=db.example']",
|
|
"syntax error in statement [SELECT 'Driver={PostgreSQL};PWD={dsn;secret};SERVER=db.example']",
|
|
),
|
|
];
|
|
|
|
for (message, expected) in cases {
|
|
let payload = serde_json::to_value(BackendError::from_sql_detail(message)).unwrap();
|
|
assert_eq!(payload["detail"].as_str(), Some(expected), "detail changed unexpectedly: {message}");
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn serialized_public_detail_preserves_nested_sql_and_literals() {
|
|
let message = "syntax error in statement [SELECT * FROM [Users] WHERE email='literal-secret@example.com']";
|
|
let payload = serde_json::to_value(BackendError::from_sql_detail(message)).unwrap();
|
|
|
|
assert_eq!(payload["detail"].as_str(), Some(message));
|
|
}
|
|
|
|
#[test]
|
|
fn structured_connection_detail_still_redacts_credentials() {
|
|
let error = BackendError::from_agent_call_error(&AgentCallError::Structured {
|
|
rpc_code: -1,
|
|
message: "connection failed: jdbc:postgresql://alice:url-secret@db.example/app".to_string(),
|
|
context: context(
|
|
AgentErrorCategory::Connection,
|
|
AgentErrorStage::Connect,
|
|
AgentOperationOutcome::NotStarted,
|
|
),
|
|
});
|
|
|
|
assert_eq!(error.detail(), Some("connection failed: jdbc:postgresql://alice:[redacted]@db.example/app"));
|
|
}
|
|
|
|
#[test]
|
|
fn structured_sql_detail_preserves_native_text() {
|
|
let message = "syntax error in statement [UPDATE users SET password='user-input']";
|
|
let error = BackendError::from_agent_call_error(&AgentCallError::Structured {
|
|
rpc_code: -1,
|
|
message: message.to_string(),
|
|
context: context(AgentErrorCategory::Sql, AgentErrorStage::Execute, AgentOperationOutcome::Unknown),
|
|
});
|
|
|
|
assert_eq!(error.detail(), Some(message));
|
|
}
|
|
|
|
#[test]
|
|
fn bounded_detail_remains_bounded_after_redaction() {
|
|
let detail = bounded_detail(&format!("ERROR: {}", "x".repeat(MAX_DETAIL_BYTES + 100))).unwrap();
|
|
assert!(detail.len() > 512);
|
|
assert!(detail.len() <= MAX_DETAIL_BYTES);
|
|
assert!(detail.is_char_boundary(MAX_DETAIL_BYTES));
|
|
}
|
|
|
|
#[test]
|
|
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\n 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("无效的表或视图名\n错误码: -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");
|
|
}
|
|
}
|