fix(query): preserve structured backend errors

This commit is contained in:
miracle authored and GitHub committed 2026-08-02 16:37:46 +08:00
1 parent 2e9dcdc26b
commit bcafc4a474
58 files changed
+4182 -554

No files matched your search

+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");
}
}