Когда большой 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,
и заголовками полей; одна операция промахом кэша и
и pipeline делят этот лимит ответом 2xx вышестоящей операции
сверх: 400 + bad_request сверх: 500, в кэш ничего не пишется
(detail: чтение 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 и вообще не содержит слов о размере. Воспроизведите его телом на один байт больше:
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, что выглядит как сбой сервера. Считать байты до того, как запрос ушёл, — единственный способ судить, не завися от того, что написано в сообщении об ошибке.