Компьютерные сети HTTP: методы, коды, заголовки, кэширование, эволюция 1.1 → 2 → 3
0%

HTTP: методы, коды, заголовки, кэширование, эволюция 1.1 → 2 → 3

HTTP: методы, коды, заголовки, кэширование, эволюция 1.1 → 2 → 3

HTTP — самый успешный протокол в истории и одновременно самый недопонятый. Его учат по шпаргалке «GET читает, POST пишет, 404 не найдено» и на этом останавливаются, а потом в проде выясняется, что CDN отдаёт всем пользователям персональную страницу первого зашедшего, что ретрай POST списал деньги дважды, что «мы включили HTTP/2» ничего не ускорило, а прокси и бэкенд по-разному поняли, где заканчивается тело запроса, и это оказалось дырой в безопасности.

Ключевая идея, из которой выводится всё остальное: HTTP — это протокол передачи представлений ресурсов, и он спроектирован вокруг посредников. Между вашим браузером и вашим приложением почти никогда нет прямой линии: там браузерный кэш, корпоративный прокси, CDN, балансировщик, service mesh sidecar. Каждый из них имеет право читать и интерпретировать сообщение. Именно поэтому HTTP такой болтливый и текстовый, поэтому у него настолько подробная модель кэширования, и поэтому семантика методов («этот запрос безопасно повторить») важнее любой из его бинарных версий.

Актуальная спецификация — не «RFC 2616», как до сих пор пишут в половине статей, а серия 2022 года: RFC 9110 «HTTP Semantics» (методы, коды, заголовки — общее для всех версий), RFC 9111 «Caching», RFC 9112 «HTTP/1.1», RFC 9113 «HTTP/2», RFC 9114 «HTTP/3». Такое разделение само по себе поучительно: семантику вынесли отдельно, потому что она за 25 лет почти не изменилась, а транспорт переписали трижды.

Предполагается, что вы знакомы с TCP, UDP и QUIC и DNS; карта уровней — в обзоре трека.

Анатомия сообщения

Раскладка байтов HTTP/1.1-сообщения: стартовая строка, заголовки, CRLF, тело

Проще всего это увидеть, отправив запрос руками, без библиотек:

# Открытым текстом на 80-й порт: видно ровно то, что уходит в TCP
printf 'GET / HTTP/1.1\r\nHost: example.com\r\nConnection: close\r\n\r\n' \
  | nc example.com 80 | head -12
HTTP/1.1 200 OK
Content-Type: text/html; charset=UTF-8
ETag: "3147526947"
Last-Modified: Thu, 17 Oct 2019 07:18:26 GMT
Cache-Control: max-age=604800
Date: Wed, 16 Jul 2026 09:04:11 GMT
Expires: Wed, 23 Jul 2026 09:04:11 GMT
Content-Length: 1256
Connection: close

Три вещи, которые здесь важнее, чем кажется. \r\n, а не \n — строгие серверы (и все прокси) считают одиночный LF нарушением; ошибка «мой самописный клиент работает с nginx, но ловит 400 от Envoy» почти всегда про это. Заголовок Host обязателен в HTTP/1.1: один IP обслуживает тысячи сайтов, и без Host сервер не знает, чью страницу отдавать; его отсутствие — гарантированный 400 Bad Request. Пустая строка — единственная граница заголовков и тела; всё, что после, интерпретируется по Content-Length или Transfer-Encoding.

Для TLS вместо nc берём openssl s_client — тот же уровень «сырых байтов», но внутри шифрования (подробности рукопожатия — в TLS):

printf 'HEAD / HTTP/1.1\r\nHost: example.com\r\nConnection: close\r\n\r\n' \
  | openssl s_client -quiet -servername example.com -connect example.com:443 2>/dev/null

-servername здесь не косметика: это SNI, без него сервер с сотней сайтов отдаст дефолтный сертификат, и вы будете отлаживать несуществующую проблему.

Методы: три свойства, из которых следует всё

Метод — это не «глагол для красоты URL», а обещание о трёх независимых свойствах. Именно на них опираются браузеры, прокси, CDN и библиотеки ретраев.

