Интеграция20 мин чтения

curl --compressed: какие клиенты не запрашивают сжатие

curl, Wget 1.x, urllib, node:http, PHP curl, Guzzle и java.net.http.HttpClient по умолчанию не шлют Accept-Encoding или шлют identity. Проверенные решения, замер байтов.

На этой странице

curl не отправляет заголовок Accept-Encoding, пока вы не передадите --compressed. GNU Wget 1.x и urllib.request в Python отправляют Accept-Encoding: identity, а node:http в Node, ext-curl в PHP, Guzzle и java.net.http.HttpClient в Java не отправляют этот заголовок вовсе. Сервер, который сжимает ответ только по запросу, возвращает им полный несжатый текст, и каждый из этих байтов проходит через ваш прокси. Requests, httpx, aiohttp, Scrapy, встроенный fetch в Node, axios, got, Go и браузеры уже запрашивают сжатие: для них включать нечего, и разве что необязательный декодер br ещё немного сократит объём некоторых страниц.

Каждое значение по умолчанию ниже зафиксировано в локальной лаборатории, которую ipvolt провёл 27 сентября 2026 года; версии указаны под каждой таблицей. Результаты синтетические: сервер и прокси на 127.0.0.1 отдавали локальную тестовую страницу, поэтому ни одно число байтов здесь не получено с публичного сайта и не взято из счёта провайдера.

Кто запрашивает сжатие по умолчанию

Не отправляют Accept-Encoding или отправляют identity

Сервер, который сжимает ответ только по запросу, отдаёт этим клиентам несжатый текст. Без заголовка: curl, node:http, node:https, PHP ext-curl, Guzzle, Laravel Http, Java HttpClient. identity: Wget, urllib, http.client.

КлиентЧто сделать
curlДобавьте --compressed
WgetДобавьте --compression=auto
urllib, http.clientПерейдите на Requests или httpx
node:http, node:httpsПерейдите на fetch, got или axios
PHP ext-curlЗадайте для CURLOPT_ACCEPT_ENCODING значение ''
Guzzle, Laravel HttpЗадайте для decode_content значение 'gzip'
Java HttpClientОтправьте Accept-Encoding: gzip, распакуйте через GZIPInputStream

Запущенные версии: curl 8.7.1, 8.22.0; Wget 1.25.0; urllib.request, http.client (Python 3.13.15, 3.14.7); node:http, node:https (Node.js 22.23.3, 24.21.0, 26.10.0); PHP 8.5.8 ext-curl (libcurl 8.20.0); Guzzle 8.2.0, Laravel Http 13.33.0; Java 25.0.4.1 HttpClient.

  • curl: заданный вручную -H 'Accept-Encoding: gzip' отправляется, но тело остаётся сжатым.
  • Wget: с --compression=auto он отправил gzip и декодировал ответ.
  • urllib.request и http.client возвращают сжатое тело в том виде, в каком оно пришло.
  • node:http и node:https возвращают сжатое тело в том виде, в каком оно пришло.
  • PHP ext-curl: при CURLOPT_ACCEPT_ENCODING, равном '', эта сборка libcurl отправила deflate, gzip, br, zstd. Заголовок, заданный вручную, отправляется, но ответ не декодируется.
  • Laravel Http: withOptions(['decode_content' => 'gzip']).
  • Java HttpClient никогда не декодирует ответ: если он пришёл в gzip, оберните тело в GZIPInputStream.

Уже запрашивают сжатие

Менять ничего не нужно: эти клиенты запрашивают сжатие и декодируют ответ.

КлиентОтправляемый заголовок
RequestsPython 3.13.15: gzip, deflate; Python 3.14.7: gzip, deflate, zstd
httpxgzip, deflate
aiohttpPython 3.13.15: gzip, deflate; Python 3.14.7: gzip, deflate, zstd
Scrapygzip, deflate, br, zstd
Встроенный fetch в Node.jsNode.js 22.23.3/24.21.0: br, gzip, deflate; Node.js 26.10.0: br, gzip, deflate, zstd
Go net/httpgzip

Запущенные версии: Requests 2.34.2; httpx 0.28.1; aiohttp 3.14.3; Scrapy 2.19.0; встроенный fetch (Node.js 22.23.3, 24.21.0, 26.10.0); Go 1.27.1 net/http.

  • Requests: пакет brotli добавляет br; на Python 3.13 пакет backports.zstd добавляет zstd.
  • httpx: пакет brotli добавляет br, а пакет zstandard добавляет zstd.
  • aiohttp: пакет Brotli добавляет br; на Python 3.13 пакет backports.zstd добавляет zstd.
  • Встроенный fetch по http://: gzip, deflate.
  • Go: если задать заголовок самостоятельно, декодирование отключается.

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

Каждая строка — результат запуска в лаборатории, а не чтения исходного кода: 567 записанных ячеек, ни одна не помечена статусом source-read (прочитано в исходном коде). В tables.md перечислены записи, на которых основана каждая строка. «Wget» здесь означает GNU Wget 1.x. GNU Wget2 по умолчанию запрашивает все кодирования, с которыми он собран (wget.c), а Fedora 40 и новее устанавливает wget2 в качестве команды wget (изменение в Fedora); это прочитано в исходном коде, Wget2 в лаборатории не запускался.

