大型 PDF 上傳被拒:100 MiB 請求本體上限,與那個指向錯誤方向的訊息
單次請求本體上限是 100 MiB,也就是 104,857,600 位元組,含 multipart 框標;超過時操作端點回傳 400 與 bad_request,而不是 413,內容只說 multipart 欄位讀取失敗,帶 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、
邊界與欄位標頭;單次操作與 快取未命中且上游 2xx 時
pipeline 共用
超過: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 的話,先撞上的會是 nginx:client_max_body_size 預設只允許 1 MiB,超過就回 413,形狀和上面那個對照很接近,很容易被誤認成同一個限制。
撞上了該怎麼辦
從成本最低的開始:
- 送出前先量。 在請求送出前把請求本體的位元組數和 104,857,600 比一次,並留出邊界的餘裕。這比事後讀狀態碼可靠。
- 把檔案壓到上限以內。 掃描檔的體積大多來自影像層,重新壓縮通常能明顯減少一部分。壓縮 PDF 在瀏覽器裡就能跑,不需要寫指令碼。
- 把工作拆成多次呼叫。 內容本身可分割時,拆分後分幾次送出:比提高上限省事,也不會推高單次請求所佔的記憶體。
- 只有確有單次請求的硬需求,才去改上限。 那意味著改程式碼、重新建置,並接受上一節說的記憶體代價。
這個上限要求用戶端事先自行判斷
兩處失效指向同一個結論:100 MiB 這個數字必須由用戶端在送出前自己算出來。入站方向用 bad_request 回你,那個碼完全沒說體積;回傳方向用 500 回你,看起來像伺服器故障。在請求離開前把位元組數算清楚,是唯一不依賴錯誤訊息措辭的判斷方式。