Метод Безопасный Идемпотентный Кэшируемый Тело запроса
GET да да да нет (по семантике игнорируется)
HEAD да да да нет
OPTIONS да да нет нет
TRACE да да нет нет
PUT нет да нет да
DELETE нет да нет обычно нет
POST нет нет да, но только с явным Cache-Control да
PATCH нет нет (зависит от формата патча) нет да
  • Безопасный (safe) — «не должен менять состояние». Отсюда: браузер имеет право спекулятивно предзагружать GET, поисковый робот пройдёт по всем ссылкам. Классическая катастрофа нулевых — админка со ссылками /delete?id=17: приходил краулер и вычищал базу. Это не баг краулера.
  • Идемпотентный — «повторить N раз = сделать один раз». Отсюда: клиентские библиотеки и прокси автоматически ретраят GET, PUT, DELETE при обрыве соединения и не ретраят POST. Если ваш POST /payments должен переживать ретрай — вводите ключ идемпотентности в заголовке; см. идемпотентность и доставку.
  • Кэшируемый — «ответ можно сохранить и переиспользовать». По умолчанию кэшируются ответы GET и HEAD с определёнными кодами.

Практические различия, о которых спорят на ревью:

PUT vs POST. PUT /orders/8812 — «сделай так, чтобы по этому URI лежало вот это». Клиент задаёт идентификатор, повтор безопасен. POST /orders — «обработай это как считаешь нужным», сервер сам создаёт ресурс и возвращает 201 Created с Location. Правило: если клиент знает URI заранее — PUT, если нет — POST.

PATCH. Не идемпотентен в общем случае: {"op":"increment","field":"qty"} при повторе даст другой результат. Идемпотентным его делает формат данных, а не метод. Используйте JSON Merge Patch (RFC 7386) для «заменить поля» или JSON Patch (RFC 6902) для операций, и явно указывайте Content-Type: application/merge-patch+json.

OPTIONS и CORS-preflight. Браузер сам, до вашего запроса, отправляет OPTIONS, если запрос «непростой» (метод не GET/HEAD/POST, или заголовок вроде Authorization, или Content-Type: application/json):

curl -i -X OPTIONS https://api.example.com/v1/orders \
  -H 'Origin: https://app.example.com' \
  -H 'Access-Control-Request-Method: POST' \
  -H 'Access-Control-Request-Headers: content-type,authorization'
HTTP/2 204
access-control-allow-origin: https://app.example.com
access-control-allow-methods: GET, POST, PATCH, DELETE
access-control-allow-headers: content-type, authorization
access-control-allow-credentials: true
access-control-max-age: 600
vary: Origin

Здесь важны две строки, которые забывают. access-control-max-age: 600 кэширует preflight — без неё каждый запрос стоит два RTT вместо одного. vary: Origin обязателен, если allow-origin вычисляется по входящему Origin: без него CDN закэширует ответ для одного фронтенда и отдаст его другому, сломав CORS для всех. И помните: CORS — это защита браузера, а не сервера. curl игнорирует её полностью, поэтому авторизация должна быть настоящей (API security).

TRACE отключайте: в связке с некоторыми клиентами он давал Cross-Site Tracing и утечку Cookie и Authorization.

Коды состояния: как выбрать правильный

Первая цифра — единственное, что клиент обязан понимать: 1xx информационные, 2xx успех, 3xx перенаправление, 4xx ошибка клиента, 5xx ошибка сервера. Незнакомый 418 клиент обязан трактовать как 400.

Разбор мест, где ошибаются чаще всего.

301 vs 302 vs 307 vs 308. Историческая ловушка: браузеры при 301 и 302 меняли POST на GET, хотя спецификация этого не разрешала. Чтобы вылечить, ввели 307 (временный, метод и тело сохраняются) и 308 (постоянный, сохраняются). Правило: редирект HTTP→HTTPS и смена домена — 301; временный техперерыв — 302; редирект внутри API, где важно не потерять POST, — 307. И 303 See Other — «сделай GET вон туда», классический post/redirect/get после формы. 301 кэшируется браузером агрессивно и практически навсегда — ошибочный 301 на продакшене живёт в браузерах пользователей месяцами; на время эксперимента всегда 302.

401 vs 403. 401 Unauthorized на самом деле означает «не аутентифицирован» и обязан нести WWW-Authenticate. 403 Forbidden — «аутентифицирован, но не положено». Возврат 403 там, где нужен 401, ломает автоматическое обновление токена в клиентах.

400 vs 422. 400 — сервер не смог разобрать сообщение (сломанный JSON, битые заголовки). 422 — разобрал, но данные не проходят по бизнес-правилам (qty: -5). Разница практическая: 400 не имеет смысла показывать пользователю, 422 имеет.

429 и 503 оба должны нести Retry-After (секунды или HTTP-дату), иначе клиенты будут долбить в цикле и превратят частичную деградацию в полный отказ. Ретраи всегда с экспоненциальной задержкой и джиттером.