curl: заголовка нет, пока вы не передадите --compressed

Обычный curl URL вообще не отправляет Accept-Encoding. В libcurl параметр CURLOPT_ACCEPT_ENCODING по умолчанию равен NULL, а это, согласно документации, значит, что libcurl «не отправляет заголовок Accept-Encoding: и не распаковывает полученное содержимое автоматически»; утилита curl меняет этот параметр только для --compressed. Ни одна из двух сборок curl в лаборатории не отправила этот заголовок ни напрямую, ни через http://-прокси, ни через CONNECT. curl#11091 показывает то же самое на захвате nc для curl 7.88.1.

--compressed запрашивает все кодирования, которые умеет декодировать ваша сборка, а затем декодирует ответ:

  • Системный curl 8.7.1 в macOS, собранный только с zlib, отправил deflate, gzip.
  • curl 8.22.0 из Homebrew, собранный с brotli и zstd, отправил deflate, gzip, br, zstd.

Чтобы узнать, что запросит ваша сборка, посмотрите строку Features: в выводе curl -V: libz означает gzip и deflate, brotli добавляет br, а zstd добавляет zstd.

Поможет ли это, решают три детали:

  • Это «просьба, а не приказ; сервер может отдать данные сжатыми, а может и нет» (man-страница). curl не сообщает об ошибке, если сервер проигнорировал просьбу, и у него нет ключа, который превратил бы такой случай в ошибку (curl#7516), поэтому проверяйте байты, как показано ниже.
  • Заданный вручную -H 'Accept-Encoding: gzip' отправляется, но curl не декодирует ответ. В лаборатории curl вывел поток gzip размером 17 750 байт вместо страницы размером 100 129 байт. Используйте --compressed, а не заголовок.
  • --no-compressed снова отключает сжатие, например после строки --compressed в .curlrc.

Когда лабораторный сервер отправлял br или zstd, которые сборка из macOS не умеет декодировать, curl --compressed завершался с кодом выхода 61:

code
curl: (61) Unrecognized content encoding type. libcurl understands deflate, gzip content encodings.

У распаковки есть собственная цена. man-страница предупреждает, что «даже крошечные передачи могут развернуться и породить огромное количество байтов», и советует --max-filesize. Этот параметр останавливает передачу, которая разрастается при распаковке через --compressed, только «начиная с 8.20.0» (man-страница), поэтому системный curl 8.7.1 в macOS он не защищает. Это ограничение задокументировано; в лаборатории оно не проверялось.

Без --compressed сервер, который всё равно сжимает ответ, оставляет вас с сырыми байтами gzip, как в curl#2836; это знакомый случай «бинарного вывода», и --compressed его исправляет. Сами флаги прокси описаны в базовом руководстве по curl.

Python: urllib отправляет identity; Requests, httpx, aiohttp и Scrapy сжимают

  • urllib.request и http.client отправляют Accept-Encoding: identity на CPython 3.13.15 и 3.14.7. http.client добавляет этот заголовок, если вы не задали свой, с комментарием «we don't support encodings such as x-gzip or x-deflate» (мы не поддерживаем кодирования вроде x-gzip или x-deflate) (client.py), и ни один из двух модулей не декодирует ответ. В лаборатории urllib.request возвращал ответы в gzip, br и zstd в том виде, в каком они пришли; http.client, через который urllib.request отправляет свои запросы, запускался только со своим заголовком по умолчанию, так что для http.client этот вывод опирается на исходный код. Если задать заголовок самостоятельно, вы лишь получите сжатые байты, которые придётся декодировать вручную, поэтому лучше сменить клиент. Скрипт, который обязан остаться на стандартной библиотеке, может отправить Accept-Encoding: gzip и, когда ответ содержит Content-Encoding: gzip, декодировать тело через gzip.decompress(); этот путь в лаборатории не запускался.
  • Requests 2.34.2 (с urllib3 2.8.0) берёт значение по умолчанию из urllib3 (utils.py): gzip, deflate на Python 3.13.15 и gzip, deflate, zstd на 3.14.7, где urllib3 использует compression.zstd из стандартной библиотеки. Установка brotli добавляет br; на 3.13 backports.zstd добавляет zstd. Начиная с urllib3 2.6.0 отдельный пакет zstandard больше не включает zstd (request.py; прочитано в исходном коде, не запускалось). Настройка прокси описана в руководстве по прокси в Python Requests.
  • urllib3 сам по себе, через urllib3.request() или PoolManager, не добавляет заголовок сжатия. Его путь запроса сводится к http.client (connection.py), что по исходному коду означает identity. Это прочитано в исходном коде; в лаборатории запускался Requests, а не urllib3 отдельно.
  • aiohttp 3.14.3 отправляет gzip, deflate на 3.13.15 и gzip, deflate, zstd на 3.14.7; Brotli добавляет br, а на 3.13 backports.zstd добавляет zstd. Из клиентов на Python в этом обзоре только он выбрасывает исключение на кодировании, которое не может декодировать; точное сообщение приведено ниже.
  • Scrapy 2.19.0 отправляет gzip, deflate, br, zstd на обеих версиях. Начиная со Scrapy 2.18.0 поддержка brotli и Zstandard обязательна, «поэтому br и zstd всегда включаются в заголовок Accept-Encoding запросов» (примечания к выпуску).

httpx и zstd на Python 3.14

httpx 0.28.1 отправляет gzip, deflate на обеих версиях Python. br он добавляет с пакетом brotli, а zstd — только со сторонним пакетом zstandard; в отличие от Requests и aiohttp, он не использует zstd из стандартной библиотеки Python 3.14 (_decoders.py). Документация HTTPX устанавливает оба декодера командой pip install "httpx[brotli,zstd]". Для самого сжатия ничего из этого не нужно, поскольку gzip запрашивается всегда.

Это важно, когда сервер присылает кодирование, которое httpx не умеет декодировать: httpx возвращает сжатые байты и не выбрасывает исключения. На Python 3.14.7 без zstandard ответ в zstd дошёл до вызывающего кода как поток zstd размером 19 298 байт. Настройка прокси описана в руководстве по прокси в HTTPX.

Node.js: встроенный fetch сжимает, node:http — нет

Встроенный fetch (undici) выбирает заголовок по схеме URL и линейке релизов (исходный код, v26.10.0; v24.21.0):

  • URL https://: br, gzip, deflate в Node.js 22.23.3 и 24.21.0 и br, gzip, deflate, zstd в 26.10.0.
  • URL http://: gzip, deflate во всех трёх.
  • Любой запрос с заголовком Range: identity во всех трёх.

Все три декодировали gzip и br. Расходятся они на zstd, в том числе на zstd, который сервер присылает без запроса:

  • fetch в Node.js 22.23.3 не имеет декодера zstd и вернул сжатые байты без ошибки (исходный код).
  • fetch в Node.js 24.21.0 декодировал zstd, который не запрашивал. Тело из четырёх склеенных кадров zstd вернулось только в виде первого кадра: 25 033 из 100 129 байт, без ошибки. axios 1.20.0 и got 16.0.0 на Node.js 22.23.3 и 24.21.0 тоже вернули только первый кадр.
  • Node.js 26.10.0 декодировал все четыре кадра в fetch, axios и got.

node:http и node:https не отправляют Accept-Encoding ни в одном из трёх релизов и никогда не декодируют ответ; базовый HTTP-клиент Node не устанавливает этот заголовок (_http_client.js). Документация zlib в Node показывает ручной путь: задать заголовок самостоятельно и пропустить ответ через распаковщик. Переход на fetch, got или axios потребует меньше кода.

Встроенный fetch и пакет node-fetch

npm-пакет node-fetch — отдельный клиент со своими значениями по умолчанию (node-fetch#1556). На Node.js 24.21.0 node-fetch 3.3.2 отправил gzip, deflate, br, а node-fetch 2.7.0 — gzip,deflate, и оба декодировали ответ. axios 1.20.0 отправляет gzip, compress, deflate, br и добавляет zstd только с transitional: { advertiseZstdAcceptEncoding: true } (http.js). got 16.0.0 отправил gzip, deflate, br, zstd во всех трёх релизах (index.ts). Ни одному из них изменения не нужны. Диспетчер прокси для встроенного fetch описан в руководстве по настройке прокси для fetch в Node.js.

Go, Wget, PHP и Java

Go net/http запрашивает только gzip

Go 1.27.1 отправляет Accept-Encoding: gzip, декодирует ответ и удаляет Content-Encoding и Content-Length из ответа, который отдаёт вам (transport.go). Он не отправляет заголовок для запросов HEAD, для запросов с заголовком Range и при DisableCompression: true. Отсюда два следствия:

  • Если задать Accept-Encoding самостоятельно, декодирование отключается: «if the user explicitly requested gzip it is not automatically uncompressed» (если пользователь явно запросил gzip, ответ не распаковывается автоматически). С req.Header.Set("Accept-Encoding", "gzip") лабораторный клиент на Go получил поток gzip размером 17 750 байт.
  • В net/http нет декодеров br и zstd. Когда сервер присылал их без запроса, клиент получал сырые байты.

GNU Wget 1.x отправляет identity

Wget 1.25.0 отправляет Accept-Encoding: identity. Для --compression руководство говорит о значении «none»: «Это значение по умолчанию» (руководство); в списке команд .wgetrc на той же странице значением по умолчанию по-прежнему названо auto, но прогон в лаборатории соответствует none. С --compression=auto Wget отправил gzip и декодировал gzip — и больше ничего: br или zstd, присланные без запроса, пришли сырыми. Wget2 ведёт себя иначе, как отмечено выше.

PHP: Guzzle, Laravel Http и ext-curl не отправляют Accept-Encoding

  • PHP ext-curl (PHP 8.5.8 с libcurl 8.20.0) ничего не отправляет, потому что у CURLOPT_ACCEPT_ENCODING «значение по умолчанию null» (руководство PHP). CURLOPT_ACCEPT_ENCODING => '' запрашивает все кодирования, которые поддерживает подключённая libcurl, в этой сборке deflate, gzip, br, zstd, и декодирует ответ. Строка Accept-Encoding в CURLOPT_HTTPHEADER отправляется, но ответ не декодируется.
  • Guzzle 8.2.0 намеренно убирает этот заголовок. Его обработчик cURL включает все декодеры, а затем добавляет пустую строку Accept-Encoding:, чтобы curl не отправил свою, с комментарием, что такая строка «will be interpreted as 'Accept-Encoding: *'», то есть будет прочитана как согласие на любое кодирование (CurlFactory.php). Тот же код есть во всех проверенных тегах от 6.5.0 до 8.2.0 (прочитано в исходном коде). Передайте в decode_content строку, например 'decode_content' => 'gzip', и Guzzle отправит её как заголовок (Client.php); 'gzip, deflate, br, zstd' с обработчиком cURL тоже сработало. По умолчанию обработчик cURL всё равно декодирует gzip, br или zstd, которые сервер присылает без запроса. StreamHandler в Guzzle, который использует потоки PHP вместо ext-curl, декодировал gzip, но br и zstd вернул сырыми.
  • Laravel Http (illuminate/http 13.33.0) создаёт обычный клиент Guzzle и наследует всё это (PendingRequest.php). И withOptions(['decode_content' => 'gzip']), и withHeaders(['Accept-Encoding' => 'gzip']) отправили gzip и декодировали ответ.

Java HttpClient никогда не декодирует

java.net.http.HttpClient на Temurin 25.0.4.1 не отправил Accept-Encoding и не декодировал ответ в gzip, который сервер прислал без запроса. Чтобы получить сжатие, отправьте Accept-Encoding: gzip самостоятельно и читайте тело через java.util.zip.GZIPInputStream, когда ответ содержит Content-Encoding: gzip, как это делает лабораторный клиент на Java. Другие клиенты на Java не запускались.

Accept-Encoding: identity, отсутствие заголовка и нечитаемый вывод

RFC 9110 различает три состояния запроса:

  • Заголовка нет (curl, node:http, ext-curl, Guzzle, Java): «пользовательский агент считает приемлемым любое кодирование содержимого».
  • identity (urllib, Wget 1.x): «синоним „без кодирования“».
  • Пустое значение: «пользовательский агент не хочет никакого кодирования содержимого в ответе».

Значит, стандарт позволяет серверу сжать ответ на запрос без заголовка, а «сжимать только по запросу» — практика серверов, а не правило RFC. Эту практику описывает документация серверов и CDN. Cloudflare выбирает gzip, Brotli, Zstandard или отсутствие сжатия «в зависимости от» значений в заголовке accept-encoding запроса, тарифного плана и подходящего правила сжатия (Cloudflare, страница обновлена 17 апреля 2026 года). mod_deflate в Apache отправляет Vary: Accept-Encoding, чтобы сжатое содержимое не было «отправлено клиенту, который его не поймёт» (mod_deflate). Комментарий в Guzzle опирается на прочтение RFC; с серверами, которые следуют этой практике, запрос без заголовка получает несжатое тело. Лабораторный сервер следовал той же практике. Как часто реальные серверы сжимают ответ без запроса, не измерялось.

Некоторые сжимают. gzip_static always в nginx отдаёт сжатый gzip-файл, «не проверяя, поддерживает ли его клиент» (nginx), а в curl#2836 описан реальный хост, который так делал. Когда лабораторный сервер отправлял gzip без запроса, curl без --compressed, Wget, urllib, node:http, PHP ext-curl, Java и Go с DisableCompression отдавали вызывающему коду поток gzip размером 17 750 байт; Guzzle и Laravel Http его декодировали. Если ответ одного из этих клиентов выглядит как двоичный мусор, первым делом проверьте его Content-Encoding.

Проверьте байты на своём целевом сайте

Если для вашего клиента здесь нет сниппета подсчёта байтов, выполните приведённое ниже сравнение curl для того же URL через тот же прокси и сравните его Content-Encoding с заголовком ответа вашего клиента. Это измеряет ответ curl; другое кодирование или другой ответ сервера не дают замер байтов вашего клиента.

Сниппеты для curl, Requests, httpx и node-fetch-bytes.mjs ниже печатают байты тела по сети (wire body bytes): тело ответа в том виде, в каком оно прошло по сети, до любого декодирования. Заголовки ответа, записи TLS, обмен CONNECT и повторные запросы сюда не входят, поэтому это не то, за что выставляет счёт провайдер. В восьми лабораторных парах (их перечисляет tables.md) один и тот же клиент загружал тестовую страницу через CONNECT со сжатием и без него, и эти дополнительные байты добавляли от 2 343 до 3 520 байт на ответ на участке между прокси и клиентом. Они снижали коэффициент, например с 5,19× по телу до 4,56× на участке прокси для curl 8.22.0, но не экономию: в каждой из этих пар трафик на участке прокси сокращался на разницу в размере тела плюс 87 байт. Чтобы замерить весь прогон через ваш прокси и пересчитать его по вашей ставке, используйте Scrapescope.

Сниппет на Go печатает декодированный размер и resp.Uncompressed, который показывает, декодировал ли Go ответ, а связанный node-fetch-verify.mjs печатает заголовок, который отправил fetch; ни один из них не печатает байты по сети. Каждый сниппет запускался в лаборатории без изменений, только https://example.com/ заменялся на loopback-URL, и приведённый вывод получен в этих запусках; сниппет на Go вдобавок компилировался вместе с одним лабораторным файлом, который доверяет CA лаборатории (он описан в разделе о методе). Укажите свой прокси в PROXY_URL. Если нужно доверять частному CA, используйте собственную настройку клиента, например --cacert, NODE_EXTRA_CA_CERTS или tls.Config.RootCAs в Go; никогда не отключайте проверку сертификатов.

Проверка с curl

sh
# Wire body bytes and the Content-Encoding the server chose (curl 7.84.0+ for %header{}).
curl -sS -o /dev/null -x "$PROXY_URL" \
  -w '%{size_download} %header{content-encoding}\n' https://example.com/
curl -sS -o /dev/null -x "$PROXY_URL" --compressed \
  -w '%{size_download} %header{content-encoding}\n' https://example.com/

С curl 8.22.0 из Homebrew на лабораторной тестовой странице первая строка напечатала 100129 без кодирования, а вторая — 19298 zstd; системный curl 8.7.1 в macOS во второй строке напечатал 17750 gzip. size_download в curl — это «размер переданного тела или данных без учёта заголовков», а %header{} требует curl 7.84.0 или новее (write-out). Коэффициент для этой страницы — первое число, делённое на второе.

Проверка с Requests и httpx

python
import os

import requests

proxies = {"http": os.environ["PROXY_URL"], "https": os.environ["PROXY_URL"]}
with requests.get("https://example.com/", proxies=proxies, stream=True) as r:
    wire = r.raw.read(decode_content=False)  # body bytes as sent, still encoded
    print(r.headers.get("content-encoding"), len(wire))

Requests напечатал gzip 17750 на Python 3.13.15 и zstd 19298 на 3.14.7. В httpx r.num_bytes_downloaded считает байты в том виде, в каком они прочитаны, а len(r.content) — декодированное тело:

python
import os

import httpx

with httpx.Client(proxy=os.environ["PROXY_URL"]) as client:
    r = client.get("https://example.com/")
    # wire body bytes (as received) vs decoded bytes your code sees
    print(r.headers.get("content-encoding"), r.num_bytes_downloaded, len(r.content))

Он напечатал gzip 17750 100129 на Python 3.14.7 и zstd 19298 100129 с установленным zstandard. urllib никогда не декодирует, поэтому длина того, что вы из него прочитали, — это уже тело по сети.

Проверка с Node.js

Для встроенного fetch размер тела по сети даёт запись resource timing, которую Node создаёт для этого URL:

js
// Wire body bytes of a built-in fetch, from Node's resource timing entry for the URL.
// Run: NODE_USE_ENV_PROXY=1 HTTPS_PROXY="$PROXY_URL" node node-fetch-bytes.mjs
import { setImmediate } from 'node:timers/promises';

const url = new URL('https://example.com/');
const res = await fetch(url);
const body = await res.arrayBuffer(); // already decoded by fetch

// Node adds the entry after the body has been read, so give the event loop a turn.
let entry;
for (let turn = 0; !entry && turn < 100; turn++) {
  await setImmediate();
  entry = performance.getEntriesByType('resource').findLast((e) => e.name === url.href);
}
performance.clearResourceTimings(); // the buffer stops at 250 entries unless you clear it

// encodedBodySize: the body as it crossed the network, before decoding (no headers, TLS or CONNECT)
console.log('content-encoding', res.headers.get('content-encoding'),
  'wire body bytes', entry?.encodedBodySize, 'decoded bytes', body.byteLength);

Через лабораторный прокси CONNECT он напечатал content-encoding br wire body bytes 18451 decoded bytes 100129 на Node.js 22.23.3 и 24.21.0 и content-encoding zstd wire body bytes 19298 decoded bytes 100129 на 26.10.0, и каждый вывод совпал с тем, что отправил лабораторный сервер. Ещё в 18 лабораторных ячейках на трёх релизах (напрямую по http://, напрямую по https:// и через прокси CONNECT, каждый вариант со сжатием и без) encodedBodySize каждый раз совпадал с числом байтов тела, отправленных сервером; эти замеры перечислены в tables.md. Сразу после чтения тела записи ни разу не было — она появлялась только после одного оборота цикла событий, поэтому сниппет ждёт. Затем сниппет очищает записи, потому что буфер resource timing в Node по умолчанию вмещает только 250 записей (observe.js). Не используйте вместо этого transferSize: в каждой ячейке он был равен encodedBodySize плюс фиксированные 300 байт, тогда как через прокси участок между прокси и клиентом переносил на 3 431–3 520 байт больше, чем составляло тело.

node-fetch-verify.mjs печатает заголовок, который отправил fetch (sent accept-encoding: br, gzip, deflate, zstd на Node.js 26.10.0). Для node:https, который никогда не декодирует, node-http-verify.mjs считает тело по мере поступления, а это и есть тело по сети.

Проверка с Go

go
// Did Go ask for gzip and decode it for you? resp.Uncompressed says so.
// Run: go run go-verify.go   (with PROXY_URL set in the environment)
package main

import (
	"fmt"
	"io"
	"net/http"
	"net/url"
	"os"
)

func main() {
	proxy, err := url.Parse(os.Getenv("PROXY_URL"))
	if err != nil {
		panic(err)
	}
	t := http.DefaultTransport.(*http.Transport).Clone()
	t.Proxy = http.ProxyURL(proxy)
	resp, err := (&http.Client{Transport: t}).Get("https://example.com/")
	if err != nil {
		panic(err)
	}
	defer resp.Body.Close()
	body, err := io.ReadAll(resp.Body)
	if err != nil {
		panic(err)
	}
	// true: Go sent "Accept-Encoding: gzip" itself, received gzip and decoded it,
	// then removed Content-Encoding and set ContentLength to -1.
	fmt.Printf("uncompressed=%v content-encoding=%q content-length=%d decoded-bytes=%d\n",
		resp.Uncompressed, resp.Header.Get("Content-Encoding"), resp.ContentLength, len(body))
}

С Go 1.27.1 он напечатал uncompressed=true content-encoding="" content-length=-1 decoded-bytes=100129. resp.Uncompressed лишь сообщает, что Go декодировал ответ (response.go); размера по сети в ответе уже нет. Чтобы посчитать байты по сети, задайте заголовок самостоятельно (тогда тело останется сжатым), измерьте его, а затем декодируйте через compress/gzip.

Через прокси: CONNECT и absolute-form

Для URL https:// клиент открывает туннель CONNECT и отправляет запрос внутри TLS. При сквозном TLS без перехвата TLS прокси не может прочитать или изменить Accept-Encoding: он передаёт зашифрованный ответ. Лабораторный регистратор не разбирал заголовки внутри туннелей CONNECT. Для URL http:// большинство клиентов отправляют сам запрос на прокси в абсолютной форме (absolute-form), например GET http://host/path, поэтому прокси читает все заголовки: лабораторный прокси увидел deflate, gzip, br, zstd от curl 8.22.0 с --compressed, identity от Wget и gzip от Go. Промежуточный узел на таком пути мог бы также переписать заголовок или тело. Лабораторный прокси не делал ни того, ни другого, а реальные прокси не проверялись.

С URL http:// два клиента повели себя иначе:

  • Встроенный fetch в Node.js 22.23.3 и 24.21.0 с NODE_USE_ENV_PROXY=1 пропускал URL http:// через туннель CONNECT. Лабораторный регистратор не разбирал внутренний заголовок, но сам HTTP-запрос оставался открытым текстом, который прокси может прочитать: CONNECT сам по себе не шифрует трафик. Node.js 26.10.0 отправлял запрос в абсолютной форме.
  • StreamHandler в Guzzle отправлял на прокси строку запроса в форме origin-form, если не был задан request_fulluri, и лабораторный прокси отклонял её с кодом 400.

Что входит в счёт, определяют правила каждого провайдера. Decodo, например, описывает входящий трафик как «каждый байт, полученный от целевого сайта, включая заголовки ответа и cookie», пишет, что для HTTPS «байты, прошедшие через туннель прокси, также учитываются в использовании трафика прокси», и приводит пример, в котором его собственная тестовая конечная точка заняла 0,85 кБ входящего трафика по http:// против 3,96 кБ по https:// (справка Decodo, прочитано 26 сентября 2026 года, страница изменена 23 апреля 2026 года). Decodo не уточняет, считает ли он сжатые или декодированные байты. Для HTTPS со сквозным TLS внутри CONNECT прокси считает зашифрованные байты, проходящие через туннель, включая сжатый ответ и накладные расходы TLS; это наш вывод, а не утверждение Decodo. Скрапинговые API и анблокеры, которые сами отправляют запрос на целевой сайт, выбирают собственные заголовки и тарифицируют трафик по-своему, поэтому сверьтесь с документацией своего провайдера.

Копирование Accept-Encoding из браузера

Может возникнуть соблазн вставить в скрапер браузерный Accept-Encoding: gzip, deflate, br, zstd. Так вы просите у сервера кодирования, которые ваш клиент, возможно, не умеет декодировать, а в ряде клиентов заданный вручную заголовок ещё и отключает декодирование. Именно с этим заголовком лабораторный сервер выбрал zstd:

  • curl (обе сборки), urllib, node:http, Go, PHP ext-curl, Java HttpClient и StreamHandler в Guzzle вернули сжатые байты: обычный ответ с нечитаемым телом.
  • Requests и httpx делали то же самое всякий раз, когда не хватало подходящего декодера. На Python 3.13 Requests без дополнительных пакетов вернул сырыми и zstd, и br. На 3.14 он декодировал zstd, но вернул br сырым, когда сервер прислал br. httpx без zstandard вернул zstd сырым на обеих версиях Python, без исключения.
  • fetch в Node.js 22.23.3 вернул zstd сырым; 24.21.0 и 26.10.0 его декодировали.
  • Обработчик cURL в Guzzle декодировал и zstd, и br.
  • aiohttp выбрасывал исключение всякий раз, когда у него не было декодера: br без Brotli и zstd на Python 3.13 без backports.zstd. На 3.14 он декодировал zstd.

Решение — позволить клиенту самому сформировать заголовок. Если нужны br или zstd, установите пакет декодера, который использует ваш клиент, например brotli, backports.zstd на Python 3.13 или httpx[brotli,zstd]; тогда клиент сам добавит нужный токен.

aiohttp 3.14.3: Can not decode content-encoding: brotli (br). Please install `Brotli`

Когда сервер присылает br, а пакета Brotli нет, aiohttp 3.14.3 выбрасывает первое из этих исключений — одинаково на Python 3.13.15 и 3.14.7. Второе — вариант для zstd, который появляется на Python 3.13.15 без backports.zstd (loopback-URL сокращены):

code
aiohttp.client_exceptions.ClientResponseError: 400, message='Can not decode content-encoding: brotli (br). Please install `Brotli`', url='…'
aiohttp.client_exceptions.ClientResponseError: 400, message='Can not decode content-encoding: zstandard (zstd). Please install `backports.zstd`', url='…'

Код 400 — собственный статус aiohttp для ответа, который он не может декодировать; лабораторный сервер ответил 200 (http_parser.py). Установка Brotli или, во втором случае, backports.zstd устранила ошибку в лаборатории: после этого aiohttp декодировал br и zstd, включая тело из четырёх кадров zstd. Более короткое Can not decode content-encoding: br возникает в другой ветке того же файла: декодер установлен, но распаковать тело не удалось.

Серверы, которые неправильно обрабатывают объявленный zstd

Само объявление кодирования может сломать запрос. OpenSearch 2.19.0 зависал, когда Accept-Encoding запроса включал zstd, как это бывает у сборок curl с поддержкой zstd. Ошибку исправили в 2.19.1 и 3.0.0 (OpenSearch#17339). Если какой-то хост уходит в тайм-аут только для клиентов с поддержкой zstd, запрос, который просит только gzip, покажет, не в согласовании zstd ли дело.

Где сжатие не помогает

  • Уже сжатые данные, например изображения, видео и архивы. «Медиафайлы, например уже сжатые изображения, не выигрывают от HTTP-сжатия» (Web Almanac 2021).
  • Ответы, которые сервер или CDN оставляет несжатыми. Cloudflare, например, документирует сжатие только ответов 200, 403 и 404, с минимальным размером 48 байт для gzip и 50 байт для Brotli и Zstandard (Cloudflare).
  • Запросы Range и HEAD. Go не отправляет заголовок ни для тех, ни для других, а fetch в Node.js отправляет identity всякий раз, когда задан заголовок Range.
  • Отправка данных на сервер. --compressed и его аналоги работают для скачивания; для тел запросов «стандартного способа сжатия нет» (Everything curl).
  • Автоматизация браузера. Браузеры согласовывают сжатие сами: MDN приводит gzip, deflate, br, zstd как типичное значение браузера (MDN), а Chrome по умолчанию декодирует zstd начиная с версии 123 (Chrome Platform Status), поэтому в Playwright или Puppeteer байты стоит сокращать в том, что скачивает браузер, а не в этом заголовке.

Для страниц, которые вы загружаете снова и снова, отдельный рычаг — условные запросы: ответ 304 Not Modified вообще не содержит тела (RFC 9110; мониторинг по ETag).

Сколько это экономит

Включать сжатие имеет смысл только клиентам из первой таблицы. Для них:

экономия $ ≈ страницы × несжатые байты текста на страницу × (1 − 1/коэффициент) ÷ 10⁹ × ваши $ за ГБ

Коэффициент берите из шага проверки на своих страницах (байты без сжатия, делённые на байты со сжатием), а ставку — из своего тарифа.

Сжатие снижает счёт, только когда вы платите за байты. На фиксированном тарифе, где цена зависит от Мбит/с или числа потоков, она остаётся прежней, хотя через канал проходит больше сжатых страниц. В статье сколько стоит гигабайт у безлимитных резидентных прокси сравниваются эти два способа тарификации.

Вымышленный пример, а не измерение: 100 000 страниц HTML по 100 КБ (100 000 байт) — это 10 ГБ без сжатия. При коэффициенте 4 сжатие экономит 7,5 ГБ, при коэффициенте 10 — 9,0 ГБ. При вымышленной ставке от $3 до $8 за ГБ это примерно от $22,50 до $72 за один обход этих страниц.

Клиенту, который уже запрашивает сжатие, включение ничего не сэкономит: оно уже включено. Необязательный декодер br для Requests, httpx или aiohttp может ещё немного сократить объём некоторых страниц: на 23 страницах документации из лабораторного корпуса тела в br в сумме составили 277 665 байт против 325 873 для gzip, примерно на 15% меньше, но на синтетической странице br оказался больше gzip (18 451 против 17 750 байт).

Собственные коэффициенты лаборатории — не прогноз. Синтетическая тестовая страница сжалась в 5,64 раза с gzip и в 5,19 раза с zstd. В этом корпусе из 23 страниц документации CPython 3.14.7 несжатые тела были в 6,67 раза больше своего размера в gzip, в 6,61 раза больше размера в zstd и в 7,83 раза больше размера в br. Эти числа описывают только эти файлы.

Формула учитывает байты тела; в лаборатории заголовки, TLS и обмен CONNECT почти не менялись от сжатия, как показывает раздел о проверке. Как провайдер превращает байты в счёт (ГБ или ГиБ, минимальные объёмы, неудачные запросы), разбирается в Scrapescope. Результат — оценка, а не счёт провайдера.

Метод, ограничения и загрузки

ipvolt прогнал записанную матрицу из 567 ячеек один раз, 27 сентября 2026 года, на macOS 15.7.4 (Apple M4 Pro, arm64): подготовка с 02:43 до 02:44 UTC, затем вся матрица с 02:44 до 02:45 UTC. Сервер на Python на 127.0.0.1 отдавал синтетическую HTML-страницу размером 100 129 байт, а для некоторых ячеек — 23 страницы документации CPython 3.14.7, за прямым прокси со счётчиком байтов (counting forward proxy) на 127.0.0.1, который обрабатывал запросы как в абсолютной форме, так и через CONNECT. Без заголовка сервер отдавал страницу несжатой; в остальных случаях он выбирал из кодирований, перечисленных клиентом, предпочитая zstd, затем br, gzip и deflate. Принудительные режимы отправляли gzip, br, zstd, четыре кадра zstd или несжатый ответ независимо от запроса. Каждая HTTPS-ячейка доверяла одноразовому локальному CA через собственную настройку клиента, а в 21 контрольной ячейке без этого CA проверка сертификата каждый раз завершалась ошибкой. Сниппет на Go компилировался вместе с одним лабораторным файлом, который добавлял этот CA в RootCAs у http.DefaultTransport до того, как сниппет его клонировал (текст файла — LAB_TRUST_GO в snippets.py), поэтому доверие не зависело от SSL_CERT_FILE: go1.27.1 на macOS учитывает эту переменную, если не задано GODEBUG=x509sslcertoverrideplatform=0, а в модуле, где объявлен go 1.26, оно задано по умолчанию. Второй прогон из опубликованного архива, в пустом каталоге и с заново скачанными файлами, совпал со всеми 567 записями по каждому сравниваемому полю (compare.py --strict).

Версии: curl 8.7.1 (macOS, LibreSSL) и 8.22.0 (Homebrew, OpenSSL 3.6.4, brotli, zstd); GNU Wget 1.25.0; CPython 3.13.15 и 3.14.7 с Requests 2.34.2 (urllib3 2.8.0), httpx 0.28.1, aiohttp 3.14.3 и Scrapy 2.19.0; Node.js 22.23.3, 24.21.0 и 26.10.0 с nodejs.org с проверенными контрольными суммами, с axios 1.20.0, got 16.0.0 и node-fetch 3.3.2 и 2.7.0; Go 1.27.1; PHP 8.5.8 (статическая сборка, libcurl 8.20.0) с Guzzle 8.2.0 и illuminate/http 13.33.0; Temurin JDK 25.0.4.1.

Лаборатория не охватывала HTTP/2 и HTTP/3 (сервер предлагал только HTTP/1.1), сборки для Linux и Windows, браузеры, Wget2, urllib3 отдельно, curl_cffi, Apache HttpClient, OkHttp, Symfony HttpClient, .NET HttpClient, Rust reqwest, Ruby Net::HTTP и Faraday, другие сборки PHP и libcurl, а также релизы Java после 25. Значения по умолчанию меняются от релиза к релизу. Здесь использованы версии, которые были установлены или актуальны на момент прогона: системный curl 8.7.1 в macOS старше curl 8.22.0, статическая сборка PHP на три патч-релиза отставала от 8.5.11 на php.net, а Java 25 — новейший LTS-релиз, но не новейший feature-релиз. Прежде чем полагаться на какую-либо строку, повторите прогон лаборатории.

Все файлы лежат в https://ipvolt.com/downloads/accept-encoding-defaults/:

Чтобы воспроизвести весь прогон, распакуйте архив, выполните в harness/ сначала ./setup.sh, затем ./run.sh, а после этого python3 ../compare.py ../results.json out/results.json. В README перечислено, что скачивает setup и что нужно каждой группе ячеек. Аккаунт у прокси-провайдера не нужен.

ipvolt, который публикует это руководство, — прокси-сервис в разработке с оплатой за ГБ на старте. Измерения выполнены через локальный тестовый прокси; советы подходят для любого провайдера и для работы вовсе без прокси.

Если хотите узнать, когда откроется доступ к ipvolt, запишитесь в список раннего доступа. Одно письмо, когда откроется доступ. Больше ничего.

Источники и дополнительное чтение

Технические материалы, использованные при подготовке руководства. Сверяйтесь с документацией вашей версии и с поддерживаемой конфигурацией вашего провайдера.