Rust in Everything
基于 dioxus 0.7.10

服务端错误原文送进了浏览器

主线位置:调用主线的最后一段,以及首屏主线的「定状态码」一步。HTTP 调用时,错误经 MakeAxumError 编成 ErrorPayload;服务端渲染时,同一个错误冒到根错误边界,render_to 把它的消息当成响应正文。两条路都不过滤消息。

flowchart TB
  dep["依赖返回的错误"] --> conv["e.to_string 或 ?"]
  conv --> sfe["ServerFnError 或 CapturedError"]
  sfe --> http["HTTP 调用 ErrorPayload"]
  sfe --> ssr["SSR 根错误边界"]
  http --> json["JSON 正文 message"]
  ssr --> body["500 页面正文"]

实验应用 pit2-fs(dioxus = "=0.7.10",dx 0.7.9 构建,PORT=18820),M2 Max、macOS 27、rustc 1.99.0。debug 和 release 都跑了,不同的地方分开写。

现象

server function 调一个内部服务。为了不碰网络,地址是本机一个没人监听的端口,URL 里带着内部路径和一个令牌:

#[cfg(feature = "server")]
async fn call_billing() -> Result<String, dioxus::fullstack::reqwest::Error> {
  // an internal service the browser should never learn about (nothing listens on port 9)
  let client = dioxus::fullstack::reqwest::Client::builder().no_proxy().build()?;
  client
    .get("http://127.0.0.1:9/internal/billing?token=sk_live_51Hx")
    .send()
    .await?
    .text()
    .await
}

#[get("/api/report")]
pub async fn report() -> Result<String, ServerFnError> {
  call_billing().await.map_err(|e| ServerFnError::new(e.to_string()))
}

#[get("/api/report-anyhow")]
pub async fn report_anyhow() -> Result<String> {
  Ok(call_billing().await?)
}

#[get("/api/boom")]
pub async fn boom() -> Result<String, ServerFnError> {
  let cfg: Option<&str> = None;
  Ok(cfg.expect("BILLING_TOKEN not set in /srv/app/.env").to_string())
}

release 构建:

### curl /api/report
HTTP/1.1 500 Internal Server Error
content-type: application/json

{"message":"error running server function: error sending request for url (http://127.0.0.1:9/internal/billing?token=sk_live_51Hx) (details: None)","code":500,"data":{"ServerError":{"message":"error sending request for url (http://127.0.0.1:9/internal/billing?token=sk_live_51Hx)","code":500}}}

### curl /api/report-anyhow
HTTP/1.1 500 Internal Server Error
content-type: application/json

{"message":"error sending request for url (http://127.0.0.1:9/internal/billing?token=sk_live_51Hx)","code":500}

### curl /api/boom
HTTP/1.1 500 Internal Server Error

Internal Server Error

同一个 /api/boom 在 debug 构建里:

### curl /api/boom
HTTP/1.1 500 Internal Server Error

Server function panicked: task 68 panicked with message "BILLING_TOKEN not set in /srv/app/.env"

页面里用同一个函数做首屏数据,错误冒上去:

#[component]
fn Leak() -> Element {
  let r = use_server_future(report)?;
  let text = r().unwrap()?;
  rsx! { p { "{text}" } }
}

curl -i /leak(release,debug 相同):

HTTP/1.1 500 Internal Server Error
content-length: 86
date: Wed, 28 Oct 2026 12:29:51 GMT