Формат тела ошибки — не изобретайте: есть RFC 9457 «Problem Details for HTTP APIs»:

{
  "type": "https://api.example.com/problems/insufficient-funds",
  "title": "Недостаточно средств",
  "status": 409,
  "detail": "На счёте 120.50 USD, требуется 340.00 USD",
  "instance": "/accounts/12/withdrawals/8812",
  "balance": "120.50"
}

Отдаётся с Content-Type: application/problem+json. type — стабильный идентификатор для кода клиента, detail — для человека.

Заголовки, которые действительно решают судьбу запроса

Заголовков сотни, но регулярно нужны десятка три. Сгруппируем по тому, на что они влияют.

Идентификация ресурса и представления. Host (в HTTP/2 и 3 — псевдозаголовок :authority) выбирает виртуальный хост. Content-Type описывает формат тела вместе с параметрами: application/json не имеет параметра charset (всегда UTF-8), а text/html; charset=utf-8 — имеет, и его отсутствие отдаёт браузеру право гадать. Content-Language, Content-Encoding (gzip, br, zstd) описывают то же представление в другой упаковке.

Согласование содержимого. Клиент шлёт Accept, Accept-Encoding, Accept-Language с весами (q=0.8), сервер выбирает и обязан объявить, по чему он выбирал, в Vary. Пропуск Vary — источник самых неприятных инцидентов с кэшами: русскоязычный ответ уезжает англоязычному пользователю, а br-сжатое тело — клиенту, который brotli не понимает.

Границы тела. Ровно один из двух механизмов: Content-Length (точная длина) или Transfer-Encoding: chunked (длина каждого куска в hex, нулевой кусок — конец). Если в сообщении есть оба, спецификация требует игнорировать Content-Length, но не все посредники это делают — отсюда HTTP request smuggling: фронт видит границу запроса в одном месте, бэкенд в другом, и «хвост» первого запроса становится началом второго, позволяя атакующему подставить свои заголовки в чужой запрос. Защита: одинаковый строгий парсер на всём пути, отклонение сообщений с обоими заголовками, HTTP/2 до бэкенда. Подробности — в OWASP Top 10.

Hop-by-hop против end-to-end. Connection, Keep-Alive, Transfer-Encoding, Upgrade, Proxy-Authenticate, TE относятся к одному участку и обязаны удаляться каждым прокси. Всё остальное едет насквозь. Именно поэтому в HTTP/2, где соединение мультиплексировано, Connection вообще запрещён — его наличие обязано порождать ошибку протокола. Ошибка «мы просто проксируем все заголовки как есть» ломает keep-alive и Upgrade для WebSocket (реальное время).

Информация о пути. Прокси добавляют Via, X-Forwarded-For, X-Forwarded-Proto, X-Forwarded-Host или стандартизованный Forwarded (RFC 7239). Правило безопасности: доверять X-Forwarded-For можно только от своих прокси и только последнему добавленному значению — иначе любой клиент подделает свой IP и обойдёт rate limiting. В nginx это set_real_ip_from + real_ip_header, детали — в прокси и балансировке.

Диапазоны. Range: bytes=1048576- + ответ 206 Partial Content с Content-Range — на этом держатся докачка, перемотка видео и параллельная загрузка кусками. Сервер объявляет поддержку через Accept-Ranges: bytes. Условный вариант If-Range защищает от докачки изменившегося файла.

Expect: 100-continue. Клиент шлёт заголовки без тела и ждёт 100 Continue, прежде чем заливать 2 ГБ. Позволяет серверу отбить запрос по авторизации или размеру, не приняв ни байта тела. curl включает это сам для больших POST, что иногда даёт загадочную секундную паузу с серверами, которые не отвечают 100.

Кэширование: самая недооценённая часть HTTP

Самый быстрый запрос — тот, которого не было. Модель кэширования HTTP (RFC 9111) работает одинаково в браузере, в CDN, в nginx и в вашем requests-клиенте с кэш-адаптером, потому что она описана в самом протоколе, а не в конфиге.

Жизненный цикл записи в кэше

Арифметика свежести, которую стоит держать в голове:

freshness_lifetime = s-maxage                                  # только для общих кэшей
        иначе -> max-age
        иначе -> Expires - Date
        иначе -> эвристика: (Date - Last-Modified) / 10         # если явного нет ничего

current_age = Age (сколько ответ пролежал в вышестоящих кэшах)
            + (now - Date)

