请求、响应与错误
主线位置:请求主线的两端。handler 从
Cx读请求、从Body提取请求体;返回的Result穿过 layer 链回到handle_inner,在那里的respond里变成真正的 HTTP 响应。
flowchart TB
h["handler 返回 Result"] --> ok{"Ok 还是 Err"}
ok -->|"Ok(T)"| ir["T::into_response(cx)"]
ok -->|"Err(Error)"| layers["沿 layer 链冒泡:layer 能看到、能改"]
layers --> rw{"是 RewriteError 吗"}
rw -->|是| again["换路径,新 Cx,重新 dispatch"]
rw -->|否| resp["respond:按顺序 downcast"]
resp --> known["已知的路由错误:403、400、413、404、405、3xx、401、503、429"]
resp --> unknown["其他一切:500,原因丢掉"]
ir --> fail{"转换失败?"}
fail -->|是| resp
fail -->|否| out["Response"]
known --> out
unknown --> out
读请求:都在 Cx 上
topcoat 的 handler 没有 axum 那样的「参数即提取器」。请求的元数据从 Cx 上用函数取,request.rs 里一组:parts、method、uri、version、headers、content_type、extensions、remote_addr、client_ip。它们都是 fn(cx: &Cx) -> &T,不消耗任何东西,可以在任意组件、任意深度里调用,这也是「函数,而不是中间件」那条建议(03 章)的基础。
rewrite 之后这些函数返回的是改写后的请求;original_parts、original_uri、original_method 等返回到达时的那一份(从连接到分发里,OriginalParts 在 rewrite 循环外只算一次)。
请求体只能读一次,所以一个 handler 最多一个请求体参数,类型实现 FromRequest:
pub trait FromRequest: Sized {
fn from_request(cx: &Cx, body: Body) -> impl Future<Output = Result<Self>> + Send;
}
参数可以用模式,比如 Json(input): Json<CreateUser>;#[page] 的宏会把它改写成先取 body 再在函数开头 let 解构。内置的实现:
| 类型 | 做什么 | 失败时 |
|---|---|---|
Body | 原样交出,自己读流 | 不检查大小限制 |
Bytes、BytesMut | 读完整个请求体 | 超过 body_limit 是 413,读失败是 400 |
String | 读成 Bytes 再按 UTF-8 解码 | 不是 UTF-8 是 400 |
Json<T> | 先查 Content-Type: application/json,再用 serde_path_to_error 反序列化 | 400,消息里带出错的字段路径 |
Form<T>、RawForm | application/x-www-form-urlencoded | 400 |
Html<String>、Css<String> | 读成字符串 | 400 |
Multipart | multipart feature,底下是 multer | 400 |
WebSocketUpgrade | websocket feature,检查握手头 | 方法不是 GET 是 405;不是 HTTP/1.1 是 400 |
Option<T> | 走 OptionalFromRequest:比如 Option<Json<T>> 在没有 Content-Type 时是 None | 同 T |
Json、Form、Html 这些类型同时也实现了 IntoResponse:同一个包装类型既能提取也能响应。
Json<T> 的三种 400(json.rs),实测:
### curl -X POST -d {"name":"a","age":3} http://127.0.0.1:38111/users
HTTP/1.1 400 Bad Request
content-type: text/plain; charset=utf-8
vary: accept-encoding
content-length: 67
bad request: expected request with `Content-Type: application/json`
### curl -X POST -H Content-Type: application/json -d {"name":"a","age":300} http://127.0.0.1:38111/users
HTTP/1.1 400 Bad Request
content-type: text/plain; charset=utf-8
vary: accept-encoding
content-length: 105
bad request: invalid JSON value: invalid value: integer `300`, expected u8 at line 1 column 21 (at `age`)
### curl -X POST -H Content-Type: application/json -d {"name":"a" http://127.0.0.1:38111/users
HTTP/1.1 400 Bad Request
content-type: text/plain; charset=utf-8
vary: accept-encoding
content-length: 90
bad request: invalid JSON syntax: EOF while parsing an object at line 1 column 11 (at `?`)
400 的描述原样发给客户端,错误模块的文档要求「use a client-safe message」;serde 的报错会把结构体的字段名带出去。
写响应:IntoResponse 带着 Cx,可以失败
pub trait IntoResponse {
fn into_response(self, cx: &Cx) -> Result<Response>;
}
和 axum 的 IntoResponse 比有两处不同:多了 cx,转换时能读请求上下文(比如视图要知道该发 HTML 还是 NDJSON);返回 Result,转换本身可以失败(比如一个非法的头值),失败的错误和 handler 的错误走同一条路。
还有一个异步版本 AsyncIntoResponse,所有 IntoResponse 都自动实现它。它是给视图用的:BoxView 要 poll 到首屏才能组出响应,见一个请求从监听到 HTML。#[route] 和 #[layer] 的返回值要求的就是 AsyncIntoResponse。
内置的实现覆盖常见类型:()、StatusCode、字符串、字节、Body、HeaderMap、http::Response<B>,以及元组 (StatusCode, 头..., 体)、([(K, V); N], 体)。content/ 下是带内容类型的包装:
| 类型 | feature | 说明 |
|---|---|---|
Html、Css、Js、Json、Form、Wasm | — | 设好 Content-Type |
Sitemap | sitemap | 生成 sitemap XML |
Sse<S> | sse | 事件流;KeepAlive 默认每 15 秒空闲发一个空注释 |
WebSocketUpgrade::on_upgrade | websocket | 返回 101,升级后的连接交给回调;只支持 HTTP/1.1 的 Upgrade |
| 视图 | — | BoxView 走 AsyncIntoResponse,HTML 或 NDJSON 帧,可能流式 |
错误即控制流
handler 的错误类型是 topcoat::Error(topcoat-core 里一个装箱的、可 downcast 的错误,03 章讲)。router 在 error/ 下提供一组构造函数,每个返回一个具体的错误类型,? 或 .into() 就变成 Error:
| 构造函数 | 响应 |
|---|---|
not_found() | 404,体 not found |
bad_request(desc) | 400,体 bad request: {desc} |
unauthorized()、forbidden() | 401、403 |
content_too_large() | 413 |
method_not_allowed(methods) | 405,带 Allow 头 |
redirect(uri)、redirect_permanent(uri) | 307、308,带 Location |
see_other(uri) | 303,带 Location;它也能当 Ok 值返回 |
too_many_requests()、service_unavailable() | 429、503 |
rewrite(path, body) | 不是响应,在 rewrite 循环里被拦下 |
internal_server_error(e) | 500 |
Option 和 Result 上有 RouterErrorExt 的 ok_or_not_found、ok_or_redirect、ok_or_unauthorized、ok_or_forbidden、ok_or_bad_request,把 None / Err 换成对应的路由错误:
#[route(GET "./users/find")]
async fn find() -> Result<&'static str> {
let found: Option<&'static str> = None;
Ok(found.ok_or_not_found()?)
}
#[route(POST "./login")]
async fn login() -> Result<SeeOther> {
Ok(see_other("/dashboard"))
}
### curl http://127.0.0.1:38111/users/find
HTTP/1.1 404 Not Found
content-type: text/plain; charset=utf-8
content-length: 9
not found
### curl -X POST http://127.0.0.1:38111/login
HTTP/1.1 303 See Other
location: /dashboard
content-length: 0
「错误」在这里不只表示失败:重定向、rewrite、「这个用户没登录」都是用 Err 从调用栈深处直接跳出来的。好处是任何一层函数、任何一个嵌套组件里都能 ? 一个重定向,不用把返回类型一层层改成「响应或数据」。
respond:按顺序 downcast
错误一路冒泡到 handle_inner,由 respond 变成响应,核心是 error_into_response:
fn error_into_response(cx: &Cx, error: Error) -> Response {
macro_rules! try_downcast {
($ident:ident as $ty:ty) => {
match $ident.downcast_cloned::<$ty>() {
Ok(error) => return into_response_or_500(cx, error),
Err(error) => error,
}
};
}
let error = try_downcast!(error as ForbiddenError);
let error = try_downcast!(error as BadRequestError);
// ...
let error = try_downcast!(error as TooManyRequestsError);
into_response_or_500(cx, internal_server_error(error))
}
十一种已知的路由错误依次试一遍,认出一个就用它自己的 IntoResponse;都不是就包成 InternalServerError。而 InternalServerError 只有一个下划线开头的字段:
pub struct InternalServerError {
_inner: Error,
}
Display 固定输出「internal server error」,IntoResponse 固定是 (500, "internal server error")。原来的错误被留在结构体里,但没有任何代码读它,也没有任何日志:从连接到分发里说过,router 和 core 里没有一行 tracing。数据库连不上、rewrite 成环、TowerLayer 里的超时,客户端和服务端日志看到的都只是一个 500。
要看到原因,只能在错误变成响应之前拦下来。最合适的位置是一个没有路径的 layer:它包住所有请求,next.run 返回的 Err 还是原来的错误。前两页实验里的 Pathless 就是这么打印出 Err(not found)、Err(rewrite to /loop-b) 的。
只在错误响应上加头:ResponseHeaders
layer 想给响应加一个头(比如请求 id),直接改 next.run 返回的 Response 不够:返回 Err 时还没有响应。response_headers(cx) 是每个请求一个的头收集器,handle_inner 在 respond 之后把它里面的头追加到最终响应上,无论那是 handler 的响应还是 404、500。文档里的例子就是一个没有路径的 layer 往里放 x-request-id,「Lands on the 404 for an unmatched URL as well as on a handler's own response」。
流式开始之后的重定向
视图响应的状态行在首屏发出时就定了。如果 suspense 里的组件在首屏之后才返回一个 redirect,已经没法改成 307。ViewBody::poll_frame 的做法是把它降级成一段客户端跳转:
Poll::Ready(Err(error)) => {
*this.done = true;
// The response committed with the first content, so a
// redirect can no longer change the status line; it degrades
// to a client-side navigation instead.
match redirect_location(error) {
Ok(location) => {
let chunk = this.delivery.redirect(&location);
Poll::Ready(Some(Ok(Frame::data(chunk.into()))))
}
Err(error) => Poll::Ready(Some(Err(error.into()))),
}
}
实验:一个页面在首屏之前就 redirect("/login"),另一个页面的首屏正常,suspense 里的组件 200 毫秒后 redirect("/login?next=\"x\""):
#[page("./before")]
async fn before() -> Result<impl View> {
Err::<(), _>(redirect("/login"))?;
Ok(view! { <p>"never"</p> })
}
#[component]
async fn guard() -> Result<impl View> {
tokio::time::sleep(Duration::from_millis(200)).await;
Err::<(), _>(redirect("/login?next=\"x\""))?;
Ok(view! { <p>"secret"</p> })
}
[ 5ms] read 128 bytes:
HTTP/1.1 307 Temporary Redirect
location: /login
connection: close
content-length: 0
[ 0ms] read 389 bytes:
HTTP/1.1 200 OK
content-type: text/html; charset=utf-8
...
<!DOCTYPE html><html><body><p>first screen</p><!--::topcoat::region::start(d139fe525a3cb181f54fb3f0ffe4eda4)--><p>checking</p><!--::topcoat::region::end(d139fe525a3cb181f54fb3f0ffe4eda4)--></body></html>
[ 203ms] read 72 bytes:
3D
<script>window.location.replace("/login?next=\"x\"")</script>
0
- 首屏之前:普通的 307。
- 首屏之后:状态已经是 200,追加一段
window.location.replace(...)就结束响应。用replace而不是给location.href赋值,注释说是为了不把这个半截页面留在历史记录里。地址里的"、\被转义,<被写成\x3C,防止提前闭合<script>。 - NDJSON 格式下同一个情况是一帧
{"t":"redirect","location":"/login?next=\"x\""}。
这对鉴权有直接影响:如果「检查登录」写在 suspense 里的某个组件中,未登录用户先拿到的是一个 200 和首屏内容,然后才被脚本带走;首屏里如果已经有不该给他看的东西,它已经发出去了,关掉 JavaScript 的客户端也不会跳转。鉴权要放在首屏之前:page 本身、layout,或者 layer。
不是重定向的错误在流式阶段发生(比如 suspense 里的组件 200 毫秒后返回 not_found()),poll_frame 返回 Err,hyper 中止这个响应。实测 HTTP/1 下客户端先收到 200 和首屏,207 毫秒时连接被关闭,没有结尾的 0 长度块;curl 以退出码 18(传输不完整)结束。状态码是 200,404 的意思没有传到客户端。想在流式阶段把错误变成页面上的内容,要在视图里用 error_boundary 接住(04 章)。
[ 5ms] read 389 bytes:
HTTP/1.1 200 OK
...
transfer-encoding: chunked
CB
<!DOCTYPE html><html><body><p>first screen</p><!--::topcoat::region::start(563fb7caab8260bea62e209677945068)--><p>checking</p><!--::topcoat::region::end(563fb7caab8260bea62e209677945068)--></body></html>
[ 207ms] <EOF>
小结
- 请求的元数据用
parts(cx)、headers(cx)等函数从Cx上取,rewrite 后另有original_*;请求体用FromRequest提取,一个 handler 最多一个,可以写模式解构。 Json<T>先查Content-Type,再反序列化,三种失败都是 400,描述(含字段路径)原样发给客户端。IntoResponse带cx、返回Result;视图走AsyncIntoResponse;content/下的包装类型既提取又响应。not_found、redirect、see_other、rewrite等都是Error,靠?从任意深度跳出;respond依次 downcast 十一种已知错误,其余一律 500,原因留在_inner里没人读、也不打日志。要看原因,在没有路径的 layer 里看next.run的Err。response_headers(cx)上的头会加到最终响应上,包括错误响应。- 首屏之后的重定向变成
window.location.replace脚本(NDJSON 下是一帧redirect),其他错误则让响应中途断开、状态仍是 200;鉴权和「找不到」的判断都要放在首屏之前。