Коли великий PDF не завантажується: ліміт тіла запиту 100 MiB і помилка, що веде не туди
Тіло одного запиту може займати 100 MiB, тобто 104,857,600 байтів разом із multipart-обрамленням; понад ліміт кінцеві точки операцій відповідають 400 з кодом bad_request і згадкою про невдале multipart-поле, а не 413, тож проблема розміру виглядає як проблема параметра; з Idempotency-Key діє другий ліміт 100 MiB на буфер відповіді, і при його перевищенні приходить 500, а в кеш нічого не пишеться.

Великий PDF, який не завантажується, зазвичай не пошкоджений файл. Спрацював ліміт на тіло одного запиту: 100 MiB, тобто 104,857,600 байтів. Він рахується по всьому тілу, разом із межами multipart і заголовками полів: рівно стільки проходить, а на один байт більше — вже ні.
А помилка, що повертається, вказує в інший бік. Кінцеві точки операцій відповідають 400, code дорівнює bad_request, а в полі detail сказано, що не вдалося прочитати multipart-поле; про розмір не згадано ніде. Клієнт, який розгалужується за code, записує це в помилки параметрів і йде перевіряти назви полів, хоча змінити треба розмір файлу.
Ті самі 100 MiB керують і зворотним напрямком. Запит із Idempotency-Key перед кешуванням читає відповідь у пам’ять, і там діє те саме число; при перевищенні клієнт отримує 500, хоча операція вже завершилася. Одне число, дві протилежні картини збою.
100 MiB = 104,857,600 байт (рівно стільки проходить)
|
+------------------+------------------+
| |
вхідний (тіло запиту) вихідний (тіло відповіді)
по всьому тілу, з межами multipart лише з Idempotency-Key,
і заголовками полів; одна операція промахом кешу та
й конвеєр ділять цей ліміт відповіддю 2xx від вищої операції
понад: 400 + bad_request понад: 500, у кеш нічого не пишеться
(detail: читання multipart-поля не вдалося)
Підпис до рисунка: один ліміт, але на вхідному й вихідному боці він дає різні коди стану та протилежні наслідки.
Ліміт рахується по всьому тілу запиту, разом із межами
100 MiB обмежує тіло одного запиту, а не розмір окремого файлу й не розмір після розпакування.
- Одна операція й багатокроковий конвеєр ділять це значення. Розбивши роботу на 10 кроків в одному виклику
/api/v1/pipeline, ви не перетворите стелю на 1 GB; кількість кроків впливає лише на час виконання. - Граничне значення проходить: тіло запиту на 104,857,600 байтів надсилається, а 104,857,601 байт — уже ні.
- У тілі, крім байтів файлу, є заголовки кожного поля й роздільники меж, тому на один файл лишається строго менше 100 MiB. Файл рівно на 104,857,600 байтів буде відхилено.
Саме на останньому пункті практика спотикається найчастіше: curl -F додає межі за вас, тож порівняння розміру файлу з цією межею ніколи не зійдеться.
При перевищенні приходить 400 і bad_request
Відповідь при перевищенні не використовує 413 і взагалі не містить слів про розмір. Відтворіть її тілом на один байт більшим:
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, який просто на кілька сотень байтів більший.
Отже, ознака не в коді стану, а в кількості байтів тіла: якщо прийшов 400, а в detail є failed to read multipart field, наступним кроком виміряйте розмір, а не перебирайте форму заново.
413 на цьому сайті теж трапляється, просто не на кінцевих точках операцій. Усі точки входу, що приймають файл, піднято до 100 MiB, а кінцеві точки без завантаження й далі працюють на типових 2 MiB HTTP-фреймворку (у Rust це axum); тіло понад ліміт отримує 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, що виглядає як збій сервера. Рахувати байти до того, як запит пішов, — єдиний спосіб судити, не залежачи від того, що написано в повідомленні про помилку.