свежий, пока current_age < freshness_lifetime

Эвристика — причина «мы ничего не настраивали, а страница залипла»: если ответ несёт Last-Modified годовой давности и не несёт Cache-Control, кэш имеет полное право держать его больше месяца. Отсутствие заголовков кэширования — это не «не кэшировать», это «решай сам».

Директивы, которые нужно знать наизусть

Директива Кто слушает Что делает
max-age=N все свежесть N секунд
s-maxage=N только общие (CDN, прокси) переопределяет max-age для них
no-cache все можно хранить, но перед каждым использованием ревалидировать
no-store все не сохранять вообще нигде — для персональных данных
private общие «только браузер»; CDN обязан не сохранять
public общие можно кэшировать даже при Authorization
must-revalidate все протухло — запрещено отдавать stale, лучше 504
immutable браузер не ревалидировать даже по F5
stale-while-revalidate=N CDN, браузер отдать протухшее мгновенно и обновить в фоне
stale-if-error=N CDN ориджин упал — отдавать протухшее вместо ошибки

Самая частая путаница: no-cache не запрещает кэширование, он требует ревалидации. Запрещает — no-store. Приватную страницу с no-cache CDN сохранит на диск совершенно законно.

Две рабочие стратегии

Иммутабельные ассеты + версия в имени файла. app.7f3a91c.js меняет имя при каждой сборке, поэтому его можно кэшировать вечно:

Cache-Control: public, max-age=31536000, immutable

Динамический HTML/API — короткая свежесть плюс ревалидация:

Cache-Control: public, max-age=0, s-maxage=60, stale-while-revalidate=300
ETag: W/"8812-v3"
Vary: Accept-Encoding, Accept-Language

Здесь браузер каждый раз спрашивает, CDN держит минуту, а следующие пять минут отдаёт слегка протухшее мгновенно и обновляет в фоне. Для трафика с пиками это разница между 5% и 95% попаданий в кэш.

Ревалидация в реальном выводе

# 1. Получаем валидатор
curl -sI https://example.com/ | grep -i -E 'etag|last-modified|cache-control|age'
Cache-Control: max-age=604800
ETag: "3147526947+gzip"
Last-Modified: Thu, 17 Oct 2019 07:18:26 GMT
Age: 219
# 2. Условный запрос — тело не приедет, если ничего не изменилось
curl -s -o /dev/null -w '%{http_code} %{size_download} байт\n' \
  -H 'If-None-Match: "3147526947+gzip"' https://example.com/
304 0 байт

Ноль байт тела вместо 1256 — и это на статической странице; на списке из тысячи заказов экономия на порядки. W/ в начале ETag означает слабый валидатор: «семантически то же самое», допускается различие в байтах (например, разный порядок полей или timestamp генерации). Сильный ETag обязателен для Range и If-Match.

If-Match — оптимистическая блокировка бесплатно. Клиент читает GET /orders/8812ETag: "v7", затем шлёт PUT с If-Match: "v7". Если кто-то успел изменить — 412 Precondition Failed вместо потерянного обновления. Это lost update problem, решённая на уровне протокола, без единой строчки в бизнес-коде.

Ключ кэша и Vary — где всё ломается

Ключ кэша по умолчанию: метод + полный URI (включая порядок query-параметров как есть) + значения заголовков из Vary. Отсюда три реальных инцидента:

  • ?utm_source=... создаёт отдельную запись для каждой рекламной метки — попадания падают до нуля. Лечится нормализацией ключа на CDN.
  • Vary: User-Agent фрагментирует кэш на десятки тысяч записей (User-Agent почти уникален). Никогда так не делайте; для мобильной версии — отдельный URI или Vary: Sec-CH-UA-Mobile.
  • Vary: Cookie забыли, а ответ содержит имя пользователя. Первый вошедший пользователь «прогревает» CDN своей персональной страницей, остальные получают её же. Правильно: персональные ответы — Cache-Control: private, no-store.

Соединения и эволюция версий

HTTP/1.1 и цена соединения

До 1.1 каждый запрос = новое TCP-соединение = рукопожатие + slow start. Страница из 40 картинок стоила 40 рукопожатий. Connection: keep-alive (в 1.1 — поведение по умолчанию) позволил переиспользовать соединение, и это до сих пор самая окупаемая оптимизация в любом HTTP-клиенте.

