大きな PDF のアップロードが拒否されるとき:100 MiB のリクエストボディ上限と、誤った方向を指すエラー
1 つのリクエストボディは multipart のフレーミングも含めて 100 MiB、つまり 104,857,600 バイトまでで、超えると操作エンドポイントは 413 ではなく 400 とコード bad_request を返して multipart フィールドの読み取り失敗とだけ説明するため、サイズの問題がパラメータの問題に見え、Idempotency-Key がある場合はレスポンスバッファにもう 1 つ 100 MiB の上限があり、超えると 500 が返って何もキャッシュされません。

大きな PDF がアップロードできないのは、たいていファイルが壊れているからではない。リクエストボディが 1 リクエストあたりの上限に達したのだ。100 MiB、つまり 104,857,600 バイトである。これは multipart の境界やフィールドヘッダーも含めたボディ全体で測られ、この正確な値は通り、1 バイトでも超えると拒否される。
返ってくるエラーは別の方向を指している。操作エンドポイントは 400 を返し、code は bad_request、詳細には multipart フィールドの読み取りに失敗したと書かれ、サイズにはどこも触れていない。code で分岐するクライアントはこれをパラメータエラーとして扱い、フィールド名を確認しに行く。変えるべきなのはファイルサイズなのに。
同じ 100 MiB は逆方向も支配している。Idempotency-Key を伴うリクエストは、応答をキャッシュする前にレスポンスボディをメモリへ読み込む。そこでも同じ値が使われ、超えると操作はすでに完了しているのに呼び出し元は 500 を受け取る。1 つの数字が、正反対の 2 つの失敗の仕方を生む。
100 MiB = 104,857,600 バイト(この正確な値は通る)
|
+------------------+------------------+
| |
受信(リクエストボディ) 送信(レスポンスボディ)
multipart の境界とフィールド Idempotency-Key があり、
ヘッダーを含むボディ全体で計測。 キャッシュミスで上流が 2xx
1 操作とパイプラインが共有 のときだけ
超過:400 + bad_request 超過:500、キャッシュなし
(詳細:multipart フィールドの読み取りに失敗)
図注:1 つの上限が、受信側と送信側で異なるステータスコードと正反対の結果を返す。
上限は境界も含めたリクエストボディ全体で数える
100 MiB が上限とするのは 1 リクエストのボディであり、1 つのファイルのサイズでも、展開後のサイズでもない。
- 1 回の操作も多段のパイプラインも同じ値を使う。1 回の
/api/v1/pipeline呼び出しの中で作業を 10 ステップに分けても、天井が 1 GB になるわけではない。ステップ数が変えるのは実行時間だけだ。 - 境界値そのものは通る。104,857,600 バイトのリクエストボディは通り、104,857,601 バイトは通らない。
- ボディにはファイルのバイトだけでなく、各フィールドのヘッダーと境界の区切り文字も含まれる。だから 1 つのファイルに使える余地は厳密に 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で、エラーコード表に実在する項目だ。すべてを受け止める catch-all に落ちるのではなく、フィールド名の打ち間違いや壊れた multipart エンコーディングと同じコードを共有している。そしてその表にサイズに関わる項目は一つもない。hintは有効な PDF をアップロードするよう促す。だがあなたが送ったのはおそらく有効な PDF で、たまたま数百バイトだけ大きいだけだ。
つまり手がかりはステータスコードではなくボディのバイト数だ。detail に failed to read multipart field を含む 400 を受け取ったら、次に測るべきはサイズであり、フォームをさかのぼる必要はない。
413 はこのサイトでも起こる。ただし操作エンドポイントでは起こらない。ファイルアップロードを受け付ける入口はすべて 100 MiB に引き上げられており、アップロードを取らないエンドポイントは HTTP フレームワーク(Rust の axum)の既定である 2 MiB のままで、上限を超えたボディには 413 と 1 行のプレーンテキストを返す:
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
同じ事実が、エンドポイントの種類によって 2 つのステータスコードと 2 つのレスポンスボディを生む。どちらかの経験をもう片方に持ち込むと、必ず誤解する。
同じ数字がもたらすもう一つの失敗、今度は戻り道で
Idempotency-Key を伴うリクエストは、再試行が再生できるように応答をキャッシュする。キャッシュするとは、まずレスポンスボディをメモリへ読み込むことで、その読み込みも同じ 100 MiB で頭打ちになる。ただし成立する条件はずっと狭い:
- リクエストに
Idempotency-Keyが付いていた - キャッシュがミスし、このキーは新しい
- 上流の操作が 2xx を返した
失敗した応答はキャッシュされずそのまま通過するので、この上限に届くのは成功した大きな出力だけだ。
そこで起きることが本当に覚えておくべき点だ。500 が返り、キャッシュには何も書かれない。 操作はすでに実行されているのに、呼び出し元には失敗が見える。何もキャッシュされていないので、再試行はすべてを最初からやり直す。冪等キーは重複作業をなくすために存在するのに、まさに最も必要とされる場面で機能せず、完了したジョブをサーバー障害の姿に着せてしまう。キャッシュはプロセスのメモリ上にあり、ディスクには決して書かれないので、再起動で消える。意味論については Idempotency-Key: PDF ジョブの安全な再試行 を参照。
100 MiB は設定で変えられるプラットフォーム値ではない
この値は変更できない。環境変数で上げることも下げることもできず、どちらの方向も無理だ。別の数値にするにはコードを変更してイメージを再ビルドするしかない。デプロイ設定の中に探しても、見つからない。
それは単なるクォータでもない。リクエストボディのバイトは処理の前に丸ごとメモリへ読み込まれるので、同時に走る大きなアップロードはそれぞれ同程度のメモリを抱え込む。天井を上げることは、それだけ高いメモリピークを受け入れることでもある。この数字は 1 つのリクエストがプロセスを押しつぶさないための歯止めでもある。
既定のセルフホスト構成にリバースプロキシはなく、ポータルも送信前にサイズを確認しない。だからこの拒否はサーバー自身の上限から来ている。前に nginx を置くと、先に当たるのは nginx だ。client_max_body_size は既定で 1 MiB しか許さず、超えると 413 を返す。その形は上の比較にあるものと近く、同じ上限だと誤解しやすい。
上限に当たったときの対処
費用の低い順に:
- 送る前に測る。 リクエストを送る前にボディのバイト数を 104,857,600 と比べ、境界の分の余裕を残しておく。後からステータスコードを読むより確実だ。
- ファイルを上限以内に圧縮する。 スキャンのサイズは主に画像層から来ており、再圧縮するとたいていはっきりした割合を削れる。PDF を圧縮 はブラウザで動くので、スクリプトは要らない。
- 作業を複数の呼び出しに分ける。 内容が分割できるなら、分割 して数回のリクエストで送る。上限を上げるより手間が少なく、1 リクエストが抱えるメモリも増やさない。
- 1 リクエストで送り切る固い要件があるときだけ、上限を変える。 それはコードを変更して再ビルドすることを意味し、前節のメモリコストも受け入れることになる。
この上限はクライアントに事前の判断を求める
2 つの失敗は同じ結論を指している。100 MiB という値は、クライアントが送信前に自分で計算しなければならない。受信側では bad_request で返され、そのコードはサイズについて何も語らない。送信側では 500 で返され、サーバー障害のように見える。リクエストが出ていく前にバイト数を数えることが、エラーメッセージの文言に依存しない唯一の判断方法だ。