error sending request for url (http://127.0.0.1:9/internal/billing?token=sk_live_51Hx)

三条路都把 reqwest 错误的 Display 原样交给了客户端:内部服务的地址、路径、查询串里的令牌。把 reqwest 换成数据库驱动,送出去的就是约束名、列名、duplicate key value violates unique constraint ... 这类文字(sea-orm 的 DbErr 各分支带什么,见 sea-orm 卷的错误)。客户端解出来的 ServerFnError 里也是这段文字,组件里 "{err}" 一显示,用户就在页面上看到了。

出处

dioxus 没有「对外消息」和「日志消息」的区分:ServerFnError 只有一个 message 字段,它就是给客户端的。

修法

在边界上换掉消息:完整错误进服务端日志,客户端只拿一句固定的话。

#[get("/api/report-fixed")]
pub async fn report_fixed() -> Result<String, ServerFnError> {
  call_billing().await.map_err(|e| {
    error!(error = %e, "billing call failed");
    ServerFnError::new("服务暂时不可用")
  })
}
### curl /api/report-fixed
HTTP/1.1 500 Internal Server Error
content-type: application/json

{"message":"error running server function: 服务暂时不可用 (details: None)","code":500,"data":{"ServerError":{"message":"服务暂时不可用","code":500}}}

服务端日志里是完整的:

2026-10-28T12:29:51.554743Z ERROR exp_dioxus_pit2_fs::api: billing call failed error=error sending request for url (http://127.0.0.1:9/internal/billing?token=sk_live_51Hx)

几处细节:

  • 页面渲染时组件出错,dioxus 自己会打一行 ERROR dioxus_core::scope_arena,带着当时的错误文字。上面 /leak 那次请求的日志:

    2026-10-28T12:29:51.566586Z ERROR dioxus_core::scope_arena: Error while rendering component `exp_dioxus_pit2_fs::Leak`: error running server function: error sending request for url (http://127.0.0.1:9/internal/billing?token=sk_live_51Hx) (details: None)
    

    这一行打的是错误本身,消息换掉之后它也只剩固定消息,完整原因要靠自己那句 error!。

  • 函数很多时,把「记日志 + 换消息」写成一个函数或一个扩展 trait(.internal()?),每处 .map_err(|e| ServerFnError::new(e.to_string())) 换成它。有意要给用户看的错误(「用户名已被占用」)用自定义错误类型的某个分支表达,状态码一起带上(server function 的错误都是 500)。

  • 返回 dioxus::Result 的函数不要直接 ? 外部错误,先 .map_err 成固定消息。

  • panic 正文只在 debug 构建里带消息;生产要用 release 构建。debug 构建的页面还会写出 initial_dioxus_hydration_debug_locations,里面是构建机上的绝对路径(本次实验:~/.cargo/registry/src/index.crates.io-1949cf8c6b5b557f/dioxus-server-0.7.10/src/ssr.rs:556:29),也是只在 debug 下有。

  • 不要把秘密放进 URL 的查询串:reqwest、hyper 的错误文字都带 URL。

怎么确认没中招

  • 搜 ServerFnError::new(e.to_string())、ServerFnError::new(format!(".. {e}"))、.map_err(|e| ServerFnError::new( 这一类写法,以及返回 Result<T> / anyhow::Result 且函数体里直接 ? 外部错误的 server function。
  • 测试里让依赖失败(关掉数据库、指向不存在的端口),对每个 server function 发请求,断言响应正文里没有 ://、constraint、/srv、os error 之类的片段;首屏用到它的页面也 curl 一次。
  • 部署的二进制是 release:curl 一个会 panic 的路径,正文应当只有 Internal Server Error;页面里不应出现 initial_dioxus_hydration_debug_locations。
  • 本站约 85 处 ServerFnError::new(e.to_string()) 的情况见本站的页面。

小结

  • ServerFnError 只有一个 message,它就是发给客户端的;ServerFnError::new(e.to_string()) 等于把内部错误原文公开。
  • dioxus::Result / anyhow 的 ? 一样:message 是 err.to_string()。
  • 服务端渲染时,同一段消息成了 500 页面的正文。
  • debug 构建把 panic 消息发给客户端,页面里还带构建机路径;release 只回 Internal Server Error。
  • 修法:完整错误写日志,客户端拿固定消息;要给用户看的错误用类型表达。

💬 关联讨论

发起讨论
加载中...