Попытка номер два — pipelining: слать несколько запросов не дожидаясь ответов. Провалилась: ответы обязаны возвращаться строго в порядке запросов, поэтому один медленный ответ блокирует все следующие (head-of-line blocking на уровне приложения), а половина прокси реализовала это с ошибками. Браузеры выключили pipelining и вместо этого стали открывать до 6 соединений на хост — отсюда родились хаки вроде доменного шардинга (img1.example.com, img2.example.com), которые в эпоху HTTP/2 стали вредными.

Проверить, переиспользуется ли соединение, можно так:

curl -sv -o /dev/null https://example.com/ https://example.com/robots.txt 2>&1 \
  | grep -E 'Connected to|Re-using|Connection #'
* Connected to example.com (93.184.216.34) port 443
* Connection #0 to host example.com left intact
* Re-using existing connection with host example.com

Re-using existing connection — второй запрос сэкономил TCP- и TLS-рукопожатие целиком, то есть 2 RTT. Если этой строки нет в вашем сервисе — ищите Connection: close от сервера или клиент, создающий новый пул на каждый вызов (классика в Go: http.Client без переиспользования, в Python: requests.get вместо requests.Session).

HTTP/2: мультиплексирование и его границы

Стеки HTTP/1.1, HTTP/2 и HTTP/3 и поведение при потере пакета

HTTP/2 берёт те же сообщения и режет их на фреймы: заголовок фрейма 9 байт (длина 24 бита, тип 8, флаги 8, идентификатор потока 31), затем полезная нагрузка. Типы, которые видно в дампе: HEADERS, DATA, SETTINGS, WINDOW_UPDATE, RST_STREAM, GOAWAY, PING. Каждый запрос-ответ живёт в своём потоке с собственным идентификатором (клиентские нечётные, серверные чётные), и фреймы разных потоков свободно чередуются в одном TCP-соединении.

curl -sv --http2 https://example.com/ -o /dev/null 2>&1 | grep -E 'ALPN|HTTP/2|^[<>]'
* ALPN: curl offers h2,http/1.1
* ALPN: server accepted h2
* using HTTP/2
* [HTTP/2] [1] OPENED stream for https://example.com/
* [HTTP/2] [1] [:method: GET]
* [HTTP/2] [1] [:scheme: https]
* [HTTP/2] [1] [:authority: example.com]
* [HTTP/2] [1] [:path: /]
> GET / HTTP/2
> Host: example.com
>
< HTTP/2 200
< content-type: text/html; charset=UTF-8
< cache-control: max-age=604800

Что здесь читается. Версию выбирает ALPN внутри TLS-рукопожатия, а не отдельный запрос — переход на h2 стоит ноль дополнительных RTT. Псевдозаголовки :method, :scheme, :authority, :path заменили стартовую строку и Host; они всегда идут первыми и всегда в нижнем регистре — имена заголовков в HTTP/2 обязаны быть строчными, заглавная буква = ошибка протокола. Строка > GET / HTTP/2 — выдумка curl для читаемости, в проводе такого текста нет.

HPACK (RFC 7541) сжимает заголовки статической таблицей из 61 записи (:method: GET — это один байт), динамической таблицей повторяющихся полей и кодированием Хаффмана. На типичном запросе с куками и длинным User-Agent это 800 байт → 30-50 байт на повторных запросах. Побочный эффект: динамическая таблица — общее состояние соединения, из-за чего атака CRIME/BREACH на сжатие потребовала запрета сжимать секреты вместе с пользовательским вводом.

Чем платим:

  • HOL blocking переехал в TCP. Потерянный сегмент останавливает доставку данных всех потоков, потому что ядро не отдаёт байты с дырой. На канале с 2% потерь HTTP/2 может быть медленнее, чем шесть отдельных соединений HTTP/1.1. Это и есть главная причина появления HTTP/3.
  • Поточное окно 64 КБ по умолчанию. SETTINGS_INITIAL_WINDOW_SIZE = 65535 байт на поток. На канале 100 Мбит/с с RTT 100 мс это ограничивает один поток примерно 5 Мбит/с независимо от TCP. Реальный симптом: «скачивание большого файла по h2 медленнее, чем по h1.1». Лечится увеличением окна на сервере (http2_body_preread_size и настройки h2 в nginx, InitialWindowSize в Go).
  • Server Push мёртв. Идея «отдать CSS до того, как браузер о нём спросил» на практике дублировала уже закэшированные ресурсы. Chrome выключил её в 2022 году; замена — 103 Early Hints с Link: </app.css>; rel=preload, где решение остаётся за браузером.
  • Приоритеты из RFC 7540 провалились — дерево зависимостей никто не реализовал одинаково. Заменены простой схемой RFC 9218 (Extensible Prioritization) с заголовком Priority: u=3, i.

