response too large
主线位置:缓存主线第四步,未命中时一边把上游响应转发给下游、一边写进存储。
cache_http_task每写一块 body 就累加一次大小,超过上限时整个请求出错。最大文件大小和预测器的机制见存储与淘汰。
flowchart TB
head["上游响应头"] --> cl{"有 Content-Length"}
cl -->|"有 且超限"| off["disable 照常转发"]
cl -->|"没有 chunked"| body["逐块写缓存"]
body --> sum{"累计超限"}
sum -->|否| body
sum -->|是| err["response too large"]
err --> cut["下游连接中断"]
实验 crate exp-pingora/pitb-lab,bin p23_cache,脚本 tools/p23.sh 加客户端 tools/c25.sh,上游 tools/origin.py(debug);M2 Max、macOS 27、rustc 1.99.0,pingora 0.9.0,tokio 钉在 1.53.2(原因见怎么读这一卷)。缓存的写法是 打开了缓存却什么都没缓存 的修法(RCF=1),另加 MAXFILE=3000:request_cache_filter 里 session.cache.set_max_file_size_bytes(3000)。上游 /sized?n=5000 回 5000 字节、带 Content-Length;/big?n=5000 回 5000 字节的分块响应,每块 1000 字节;两者都是 max-age=60。
现象
/sized 两次,/big 三次:
/sized?n=5000 -> 200 x-cache=disabled size=5000 exit=0
/sized?n=5000 -> 200 x-cache=disabled size=5000 exit=0
/big?n=5000 -> 200 x-cache=miss size=3000 exit=18
/big?n=5000 -> 200 x-cache=miss size=3000 exit=18
/big?n=5000 -> 200 x-cache=miss size=0 exit=18
logging GET /sized?n=5000 phase=disabled reason=ResponseTooLarge status=Some(200) err=None
logging GET /sized?n=5000 phase=disabled reason=ResponseTooLarge status=Some(200) err=None
[2026-10-29T06:47:13Z ERROR pingora_proxy] Fail to proxy: response too large context: Peer: addr: 127.0.0.1:19381, scheme: HTTP cause: context: writing data of size 922 bytes would exceed max file size of 3000 bytes, status: 500, tries: 1, retry: false, GET /big?n=5000, Host: 127.0.0.1:19380
logging GET /big?n=5000 phase=disabled reason=StorageError status=Some(200) err=Some(" response too large context: Peer: addr: 127.0.0.1:19381, scheme: HTTP cause: context: writing data of size 922 bytes would exceed max file size of 3000 bytes")
...
- 带
Content-Length的/sized:不缓存(ResponseTooLarge),5000 字节完整送到。这是设计的行为。 - 分块的
/big:客户端每次都拿到 200 的响应头,body 被截断,curl 退出码 18(CURLE_PARTIAL_FILE)。截在哪里不固定:同一个程序跑了 4 轮共 12 次/big,5 次收到 3000 字节,7 次收到 0 字节。 - 日志写的是
status: 500,客户端实际收到的是 200 加半截 body;logging里的原因是StorageError,不是ResponseTooLarge。 - 每次都这样:第三次和第一次一样被截断,这个 URL 对所有客户端都是坏的,直到缓存配置改掉。
出处
cache_http_task 处理每一块 body:
Some(d) => {
if session.cache.enabled() {
// TODO: do this async
// fail if writing the body would exceed the max_file_size_bytes
let body_size_allowed =
session.cache.track_body_bytes_for_max_file_size(d.len());
if !body_size_allowed {
debug!("chunked response exceeded max cache size, remembering that it is uncacheable");
session
.cache
.response_became_uncacheable(NoCacheReason::ResponseTooLarge);
return Error::e_explain(
ERR_RESPONSE_TOO_LARGE,
format!(
"writing data of size {} bytes would exceed max file size of {} bytes",
d.len(),
session.cache.max_file_size_bytes().expect("max file size bytes must be set to exceed size")
),
);
}
响应头阶段只有 Content-Length 能提前判断大小,超过就 disable(ResponseTooLarge)、照常转发;分块响应只能边收边数,数到超过上限时返回一个 ERR_RESPONSE_TOO_LARGE 错误,它就是 ErrorType::Custom("response too large"),日志开头那个 response too large 由此而来。这个错误一路回到转发循环,请求失败;响应头早已写给下游,代理能做的只有断开连接。response_became_uncacheable 会告诉预测器「这个键太大」,可这里没有配预测器,这句话没人听,于是下一个请求还是同样的结局。writing data of size 922 bytes 里的块大小和上游的 1000 字节对不上,是代理那一侧读到的块,不是上游写的块。
cache_http_task 在响应头阶段的注释直说了这个行为:没有 Content-Length 时「the miss handler will count the response size on the fly, aborting the request mid-transfer if the max file size is exceeded」。tag 之后到 84f21ee,main 上这段没有改。
配了预测器时,第一次超限会让预测器记下这个键。0.6.0 之前的问题是记下之后就再也出不来:被预测为不可缓存的请求直接绕过缓存,而大小的累计只在 miss handler 里做,绕过时没人再数,就算上游后来把资产改小了也一样。889b69e(「Mark previously too large chunked assets as cacheable」,没有 PR 编号,首次进入 0.6.0,CHANGELOG 0.6.0 有这一条)的说明是:「Too large assets without content-length could previously be skipped as uncacheable indefinitely by the cacheable predictor.」它把累计挪出 miss handler(MaxFileSizeMissHandler 换成 MaxFileSizeTracker),缓存被关掉时也继续数,并新加了 NoCacheReason::PredictedResponseTooLarge:这次不写缓存,但完整转发;数完发现没超限,就把这个键重新标成可缓存。下面修法一里的 PredictedResponseTooLarge 就是它。
修法
一、配上预测器(PREDICTOR=1,session.cache.enable 的第三个参数传一个 Predictor):
static PREDICTOR: Lazy<Predictor<16>> = Lazy::new(|| Predictor::new(64, None));
// in request_cache_filter:
session.cache.enable(&*STORAGE, None, Some(&*PREDICTOR), None, None);
session.cache.set_max_file_size_bytes(3000);
/sized?n=5000 -> 200 x-cache=disabled size=5000 exit=0
/sized?n=5000 -> 200 x-cache=disabled size=5000 exit=0
/big?n=5000 -> 200 x-cache=miss size=3000 exit=18
/big?n=5000 -> 200 x-cache=disabled size=5000 exit=0
/big?n=5000 -> 200 x-cache=disabled size=5000 exit=0
...
logging GET /big?n=5000 phase=disabled reason=StorageError status=Some(200) err=Some(" response too large context: Peer: addr: 127.0.0.1:19381, scheme: HTTP cause: context: writing data of size 922 bytes would exceed max file size of 3000 bytes")
logging GET /big?n=5000 phase=disabled reason=PredictedResponseTooLarge status=Some(200) err=None
logging GET /big?n=5000 phase=disabled reason=PredictedResponseTooLarge status=Some(200) err=None
之后的请求完整送达、不写缓存。但第一个请求照样被截断,每个超限的键都要牺牲一次;预测器是一个有容量的分片 LRU(Predictor::new 的第一个参数是每个分片记几个键,这里 16 个分片各 64 个),键被挤出去以后又要牺牲一次。
二、没有 Content-Length 的响应不缓存(NEEDLEN=1):
fn response_cache_filter(&self, session: &Session, resp: &ResponseHeader, _: &mut ()) -> Result<RespCacheable> {
if session.cache.max_file_size_bytes().is_some() && !resp.headers.contains_key("content-length") {
return Ok(RespCacheable::Uncacheable(NoCacheReason::Custom("no content-length")));
}
// ... resp_cacheable as before
}
/sized?n=5000 -> 200 x-cache=disabled size=5000 exit=0
/sized?n=5000 -> 200 x-cache=disabled size=5000 exit=0
/big?n=5000 -> 200 x-cache=disabled size=5000 exit=0
/big?n=5000 -> 200 x-cache=disabled size=5000 exit=0
/big?n=5000 -> 200 x-cache=disabled size=5000 exit=0
连跑 3 次,每次都是这 5 行,logging 里的原因是 no content-length。响应头阶段就决定不缓存,body 不经过大小累计,不会中途出错。代价是小的分块响应也不缓存了;上游能给 Content-Length 的资产不受影响。两种修法可以一起用。
怎么确认没中招
- 日志搜
would exceed max file size:每一条都是一个被截断的下游响应,日志里的status: 500不是客户端看到的状态码。 - 客户端或 CDN 报
CURLE_PARTIAL_FILE/ERR_INCOMPLETE_CHUNKED_ENCODING,而代理的访问日志是 200。 - 代码里有
set_max_file_size_bytes,同一处enable的预测器参数是None,也没有对分块响应单独处理。
小结
- 最大文件大小对带
Content-Length的响应是「不缓存、照常转发」,对分块响应是「数到超限就让整个请求失败」:客户端拿到 200 加截断的 body,日志写 500、原因StorageError。 - 不配预测器时,同一个 URL 每次都被截断。
- 修法:配预测器,只牺牲第一次(0.6.0 起超限的键还能重新变成可缓存,
889b69e);或者在response_cache_filter里对没有Content-Length的响应直接不缓存。