大 PDF 上传被拒:100 MiB 请求体上限的边界,与那个会误导排查方向的报错
单次请求的请求体上限是 100 MiB:正好 104,857,600 字节能过,多 1 字节就被拒,且按整个请求体算,含 multipart 的边界字节。超限时操作端点返回 400 与 code 为 bad_request 的错误,正文说读 multipart 字段失败,不是 413。带 Idempotency-Key 时另有一个 100 MiB 的响应缓冲上限,超限返回 500 且不写缓存。

大 PDF 传不上去,多数时候不是文件坏了,而是请求体撞上了单次请求的上限:100 MiB,也就是 104,857,600 字节。它按整个请求体算,含 multipart 的边界与字段头部,正好这么多能过,多 1 字节就被拒。
撞上之后的报错指向的是另一个方向。操作端点返回 400,code 是 bad_request,正文说读 multipart 字段失败,没有一处提到体积。按 code 分支的客户端会把它归进参数错误,然后去核对表单字段名,而真正要改的是文件大小。
同一个 100 MiB 还管着反方向。带 Idempotency-Key 的请求在缓存响应前要把响应体读进内存,那里设的是同一个数字;超限时调用方拿到 500,而操作其实已经执行完了。两件事共用一个数字,失效方式相反。
100 MiB = 104,857,600 字节(正好这么多能过)
|
+------------------+------------------+
| |
入站(请求体) 回传(响应体)
按整个请求体算,含 multipart 边界与 仅在带 Idempotency-Key、
字段头部;单次操作与 pipeline 共用 缓存未命中、上游 2xx 时缓冲
超限:400 + bad_request 超限:500,且不写缓存
(正文说读 multipart 字段失败)
图注:同一个上限在入站与回传两侧,给出的是不同的状态码与相反的后果。
上限按整个请求体算,含边界字节
100 MiB 限制的是一次请求的请求体,不是单个文件的大小,也不是解压后的体积。
- 单次操作与多步 pipeline 共用这个数。把任务拆成 10 步塞进一次
/api/v1/pipeline,上限不会因此变成 1 GB;步数只影响耗时。 - 边界值本身可以通过:104,857,600 字节的请求体放行,104,857,601 字节不行。
- 请求体里除了文件字节,还有每个字段的头部与边界分隔符,所以单个文件能用的额度严格小于 100 MiB。一个正好 104,857,600 字节的文件会被拒。
最后一条是实操里最容易踩空的地方:curl -F 会替你补上边界,拿文件大小去对那条线必然对不上。
超限时收到的是 400 与 bad_request
超限的响应不走 413,也不走任何与体积有关的措辞。用比上限多 1 字节的请求体复现,你会拿到这个:
head -c 104857601 /dev/zero > /tmp/over.bin
curl -s -X POST "$API_BASE/api/v1/misc/compress-pdf" \
-H "X-API-KEY: $API_KEY" \
-F "fileInput=@/tmp/over.bin"
HTTP/1.1 400 Bad Request
content-type: application/problem+json
{ "code": "bad_request",
"detail": "failed to read multipart field: Error parsing `multipart/form-data` request",
"hint": "Fix request parameters or upload a valid PDF.",
"status": 400,
"title": "Bad Request",
"type": "https://pdf123.xyz/developers/errors#bad_request" }
三点要对着看:
- 状态码是 400。读取请求体失败在本站被归成
bad_request,照常走 problem+json,所以按“超限即 413”写分支的客户端会走错路。 code是bad_request,错误码表里真实存在的一项。它不会落进兜底分支,而是和字段名写错、multipart 编码不对共用同一个码;那张表里没有任何一项与体积有关。hint建议你上传有效的 PDF。你上传的很可能就是一份有效 PDF,只是大了几百字节。
判断依据因此不在状态码,而在请求体字节数:拿到 400 且 detail 里出现 failed to read multipart field,下一步该量体积,不必回头检查表单。
413 在本站也确实会出现,只是不出现在操作端点上。能收文件上传的入口都被提到 100 MiB,不走上传的接口仍按 HTTP 框架(Rust 的 axum)默认的 2 MiB 处理,超限时返回 413 和一行纯文本:
head -c 2097153 /dev/zero > /tmp/big.json
curl -s -w '\n%{http_code}\n' -X POST "$API_BASE/api/v1/auth/login" \
-H 'Content-Type: application/json' \
--data-binary @/tmp/big.json
Failed to buffer the request body: length limit exceeded
413
同一个事实,在两类端点上给出两种状态码、两种响应体。跨端点套用任何一条经验都会出错。
回传方向:同一个数字的另一种失效
带 Idempotency-Key 的请求会缓存响应,好在重试时直接回放。缓存要先把响应体读进内存,那里设了同样的 100 MiB,但触发条件窄得多,三件事同时成立才会走到:
- 请求带了
Idempotency-Key - 缓存未命中,这个键是第一次见
- 上游操作返回的是 2xx
失败响应不缓存,直接放行,所以只有成功的大输出去撞这个上限。
撞上之后的行为是本文最该记住的一点:返回 500,并且什么都不写进缓存。 操作已经执行完,调用方却拿到失败;因为没缓存,重试会把整件事再跑一遍。幂等键本来是用来消除重复执行的,恰好在最需要它的时候失效,还把一个已完成的工作包装成服务端故障。这份缓存在进程内存里,不落盘,进程重启即失效;语义细节见 Idempotency-Key:PDF 作业的安全重试。
100 MiB 不是可配置的平台参数
这个值不能改。没有环境变量能把它调大或调小,两个方向都不能;想换成别的数字,只能改代码、重新构建镜像。把它当部署参数去找,会找不到地方。
它也不只是一条配额。请求体的字节会被完整读进内存之后再处理,每个并发的大上传都让服务端多占一份与之相当的内存。把上限往上调,同时要接受峰值内存随之上移:这个数字某种程度上也在替服务端挡住单个请求。
默认的自托管部署里没有反向代理,门户在提交前也不做体积校验,所以那一次拒绝来自服务端自己的上限。如果你自己在前端加了 nginx,先撞上的会是它:client_max_body_size 默认只放行 1 MiB,超过就返回 413,和上面那条对照里的形状接近,很容易被当成同一个限制。
撞上了怎么办
按代价从低到高:
- 先量后传。 发送前把请求体字节数与 104,857,600 比一次,并留出边界余量。这比事后读状态码可靠。
- 把文件压到上限以内。 扫描件的体积大多来自图像层,重新压缩常常能砍掉可观的一块。压缩 PDF 在浏览器里就能跑完,不用写脚本。
- 拆成多次调用。 内容本身可分的,拆分后分几次传,比调大上限省事,也不会把单次请求的内存占用推高。
- 确有一次传完的硬需求,才考虑改上限。 那属于改代码重建,并接受上一节那份内存代价。
这个上限要求客户端自己预判
两处失效指向同一个结论:100 MiB 需要客户端在发送前自己算。入站方向用 bad_request 回复你,那个码与体积无关;回传方向用 500 回复你,看起来像服务端故障。把字节数算在请求发出之前,是唯一不依赖报错语义的判断方式。