Отдельный практический нюанс: шардинг доменов и склейка спрайтов при h2 вредны. Разные домены = разные соединения = потерянное мультиплексирование и повторные рукопожатия. Оптимизации 2012 года нужно откатывать, а не накапливать.

HTTP/3: тот же HTTP поверх QUIC

HTTP/3 — это HTTP/2 без TCP. Потоки, мультиплексирование и сжатие заголовков переехали в QUIC, который знает границы потоков и доставляет каждый независимо, поэтому потеря пакета тормозит только пострадавший поток. HPACK заменён на QPACK (RFC 9204): в HPACK ссылка на динамическую таблицу требовала строгого порядка, что при независимой доставке невозможно, поэтому QPACK разнёс инструкции таблицы по отдельным однонаправленным потокам.

Что получаем сверх этого: 1 RTT на установку соединения вместе с шифрованием (TLS 1.3 вшит в QUIC) и 0 RTT при возобновлении — но 0-RTT данные уязвимы к replay, поэтому туда разрешено класть только безопасные идемпотентные запросы. Миграция соединения по Connection ID означает, что переход Wi-Fi → LTE не рвёт загрузку.

Обнаружение — через заголовок в ответе по HTTP/1.1 или /2:

curl -sI https://cloudflare.com/ | grep -i alt-svc
alt-svc: h3=":443"; ma=86400

«Тот же хост доступен по h3 на 443/UDP, помни об этом сутки». Первый запрос всегда идёт по TCP; на h3 браузер переключается со второго. Проверить принудительно:

curl -sI --http3-only https://cloudflare.com/ -o /dev/null \
  -w 'версия=%{http_version} код=%{response_code} total=%{time_total}\n'
версия=3 код=200 total=0.121

Чем платим: UDP на 443 порту режут некоторые корпоративные фаерволы (нужен рабочий fallback на TCP, и он есть), CPU на пакет выше, чем у TCP с аппаратной разгрузкой на сетевой карте, а инструменты отладки моложе и грубее.

Сводка компромиссов

HTTP/1.1 HTTP/2 HTTP/3
Формат текст бинарные фреймы бинарные фреймы
Параллелизм ~6 соединений на хост потоки в одном TCP потоки в одном QUIC
Сжатие заголовков нет HPACK QPACK
HOL blocking на уровне запросов на уровне TCP практически нет
RTT до первого байта 1 (TCP) + 1-2 (TLS) + 1 то же 1 (QUIC+TLS) + 1, или 0-RTT
Шифрование опционально де-факто обязательно обязательно
Смена сети разрыв разрыв миграция без разрыва
Отладка тривиальная нужен keylog нужен keylog и свежие инструменты

Что видно в Wireshark и tcpdump

Открытый HTTP/1.1. Фильтр отображения http показывает разобранные запросы; правый клик → Follow → HTTP Stream склеивает обе стороны в читаемый диалог. Полезные фильтры: http.response.code >= 400, http.time > 1 (время от запроса до ответа, Wireshark считает его сам), http.request.uri contains "/api/".

Без GUI тот же результат даёт классический фильтр «только пакеты с полезной нагрузкой»:

# Отбрасываем чистые ACK: длина IP минус заголовки IP и TCP != 0
sudo tcpdump -ni any -A -s 0 \
  'tcp port 80 and (((ip[2:2] - ((ip[0]&0xf)<<2)) - ((tcp[12]&0xf0)>>2)) != 0)'
09:04:11.101233 IP 10.0.0.12.51234 > 93.184.216.34.80: Flags [P.], seq 1:78, length 77
E..u..@.@..............P....
GET / HTTP/1.1
Host: example.com
User-Agent: curl/8.5.0

Потеря пакета глазами Wireshark. TCP-анализ подсвечивает её сам: [TCP Dup ACK] (получатель повторяет подтверждение, видя дыру), затем [TCP Retransmission] или [TCP Fast Retransmission]. Признак «HTTP тормозит из-за сети, а не из-за бэкенда»: между последним фреймом HEADERS и первым DATA — секунды, заполненные Dup ACK. Полезные фильтры: tcp.analysis.retransmission, tcp.analysis.zero_window (получатель захлебнулся — виновато приложение, а не сеть), tcp.analysis.ack_rtt > 0.2. График Statistics → TCP Stream Graphs → Time Sequence (tcptrace) показывает лесенку передачи с провалами на ретрансмиссиях нагляднее любых цифр.

