fix(db): expand source chain for all DB error logging

The previous fix only covered migration errors. The same truncation
affected two more places, both because tokio_postgres::Error's Display
prints only "db error" while the real message lives in source():

- api/error.rs: AppError::{db_conn,query,tx} logged with {e}, so every
  runtime DB failure showed as "Query failed: db error". The
  constructors' bound is tightened from impl Display+Debug to
  impl std::error::Error so the source chain can be walked; the
  user-facing message stays sanitized (existing tests still pass).
- db/pool.rs: ensure_database formatted tokio_postgres::Error directly
  into the String returned to main.rs' exit(1) path, collapsing to
  "failed to query pg_database: db error" etc.

Adds a shared crate::db::format_with_sources helper (walks source()
with de-dup of placeholder layers) and reuses it from MigrateError's
Display, the AppError constructors, and pool.rs.
This commit is contained in:
xfy 2026-07-04 21:54:54 +08:00
parent e9f0b032b7
commit 383e6f6b43
4 changed files with 86 additions and 42 deletions

View File

@ -30,21 +30,34 @@ pub enum AppError {
impl AppError {
/// 记录并包装数据库连接错误。
///
/// 日志仅记录 Display 摘要,避免 Debug 输出中的 SQL/参数泄露。
pub fn db_conn(e: impl std::fmt::Display + std::fmt::Debug) -> Self {
tracing::error!("DB connection failed: {e}");
/// 对外只暴露通用提示(脱敏),但服务端日志会展开 source 链,记录完整的
/// 失败原因(如 `db error: connection refused`、IO 错误等)。传入的错误类型
/// 必须实现 `std::error::Error` —— DB 相关错误(`tokio_postgres::Error`、
/// `deadpool_postgres::PoolError`)均满足,这一点是上次"日志只显示 `db error`"
/// 问题的根治前提。
pub fn db_conn(e: impl std::error::Error) -> Self {
tracing::error!(
"DB connection failed: {}",
crate::db::format_with_sources(&e)
);
AppError::DbConn("connection error".to_string())
}
/// 记录并包装 SQL 查询错误。
pub fn query(e: impl std::fmt::Display + std::fmt::Debug) -> Self {
tracing::error!("Query failed: {e}");
pub fn query(e: impl std::error::Error) -> Self {
tracing::error!(
"Query failed: {}",
crate::db::format_with_sources(&e)
);
AppError::Query("query error".to_string())
}
/// 记录并包装数据库事务错误。
pub fn tx(e: impl std::fmt::Display + std::fmt::Debug) -> Self {
tracing::error!("Transaction failed: {e}");
pub fn tx(e: impl std::error::Error) -> Self {
tracing::error!(
"Transaction failed: {}",
crate::db::format_with_sources(&e)
);
AppError::Transaction("transaction error".to_string())
}
}
@ -80,7 +93,10 @@ mod tests {
#[test]
fn db_conn_hides_internal_details() {
let err: ServerFnError = AppError::db_conn("connection refused on port 5432").into();
// 入参是实现 Error 的类型;构造函数内部会把 source 链写进日志,
// 但对外ServerFnError必须只暴露通用提示。
let src = std::io::Error::other("connection refused on port 5432");
let err: ServerFnError = AppError::db_conn(src).into();
let msg = err.to_string();
assert!(
!msg.contains("5432"),
@ -94,7 +110,8 @@ mod tests {
#[test]
fn query_hides_sql_details() {
let err: ServerFnError = AppError::query("syntax error at SELECT * FROM").into();
let src = std::io::Error::other("syntax error at SELECT * FROM");
let err: ServerFnError = AppError::query(src).into();
let msg = err.to_string();
assert!(!msg.contains("SELECT"), "should not leak SQL: {msg}");
}
@ -124,7 +141,8 @@ mod tests {
#[test]
fn transaction_hides_sql_details() {
// 事务错误同样返回通用提示,不泄露 SQL 细节。
let err: ServerFnError = AppError::tx("deadlock detected on UPDATE posts").into();
let src = std::io::Error::other("deadlock detected on UPDATE posts");
let err: ServerFnError = AppError::tx(src).into();
let msg = err.to_string();
assert!(!msg.contains("UPDATE"), "should not leak SQL: {msg}");
assert!(

View File

@ -91,18 +91,22 @@ impl From<tokio_postgres::Error> for MigrateError {
impl std::fmt::Display for MigrateError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
// 主动展开 source 链tokio_postgres::Error 的 Display 对 DB 侧错误只打印
// 无信息量的 `db error`,真正的 message/SQLSTATE/约束名藏在 source() 里。
match self {
MigrateError::Pool(e) => {
write!(f, "database pool error: {}", e)?;
fmt_source_chain(f, e)
write!(f, "database pool error: {}", crate::db::format_with_sources(e))
}
MigrateError::Query(e) => {
write!(f, "database query error: {}", e)?;
fmt_source_chain(f, e)
write!(f, "database query error: {}", crate::db::format_with_sources(e))
}
MigrateError::Apply { version, source } => {
write!(f, "migration {} failed: {}", version, source)?;
fmt_source_chain(f, source)
write!(
f,
"migration {} failed: {}",
version,
crate::db::format_with_sources(source)
)
}
}
}
@ -118,26 +122,6 @@ impl std::error::Error for MigrateError {
}
}
/// 把错误的 `source()` 链逐层拼到 Display 输出里,用 `: ` 分隔。
///
/// 存在的原因:`tokio_postgres::Error` 的 `Display` 对 DB 侧错误只会打印
/// 无信息量的 `db error`,真正的消息文本(如
/// `column "x" of relation "y" already exists`、SQLSTATE、约束名藏在
/// `source()` 链里的 `postgres::error::DbError`。不主动遍历链就会丢失全部上下文,
/// 日志只显示 `migration 001 failed: db error`,难以定位。
fn fmt_source_chain(
f: &mut std::fmt::Formatter<'_>,
mut source: &(dyn std::error::Error + 'static),
) -> std::fmt::Result {
while let Some(next) = source.source() {
// `DbError` 的 Display 已经包含 message必要时还有 detail/hint
// 直接拼上即可;避免逐层重复打印 `db error` 这类占位串。
write!(f, ": {next}")?;
source = next;
}
Ok(())
}
/// 在**已获取**的连接上执行迁移主体逻辑(咨询锁 + 建表 + 应用迁移 + 解锁)。
///
/// 调用方负责自行控制连接获取策略——例如 `main.rs` 启动时用

View File

@ -8,6 +8,33 @@
//! 这种 stub 模式是 Dioxus fullstack 项目的常见做法:服务端函数体在 WASM 构建时会被剥离,
//! 但模块结构必须保持一致,因此需要一个占位实现来满足编译器的符号解析。
/// 错误格式化工具:把 `std::error::Error` 的 source 链完整展开为字符串。
///
/// 存在的原因:`tokio_postgres::Error` 的 `Display` 对 DB 侧错误只会打印
/// 无信息量的占位串 `db error`,真正的消息文本(如
/// `column "x" of relation "y" already exists`、SQLSTATE、约束名藏在
/// `source()` 链里的 `postgres::error::DbError`。不主动遍历链,日志和错误
/// 字符串就会全部折叠成 `db error`,无法定位失败原因。
///
/// 用法:`format!("...: {}", format_with_sources(&e))` 或直接
/// `format_with_sources(&e)` 得到完整的 `e: cause: deeper cause`。
#[cfg(feature = "server")]
pub fn format_with_sources(e: &dyn std::error::Error) -> String {
use std::fmt::Write;
let mut s = e.to_string();
let mut cur: &dyn std::error::Error = e;
while let Some(next) = cur.source() {
// 跳过与外层 Display 完全相同的占位层(如 tokio_postgres 的 `db error`
// 避免输出 `db error: db error` 这种重复。只在能带来新信息时追加。
let next_disp = next.to_string();
if !next_disp.is_empty() && next_disp != s {
let _ = write!(s, ": {next_disp}");
}
cur = next;
}
s
}
/// 真实的 PostgreSQL 连接池实现,仅在启用 server feature 时编译。
#[cfg(feature = "server")]
pub mod pool;

View File

@ -256,12 +256,14 @@ pub async fn ensure_database() -> Result<(), String> {
let remaining = deadline.saturating_duration_since(tokio::time::Instant::now());
if remaining.is_zero() {
return Err(format!(
"could not connect to 'postgres' maintenance database within {timeout_secs}s: {e}"
"could not connect to 'postgres' maintenance database within {timeout_secs}s: {}",
crate::db::format_with_sources(&e)
));
}
tracing::warn!(
"ensure_database: connect to 'postgres' failed, ~{}s remaining: {e}",
remaining.as_secs()
"ensure_database: connect to 'postgres' failed, ~{}s remaining: {}",
remaining.as_secs(),
crate::db::format_with_sources(&e)
);
tokio::time::sleep(std::cmp::min(retry_interval, remaining)).await;
}
@ -270,7 +272,10 @@ pub async fn ensure_database() -> Result<(), String> {
// 连接的后台驱动任务:出错时仅记录,连接随即作废。
tokio::spawn(async move {
if let Err(e) = connection.await {
tracing::warn!("postgres maintenance connection ended: {e}");
tracing::warn!(
"postgres maintenance connection ended: {}",
crate::db::format_with_sources(&e)
);
}
});
@ -281,7 +286,12 @@ pub async fn ensure_database() -> Result<(), String> {
&[&db_name],
)
.await
.map_err(|e| format!("failed to query pg_database: {e}"))?
.map_err(|e| {
format!(
"failed to query pg_database: {}",
crate::db::format_with_sources(&e)
)
})?
.get(0);
if exists {
@ -295,7 +305,12 @@ pub async fn ensure_database() -> Result<(), String> {
client
.batch_execute(&stmt)
.await
.map_err(|e| format!("failed to create database {db_name:?}: {e}"))?;
.map_err(|e| {
format!(
"failed to create database {db_name:?}: {}",
crate::db::format_with_sources(&e)
)
})?;
tracing::info!("created database {:?}", db_name);
Ok(())
}