How-to2026-08-276 мин чтения

Когда большой PDF не загружается: лимит тела запроса 100 MiB и ошибка, уводящая не туда

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

PDF123 · Updated 2026-08-27

Большой 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, но условий для него куда меньше:

  1. Запрос нёс Idempotency-Key
  2. В кэше промах: этот ключ встретился впервые
  3. Вышестоящая операция вернула 2xx

Отказы не кэшируются и проходят насквозь, поэтому до этого лимита добирается только успешный большой вывод.

Именно здесь и происходит то, что стоит запомнить: вы получаете 500, и в кэш не записывается ничего. Операция уже выполнена, а вызывающий видит отказ; поскольку в кэше пусто, повторная попытка прогоняет всё заново. Ключ идемпотентности существует, чтобы исключать повторную работу, и отказывает ровно тогда, когда нужен больше всего, выдавая завершённую задачу за сбой сервера. Кэш живёт в памяти процесса и никогда не пишется на диск, поэтому перезапуск его очищает; о семантике см. Idempotency-Key: безопасные повторные попытки для PDF-задач.

100 MiB — это не настраиваемый параметр платформы

Это значение нельзя изменить. Ни одна переменная окружения не поднимает и не опускает его, ни в одну сторону; другое число означает правку кода и пересборку образа. Искать его среди настроек развёртывания бесполезно: вы его там не найдёте.

Это к тому же не просто квота. Байты тела запроса читаются в память целиком до обработки, поэтому каждая параллельная большая загрузка держит рядом сопоставимый объём. Поднять потолок — значит принять и более высокий пик памяти: это число ещё и не даёт одному запросу утянуть процесс за собой.

В развёртывании по умолчанию, самостоятельно размещённом, обратного прокси нет, а портал не проверяет размер перед отправкой, поэтому отказ приходит от собственного лимита сервера. Поставьте впереди nginx, и первым сработает он: client_max_body_size по умолчанию пропускает только 1 MiB и отвечает 413 — форма близка к той, что в сравнении выше, и её легко принять за тот же лимит.

Что делать, когда вы в него упёрлись

Сначала самое дешёвое:

  1. Сначала измерьте, потом отправляйте. Сравните число байт тела запроса с 104,857,600 до отправки и оставьте запас на границы. Это надёжнее, чем читать код состояния задним числом.
  2. Сожмите файл до лимита. Размер скана в основном складывается из его графического слоя, и повторное сжатие обычно снимает заметную его часть. Сжать PDF работает в браузере, скрипт не нужен.
  3. Разделите работу на несколько вызовов. Когда содержимое делится, разделите его и отправьте несколькими запросами: это проще, чем поднимать потолок, и не увеличивает память, которую держит один запрос.
  4. Меняйте лимит только ради жёсткого требования всё уместить в один запрос. Это означает правку кода, пересборку и принятие той памяти из предыдущего раздела.

Этот лимит требует от клиента решить заранее

Оба отказа ведут к одному выводу: число 100 MiB клиент должен вычислить сам, до отправки. На входящем направлении вам отвечают bad_request — код, который ничего не говорит о размере; на исходящем — 500, что выглядит как сбой сервера. Считать байты до того, как запрос ушёл, — единственный способ судить, не завися от того, что написано в сообщении об ошибке.

Open tool
Process in the browser — no watermark, files removed after the job.
Open tool