HTTP/2 и /3 внутри TLS. Без ключей это просто зашифрованные байты. Решение — переменная окружения, которую понимают браузеры и curl:

export SSLKEYLOGFILE=/tmp/tls-keys.log
curl -s --http2 https://example.com/ -o /dev/null
# Wireshark: Preferences → Protocols → TLS → (Pre)-Master-Secret log filename → /tmp/tls-keys.log

После этого фильтр http2 покажет фреймы, http2.type == 1 — только HEADERS, http2.streamid == 5 — конкретный поток, quic и http3 — то же для HTTP/3. Ключи логировать только на тестовых стендах: файл позволяет расшифровать весь трафик.

Быстрая альтернатива без дампов — тайминги curl, которые отвечают на вопрос «где именно ушло время»:

curl -sS -o /dev/null -w \
'dns=%{time_namelookup} tcp=%{time_connect} tls=%{time_appconnect} ttfb=%{time_starttransfer} total=%{time_total} ver=%{http_version} size=%{size_download}\n' \
  https://example.com/
dns=0.014 tcp=0.042 tls=0.089 ttfb=0.151 total=0.152 ver=2 size=1256

Читается по разностям: DNS 14 мс, TCP-рукопожатие 28 мс, TLS 47 мс, «сервер думал» 62 мс, скачивание 1 мс. Если ttfb - tls огромен — проблема в бэкенде, а не в сети. Если total - ttfb огромен — узкая полоса или медленный стриминг. Методика в целом — в диагностике сети.

Путь реального запроса

Из этой схемы следуют вещи, которые постоянно кусают в проде.

Версия HTTP разная на каждом участке. Браузер ↔ CDN может быть h3, CDN ↔ балансировщик — h2, балансировщик ↔ приложение — h1.1 по unix-сокету. Это нормально: семантика одна, транслировать легко. Но именно поэтому нельзя закладываться на «у нас HTTP/2 везде»: логика приложения обязана работать одинаково при любой версии.

X-Request-Id (или traceparent из W3C Trace Context) прокидывайте с самого края. Без сквозного идентификатора расследование инцидента превращается в сопоставление таймстемпов; см. наблюдаемость.

Заголовки диагностики кэша стоит логировать. X-Cache: HIT/MISS, Age, cf-cache-status, x-served-by — по ним видно долю попаданий по URL, а это самый дешёвый источник ускорения. Подробности раздачи — в CDN и edge.

Инвалидация — не часть HTTP. Протокол умеет только истечение и ревалидацию. PURGE — расширение конкретных реализаций (Varnish, nginx plus, API конкретного CDN). Поэтому иммутабельные URL с хешем в имени почти всегда лучше, чем любая схема активной инвалидации.

Мини-практика: клиент, который не выстрелит в ногу

import httpx  # httpx умеет HTTP/2 и переиспользует соединения

# Один клиент на процесс: пул соединений — главная оптимизация HTTP-клиента.
# Создание клиента на каждый запрос убивает keep-alive и стоит 2 RTT сверху.
client = httpx.Client(
    http2=True,
    timeout=httpx.Timeout(connect=2.0, read=10.0, write=10.0, pool=1.0),
    limits=httpx.Limits(max_connections=100, max_keepalive_connections=20),
    headers={"user-agent": "orders-sync/1.4"},
)

def fetch_catalog(etag: str | None) -> tuple[int, bytes | None, str | None]:
    """Условный GET: при отсутствии изменений сервер вернёт 304 без тела."""
    headers = {"if-none-match": etag} if etag else {}
    r = client.get("https://api.example.com/v1/catalog", headers=headers)
    if r.status_code == 304:
        return 304, None, etag              # тело не приехало — экономия трафика
    r.raise_for_status()
    return r.status_code, r.content, r.headers.get("etag")

def create_order(payload: dict, idem_key: str) -> httpx.Response:
    """POST не идемпотентен по протоколу — идемпотентность делаем ключом.
    Сервер обязан вернуть тот же ответ на повтор с тем же ключом."""
    return client.post(
        "https://api.example.com/v1/orders",
        json=payload,
        headers={"idempotency-key": idem_key},
    )

Четыре вещи, которые здесь сделаны намеренно: один клиент (пул), раздельные таймауты (connect короткий, read длинный — иначе медленный бэкенд выглядит как недоступная сеть), условный GET и ключ идемпотентности вместо надежды на ретраи. Аналог на Go — один http.Client с настроенным Transport и обязательным resp.Body.Close(), иначе соединение не вернётся в пул; на Node — undici с Agent.

