服务端错误原文送进了浏览器
主线位置:调用主线的最后一段,以及首屏主线的「定状态码」一步。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}" 一显示,用户就在页面上看到了。
出处
ServerFnError::new把参数to_string()进ServerError.message。第 665 行的MakeAxumError写两份:message是错误的Display(thiserror 的#[error("error running server function: {message} (details: {details:#?})")]),data是整个错误的 JSON。dioxus::Result/ anyhow 走 第 754 行:downcast 不出ServerFnError/HttpError时,message: err.to_string()。?自动把任何std::error::Error转进来,所以不写to_string也一样。- panic:
make_handler用spawn_pinned跑函数,panic 变成JoinError;match result在cfg!(debug_assertions)时回format!("Server function panicked: {}", err),否则回Internal Server Error。 - SSR:
render_to渲染完 查根错误边界,错误是ServerFnError::ServerError时取message作out_message,render_handler的Err(SSRError::HttpError { .. })分支 把它作为整个响应的正文(没有content-type)。HTTP 调用和 SSR 是同一个函数体的两种调用方式(server function),两处都要防。
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。 - 修法:完整错误写日志,客户端拿固定消息;要给用户看的错误用类型表达。