Типичные ошибки

  • POST для чтения. Убивает кэширование, ломает ретраи и логи. Если данных для GET слишком много — это признак плохого API, а не повод менять метод.
  • Считать no-cache запретом на кэширование. Запрещает no-store; no-cache лишь требует ревалидации.
  • Забыть Vary. Самый дорогой баг в этой статье: персональный или локализованный ответ уезжает чужим пользователям через общий кэш.
  • Vary: User-Agent или Vary: *. Фрагментация кэша до нуля попаданий.
  • 200 OK с телом {"error": ...}. Ломает всю экосистему: прокси кэшируют ошибку, метрики показывают здоровье, клиентские библиотеки не бросают исключение.
  • Ретрай POST вслепую. Без ключа идемпотентности это двойное списание при первом же таймауте.
  • 301 вместо 302 на эксперименте. Кэшируется практически навсегда, откатить у уже зашедших пользователей нельзя.
  • Заголовки без Retry-After при 429 и 503. Клиенты уходят в плотный цикл ретраев и добивают сервис.
  • Доверять X-Forwarded-For целиком. Любой клиент подставит туда что угодно и обойдёт лимиты и гео-логику.
  • Одновременно Content-Length и Transfer-Encoding. Request smuggling; строгие парсеры на всех участках обязательны.
  • Создавать HTTP-клиент на каждый запрос. Потеря keep-alive: 2 лишних RTT и исчерпание эфемерных портов под нагрузкой.
  • Оставлять шардинг доменов и спрайты после перехода на h2/h3. Оптимизации HTTP/1.1 превращаются в антипаттерны.
  • Считать HTTP/2 автоматическим ускорением. Без исправленных поточных окон и при потерях он бывает медленнее 1.1; измеряйте, а не верьте.
  • Пропускать Connection, Upgrade, TE через прокси. Hop-by-hop заголовки обязаны срезаться, иначе ломается WebSocket и keep-alive.

Мини-итог

  • HTTP — протокол представлений ресурсов, спроектированный вокруг посредников; отсюда текстовость, богатые заголовки и подробная модель кэширования.
  • Метод — это контракт из трёх свойств: safe, idempotent, cacheable. На них опираются браузеры, прокси и ретраи, и нарушение контракта ломает не ваш код, а всю цепочку.
  • Код ответа — интерфейс для машин: 401 vs 403, 400 vs 422, 307/308 вместо 301/302 там, где нужно сохранить метод, Retry-After при 429 и 503.
  • Границы тела задаёт ровно один механизм — Content-Length или chunked; расхождение парсеров даёт request smuggling.
  • Кэширование — самая недооценённая часть протокола: s-maxage + stale-while-revalidate + ETag + правильный Vary дают больше, чем любая оптимизация кода. no-cache не запрещает хранение, запрещает no-store.
  • HTTP/2 убрал HOL blocking приложения, но не TCP; HTTP/3 убрал и его, переехав на QUIC, ценой UDP-фильтров и более дорогих пакетов.
  • Семантика одинакова во всех версиях — переход на h2/h3 это вопрос конфигурации фронта, а не переписывания кода.
  • Отладка: curl -w с таймингами отвечает «где ушло время», curl -v — «какие заголовки реально уехали», tcpdump и Wireshark с SSLKEYLOGFILE — «что именно было в проводе».

Источники

Что дальше

Всё, что мы разбирали, ехало открытым текстом или «внутри TLS», который мы аккуратно обходили стороной: openssl s_client с -servername, ALPN, выбирающий h2, SSLKEYLOGFILE для расшифровки дампа. Пора открыть этот чёрный ящик — тем более что без него сегодня не работают ни HTTP/2, ни HTTP/3.

TLS и HTTPS: рукопожатие, сертификаты, цепочки доверия, отладка — про то, из чего состоит рукопожатие и сколько RTT стоит, как устроены цепочки доверия и почему «сертификат валиден в браузере, но не в curl», и как читать вывод openssl и Wireshark, когда соединение падает.

Нашли неточность? Выделите фрагмент текста — рядом появится жучок.

Нужен разбор именно вашей ситуации?

Статья описывает общий случай. Если у вас частный — можно разобрать его отдельно, платно. А если не хватает целого материала, предложите тему: её оплачивают вскладчину, и она выходит открытой для всех.

Доска запросов