voidlearn
 

9.3HTTP и TLS

Разбери семантику HTTP методов и статусов, затем TLS и HTTP caching. Научись выбирать ответ по результату операции, правильно проверять TLS peer и учитывать, какие запросы могут быть сохранены посредниками.

Как HTTP выглядит на проводе

HTTP/1.1 гоняет поверх TCP обычный текст. Сообщение состоит из стартовой строки, набора заголовков вида Name: value, пустой строки и тела. Строки всегда разделяет CRLF (\r\n), а два CRLF подряд означают «заголовки кончились».

Байты HTTP-запроса по порядку GET /api/v1/users?limit=10 HTTP/1.1 стартовая строка: метод, request-target, версия Host: api.example.com обязателен в 1.1 - на одном IP много сайтов Authorization: Bearer eyJhbGciOi... заголовки, регистр имени не важен CRLF ПУСТАЯ строка: конец заголовков тело (у GET обычно отсутствует) длина - из Content-Length либо из chunked Тело неизвестной заранее длины: Transfer-Encoding: chunked 1a CRLF 26 байт данных + CRLF 10 CRLF 16 байт данных + CRLF 0 CRLF CRLF конец тела, дальше могут идти трейлеры Каждый кусок предваряется своей длиной в шестнадцатеричном виде - так стримят ответ, не зная его размера. Слать Content-Length и Transfer-Encoding одновременно нельзя: прокси и сервер разойдутся - это дыра request smuggling.
Анатомия сообщения. До пустой строки идут метаданные, после неё тело. Границу тела надо задать явно: TCP о границах сообщений ничего не знает.
$ printf 'GET /health HTTP/1.1\r\nHost: api.example.com\r\nAccept: application/json\r\n\r\n' | openssl s_client -quiet -connect api.example.com:443

HTTP/1.1 200 OK
Content-Type: application/json; charset=utf-8
Content-Length: 25
Cache-Control: no-store
ETag: "9a1f3c"
X-Request-Id: 01HN2Q...

{"status":"ok","db":"up"}

У ответа отличается только стартовая строка: версия код текст. Текст статуса («OK», «Not Found») нужен для красоты, его никто не разбирает, а в HTTP/2 его нет вовсе.

Content-Length против chunked

Получателю нужно понять, где кончается тело. Вариантов ровно три: Content-Length (длина известна заранее), Transfer-Encoding: chunked (длины никто не знает, поэтому каждый кусок несёт свою, а нулевой означает конец) или «до закрытия соединения» (HTTP/1.0, при этом keep-alive невозможен и обрыв не отличить от нормального конца). Go выбирает сам: если ты вызвал w.Write и всё влезло в буфер до Flush, выставится Content-Length; если пишешь потоком или зовёшь Flusher.Flush(), уйдёт chunked. Поставишь Content-Length явно, а запишешь другое число байт, и клиент повиснет или получит битый ответ.

Методы: безопасные и идемпотентные

Безопасный метод не меняет состояние на сервере, это «только чтение». Идемпотентный выдерживает повторы: тот же самый запрос, отправленный ещё раз, оставит сервер в том же состоянии, что и первый. Оба свойства остаются обещанием семантики, их никто не проверяет: можно написать GET, который удаляет заказ, но тогда сломаются кэши, прокси, краулеры и ретраи.

МетодБезопасныйИдемпотентныйКэшируемыйКомментарий
GETдададатело в запросе не предусмотрено семантикой
HEADдададакак GET, но только заголовки
OPTIONSдаданетCORS preflight
PUTнетданетполная замена ресурса: сколько раз ни положи одно и то же - состояние одно
DELETEнетданетвторой вызов вернёт 404, но состояние то же - «ресурса нет»
POSTнетнетусловнодва одинаковых POST = два заказа
PATCHнетнет*нетзависит от формата патча: set status=paid идемпотентен, increment counter - нет
Почему POST не идемпотентен и что с этим делают

POST означает «обработай это по своим правилам», и какой идентификатор создать, обычно решает сервер. Повтор создаёт новый ресурс - отсюда два заказа при двойном клике или ретрае после таймаута. Это не теория: любая сетевая ошибка неоднозначна - ты не знаешь, то ли запрос не дошёл, то ли дошёл, а потерялся ответ. Лечится ключом идемпотентности: клиент генерирует UUID и шлёт его в Idempotency-Key, сервер сохраняет пару (ключ → результат) и на повтор отдаёт тот же ответ, ничего не создавая. Так работают Stripe, YooKassa и все нормальные платёжные API. Или сделай операцию PUT с идентификатором, который придумал клиент.

Три любимых уточнения
  • «DELETE идемпотентен, но второй раз возвращает 404 - противоречие?» Нет: идемпотентность касается состояния сервера, а не кода ответа.
  • «Идемпотентный значит безопасный?» Нет: PUT и DELETE идемпотентны, но состояние меняют. Обратное верно: все безопасные методы идемпотентны.
  • «Почему нельзя тело в GET?» Спецификация его не запрещает жёстко, но семантики у него нет: кэши, прокси и половина клиентов его выбросят. Для сложных запросов делают POST на /search или кладут фильтр в query.

Статус-коды: не «какой существует», а «какой отдать»

ГруппаСмыслКлючевые
1xxинформационные, промежуточные100 Continue, 101 Switching Protocols (WebSocket), 103 Early Hints
2xxуспех200 OK, 201 Created (+ Location), 202 Accepted (приняли в асинхронную обработку), 204 No Content, 206 Partial Content
3xxперенаправление301 Moved Permanently, 302 Found, 304 Not Modified, 307/308 (метод сохраняется)
4xxвиноват клиент, повтор без изменений не поможет400, 401, 403, 404, 405, 409, 410, 415, 422, 429
5xxвиноват сервер, повтор может помочь500, 501, 502, 503, 504
Пара, которую путаютКак выбрать
401 vs 403401 - «я не знаю, кто ты»: токена нет, он протух или подпись невалидна. Обязателен заголовок WWW-Authenticate. Смысл: перелогинься и приходи снова. 403 - «я знаю, кто ты, и тебе нельзя»: аутентификация прошла, не хватает прав. Повтор с тем же токеном бесполезен.
400 vs 422400 - сообщение вообще не разобрать: битый JSON, нет обязательного поля, не тот тип. 422 Unprocessable Entity - синтаксис корректен, но семантика невалидна: дата окончания раньше даты начала, отрицательная сумма, несуществующий статус. Многие API живут только на 400 - это допустимо, но 422 точнее.
404 vs 403 vs 410404 - нет ресурса (или мы не хотим раскрывать, что он есть - часто отдают вместо 403 для приватных объектов). 410 Gone - был и удалён навсегда, поисковикам сигнал выкинуть из индекса.
409 ConflictСостояние ресурса не позволяет выполнить операцию: дубликат уникального поля, конфликт версий при оптимистичной блокировке (If-Match не совпал - тогда точнее 412 Precondition Failed), попытка отменить уже отгруженный заказ.
429 Too Many RequestsRate limit. Обязательно отдавай Retry-After (секунды или дата) и по возможности X-RateLimit-Remaining/Reset - иначе клиент будет долбиться в цикле.
502 vs 503 vs 504502 Bad Gateway - прокси сходил на апстрим и получил мусор или разрыв (апстрим упал, отдал битый ответ). 503 Service Unavailable - сервис сам говорит «я сейчас не могу»: перегрузка, деплой, circuit breaker открыт; сюда же Retry-After. 504 Gateway Timeout - апстрим не ответил за отведённое время. Разница важна при разборе инцидента: 502 - «упал бэкенд», 504 - «бэкенд тормозит».
301 vs 302 vs 307/308301/308 - навсегда (301 кэшируется браузером агрессивно, откатить тяжело), 302/307 - временно. Ключевое отличие: 301 и 302 исторически позволяли клиенту сменить POST на GET, а 307 и 308 обязаны сохранить метод и тело. Для API всегда бери 307/308.
Правило для 4xx и 5xx на ретраях

Клиентская библиотека должна ретраить только идемпотентные запросы и только на 5xx, 429 и сетевых ошибках, причём с экспоненциальной задержкой и джиттером. Ретрай POST без ключа идемпотентности на 504 плодит дубли: таймаут не означает, что сервер ничего не сделал. И никогда не прячь ошибки за 200 с полем "error" в теле: сломаются кэши, ретраи, алерты и метрики по кодам.

Заголовки, которые надо знать наизусть

ЗаголовокЗачем
Hostобязателен в 1.1: виртуальный хостинг, на одном IP много доменов. В HTTP/2 - псевдозаголовок :authority
Content-Typeтип тела: application/json, application/x-www-form-urlencoded, multipart/form-data, application/grpc+proto. Плюс charset
Accept, Accept-Encoding, Accept-Languageсогласование содержимого. На них завязан Vary
AuthorizationBearer <jwt>, Basic base64(user:pass). В логи не писать никогда
Cache-Control, ETag, Last-Modified, Varyкэширование - отдельный раздел ниже
X-Request-Id / traceparentсквозная трассировка: генерируется на входе, прокидывается во все внутренние вызовы и пишется в каждый лог. traceparent - стандарт W3C Trace Context, его понимает OpenTelemetry
X-Forwarded-For / Forwardedреальный IP клиента за прокси. Доверять можно только своему прокси - клиент может подделать заголовок
Retry-Afterк 429 и 503: когда приходить снова
Locationк 3xx и к 201 Created - адрес созданного ресурса
Connection, Upgradeуправление соединением; Upgrade: websocket - переход на WebSocket
Set-Cookie / Cookieсостояние на клиенте; см. ниже
Set-Cookie: session=abc123; Path=/; Max-Age=86400; Secure; HttpOnly; SameSite=Lax

Secure     - только по HTTPS
HttpOnly   - недоступна из JavaScript: главная защита от кражи через XSS
SameSite   - Strict (не отправляется вообще при переходе с чужого сайта),
             Lax (дефолт в Chrome: отправляется при обычной навигации GET),
             None (отправляется всегда, ОБЯЗАТЕЛЬНО вместе с Secure) - защита от CSRF
Domain     - расширяет область на поддомены, по умолчанию только текущий хост

Из всех механизмов только куки браузер прикрепляет к запросу автоматически, и именно поэтому существует CSRF. У заголовка Authorization такой проблемы нет, его проставляют явно, зато токен в localStorage уязвим к XSS. Отсюда типовой выбор: браузерным приложениям подходит HttpOnly-кука с SameSite, мобильным и межсервисным - Authorization: Bearer.

HTTP/1.1 против HTTP/2 против HTTP/3

Три поколения: как по соединению идут запросы HTTP/1.1 одно соединение R1 ответ 1 R2 ответ 2 R3 ответ 3 HOL прикладного уровня: ответы строго по порядку. Обход - до 6 параллельных соединений на домен. HTTP/2 одно TCP, бинарные фреймы s1 s3 s5 s1 s3 s5 потеря все потоки ждут ретрансмиссию Потоки независимы для HTTP, но лежат в одном TCP: потеря одного пакета останавливает ВСЕ потоки. HTTP/3 QUIC поверх UDP s1 s3 s5 s1 потеря s3 s5 s1 s3 ждёт s5 остальные потоки едут дальше Нумерация и ретрансмиссии - отдельно по каждому потоку, поэтому TCP-HOL исчезает как класс. 1.1: текст, заголовки открытым текстом в каждом запросе, keep-alive есть, конвейеризация мертва. 2: бинарные фреймы, потоки, HPACK, приоритеты, server push (отменён). 3: QUIC = UDP + TLS 1.3, 0-RTT, миграция по Connection ID.
Эволюция по одной причине. Каждая версия чинит блокировку предыдущей: HTTP/2 убрал её на прикладном уровне, HTTP/3 - на транспортном.

HTTP/1.1

Текстовый, в соединении один запрос за раз. Версия дала Host (виртуальный хостинг), постоянные соединения по умолчанию, chunked, кэширование, Range-запросы. Конвейеризация разрешала слать следующий запрос, не дожидаясь ответа, но ответы всё равно обязаны были идти в порядке запросов, и медленный первый блокировал всех. Вдобавок кривые прокси теряли или переставляли ответы. В итоге её выключили во всех браузерах, а параллелизма добивались шестью соединениями на домен и доменным шардингом.

HTTP/2

  • Бинарный фрейминг. Сообщение разбито на фреймы (HEADERS, DATA, SETTINGS, WINDOW_UPDATE, RST_STREAM, PING, GOAWAY). Парсинг однозначный, без эвристик по пробелам.
  • Потоки и мультиплексирование. Внутри одного TCP-соединения живут независимые потоки со своими идентификаторами; фреймы разных потоков идут вперемешку. Отвечать можно в любом порядке - HOL прикладного уровня исчез.
  • HPACK. Сжимает заголовки: статическая таблица частых пар, динамическая таблица «уже виденных» плюс Хаффман. Повторные запросы к тому же хосту шлют заголовки почти бесплатно, а раньше с каждым запросом улетали одни и те же куки на килобайт.
  • Управление потоком на уровне потоков. У каждого stream своё окно, отдельно от TCP.
  • Server push. Сервер мог сам отправить ресурсы, которые клиент ещё не просил. Идея не взлетела: сервер не знает, что уже лежит в кэше браузера, и в среднем гнал трафик впустую. Браузеры его выключили (Chrome - в 2022 году), хотя в RFC 9113 он формально остался. На смену пришёл 103 Early Hints с Link: rel=preload: сервер подсказывает, а решает клиент.

HTTP/3 и QUIC

  • Транспорт поверх UDP, а всю надёжность QUIC реализует сам, в пользовательском пространстве. Почему UDP: новый транспортный протокол в интернет не протащить, промежуточные узлы (NAT, файрволы) пропускают практически только TCP и UDP.
  • Нет TCP-HOL: порядок, подтверждения и ретрансмиссии ведутся отдельно по каждому потоку.
  • TLS 1.3 встроен в рукопожатие. Транспортное и криптографическое рукопожатия слиты в одно: 1 RTT на первое соединение и 0 RTT при возобновлении. Открытым почти ничего не идёт, скрыты даже номера пакетов.
  • Миграция соединения. Соединение опознаётся не по пятёрке, а по Connection ID, поэтому переход с Wi-Fi на LTE не рвёт сессию: адрес поменялся, соединение то же.
  • QPACK вместо HPACK: сжимает заголовки так же, но не ломается, когда фреймы приходят не по порядку.
  • Минусы тоже есть. UDP местами режут корпоративные файрволы; CPU нагружается сильнее, потому что стек в user space (хотя есть offload); отлаживать сложнее, tcpdump покажет только шифрованный UDP.
Как клиент узнаёт, какую версию использовать

Про HTTP/2 договариваются через ALPN в TLS-рукопожатии: клиент присылает список h2, http/1.1, сервер выбирает. Лишнего круга нет. С HTTP/3 иначе: браузер сначала ходит по TCP+TLS, а сервер в ответе отдаёт Alt-Svc: h3=":443"; ma=86400, то есть «я умею h3, приходи по нему в следующий раз». Узнать про h3 ещё до первого соединения позволяет HTTPS-запись в DNS (type65). В Go net/http сервер поднимает HTTP/2 сам, если ты используешь TLS и не трогал TLSNextProto, а клиент - если не задавал свой TLSClientConfig или Dial (иначе нужен ForceAttemptHTTP2); HTTP/3 в стандартной библиотеке нет, берут quic-go.

TLS: что происходит в рукопожатии

TLS 1.2: два RTT до данных TLS 1.3: один RTT клиент сервер ClientHello версии, шифры, random, SNI, ALPN ServerHello, Certificate, ServerKeyExchange, HelloDone ClientKeyExchange, ChangeCipherSpec, Finished ChangeCipherSpec, Finished только теперь: HTTP-запрос 2 RTT клиент сервер ClientHello + key_share догадка о группе ECDHE, SNI, ALPN ServerHello + key_share, {Certificate, CertVerify, Finished} фигурные скобки = уже зашифровано {Finished} + HTTP-запрос 1 RTT при возобновлении по билету - 0 RTT Асимметрика (подпись RSA/ECDSA, обмен ECDHE) нужна только чтобы доказать личность сервера и выработать общий секрет. Дальше весь трафик шифруется симметрично: AES-GCM или ChaCha20-Poly1305 - на порядки быстрее асимметрики. TLS 1.3 выкинул RSA key transport, статический DH, CBC, сжатие и слабые наборы: осталось 5 шифронаборов, все AEAD, обмен ключами - эфемерный. SNI в ClientHello идёт открытым текстом (скрывает только ECH), там же ALPN выбирает h2 / http/1.1.
Один круг вместо двух. TLS 1.3 экономит RTT: клиент угадывает группу ECDHE (в Go с 1.24 по умолчанию - гибридную постквантовую X25519MLKEM768) и присылает свою долю ключа уже в первом сообщении.

Что даёт TLS и как

TLS даёт три свойства: конфиденциальность (по дороге никто не прочитает), целостность (никто не подменит: AEAD-шифры сразу и шифруют, и аутентифицируют) и аутентификацию сервера (ты действительно говоришь с тем доменом). Чего TLS не даёт: он не скрывает, к какому домену ты подключаешься (SNI открыт), не прячет размеры и тайминги трафика и не защищает от дырявого приложения на том конце.

Симметрика и асимметрика работают вместе, потому что асимметричная криптография в тысячи раз медленнее. Асимметрика нужна ровно дважды: сервер подписывает рукопожатие своим приватным ключом (так он доказывает, что владеет сертификатом), а стороны выполняют обмен ECDHE и получают общий секрет, который по проводу не передаётся. Из него выводятся симметричные ключи, и дальше данные шифруются AES-GCM или ChaCha20-Poly1305. Forward secrecy: ключи ECDHE эфемерные, поэтому даже если приватный ключ сервера утечёт завтра, записанный сегодня трафик им не расшифровать.

Сертификаты и цепочка доверия

Сертификат X.509 связывает доменное имя с публичным ключом и подписан удостоверяющим центром. Проверяют его по цепочке: листовой сертификат сервера подписан промежуточным, промежуточный - корневым, а корневой лежит в доверенном хранилище ОС или браузера. Клиент смотрит подписи по всей цепочке, срок действия каждого, соответствие имени полю SAN (Common Name как имя не используется с 2017 года), назначение ключа и отзыв (CRL или OCSP; Let's Encrypt в 2025 году выключил OCSP, и отзыв у него снова идёт через CRL).

Самая частая продовая ошибка с сертификатами

Сервер отдаёт только листовой сертификат и забывает промежуточный. В браузере всё работает (он кэширует промежуточные с других сайтов или дотягивает их по AIA), а curl из контейнера и Go-клиент падают с x509: certificate signed by unknown authority. Ловится командой openssl s_client -connect host:443 -showcerts: смотри, какой длины цепочка. Вторая по частоте ошибка: x509: certificate is valid for X, not Y. Имя в SAN не совпало, обычно из-за обращения по IP или по внутреннему DNS-имени.

// Клиент с проверкой и минимальной версией
tr := &http.Transport{
    TLSClientConfig: &tls.Config{
        MinVersion: tls.VersionTLS12,    // ниже 1.2 не соглашаемся
        // InsecureSkipVerify: true пиши только в тестах, в проде он снимает всю защиту
    },
    ForceAttemptHTTP2: true, // со своим TLSClientConfig HTTP/2 сам не включится
}

// Взаимный TLS: сервер требует сертификат и от клиента
srv := &http.Server{
    Addr: ":8443",
    TLSConfig: &tls.Config{
        ClientAuth: tls.RequireAndVerifyClientCert,
        ClientCAs:  clientCAPool, // CA, которыми подписаны сертификаты клиентов
        MinVersion: tls.VersionTLS12,
    },
}

SNI, ALPN, mTLS, возобновление сессии

  • SNI: клиент открытым текстом называет в ClientHello нужный домен. Без этого сервер не знает, какой сертификат предъявить, когда на одном IP висят сотни доменов (весь shared-хостинг и CDN). Побочный эффект: по SNI провайдер видит, куда ты идёшь. Закрывает эту дыру расширение ECH.
  • ALPN позволяет договориться о прикладном протоколе прямо в рукопожатии: h2, http/1.1, h3. Поэтому HTTP/2 не стоит ни одного лишнего RTT.
  • mTLS: сертификат предъявляет и сервер, и клиент, а сервер клиентский проверяет. Применяют для межсервисных вызовов внутри периметра (Istio/Linkerd делают это прозрачно), доступа к Kubernetes API, банковских и платёжных интеграций, IoT-устройств, доступа к внутренним админкам вместо VPN (BeyondCorp). Плюс: аутентификация без паролей и токенов. Минус: свой PKI, а с ним ротация и отзыв.
  • Возобновление сессии. В TLS 1.2 это session ID (состояние на сервере) или session ticket (состояние зашифровано и хранится у клиента). В TLS 1.3 это PSK-билет, дающий 0-RTT: данные едут в самом первом пакете. Беда 0-RTT в replay: перехваченный ранний запрос можно повторить, поэтому в 0-RTT кладут только идемпотентные запросы (GET) и никогда POST.

Кэширование HTTP

Что делает кэш, когда приложение просит ресурс запрос ресурса из приложения запись в кэше есть и ещё свежая? (max-age / Expires) да отдаём из кэша, запроса в сеть НЕТ (from disk cache) нет условный запрос на сервер If-None-Match: "9a1f3c" If-Modified-Since: ... 304 Not Modified тела нет, свежесть продлена 200 OK новое тело и новый ETag no-store - не сохранять вообще. no-cache - сохранять можно, но КАЖДЫЙ раз перепроверять условным запросом. must-revalidate - протухшее нельзя отдавать даже при недоступном сервере. immutable - не перепроверять никогда.
Два режима. Свежий ответ отдаётся вообще без сети. Протухший не выбрасывается, а перепроверяется условным запросом, и 304 обходится в ноль байт тела.
Директива Cache-ControlЧто значит
max-age=3600ответ считается свежим час с момента получения
s-maxage=60то же, но только для общих кэшей (CDN, прокси); перебивает max-age
public / privateможно ли класть в общий кэш. Ответы с Authorization по умолчанию не кэшируются общими кэшами - public это переопределяет (осторожно!)
no-cacheне «не кэшировать»: сохранять можно, но перед каждой отдачей обязательна ревалидация условным запросом
no-storeвот это «не кэшировать вообще»: не писать ни на диск, ни в память. Для персональных данных, банковских выписок, страниц с токенами
must-revalidateпосле истечения свежести отдавать старое запрещено, даже если сервер недоступен - лучше ошибка, чем устаревшие данные
immutableсодержимое не изменится никогда: не перепроверять даже при F5. Для файлов с хэшем в имени (app.7f3a1c.js)
stale-while-revalidate=30отдать протухшее сразу, а свежее подтянуть фоном - заметно улучшает воспринимаемую скорость

В ETag лежит версия содержимого, обычно хэш. Клиент присылает If-None-Match: "9a1f3c", сервер сравнивает и отвечает либо 304 Not Modified вообще без тела, либо 200 с новым содержимым. Бывает сильный (побайтовое совпадение) и слабый (W/"...", «семантически то же»). Last-Modified с If-Modified-Since делают то же самое по времени, с точностью до секунды, и два изменения в одну секунду не различат. Если есть оба, приоритет у ETag.

func handler(w http.ResponseWriter, r *http.Request) {
    body := render()
    etag := `"` + fmt.Sprintf("%x", sha256.Sum256(body))[:16] + `"`
    w.Header().Set("ETag", etag)
    w.Header().Set("Cache-Control", "private, max-age=60, must-revalidate")
    w.Header().Set("Vary", "Accept-Encoding, Authorization") // иначе отдадим чужой кэш

    if match := r.Header.Get("If-None-Match"); match == etag {
        w.WriteHeader(http.StatusNotModified) // 304, тело не пишем
        return
    }
    w.Write(body)
}
Про заголовок Vary забывают, и это дорого

Кэш ключуется по URL. Если ответ зависит ещё от чего-то (сжатия, языка, пользователя), перечисли эти заголовки в Vary, иначе общий кэш отдаст одному пользователю ответ, сгенерированный для другого. Самая опасная версия бага: персональный ответ без Cache-Control: private оседает на CDN и раздаётся всем. Правило: любой ответ, который зависит от Authorization или куки, помечай private (а лучше no-store) и всегда ставь Vary.

Вопросы

8
Суть: в HTTP/1.1 это обычный текст: стартовая строка, потом заголовки по одному в строке, потом пустая строка (CRLF CRLF), потом тело. Границу тела задаёт либо Content-Length, либо Transfer-Encoding: chunked. В HTTP/2 и HTTP/3 та же семантика, но это бинарные фреймы и сжатые заголовки.

Запрос

POST /api/v1/orders?dry=1 HTTP/1.1     <- стартовая строка: метод, target, версия
Host: api.example.com                  <- обязателен в 1.1, иначе 400
Content-Type: application/json
Content-Length: 21
Authorization: Bearer eyJhbGciOi...
X-Request-Id: 0f1c-88ad-...
                                       <- ПУСТАЯ строка = конец заголовков
{"sku":"A-1","qty":2}

Строки разделяются строго \r\n (CRLF), а не \n. Регистр имён заголовков не важен (content-type == Content-Type), и в Go http.Header приводит имя к канонической форме (X-Request-Id). Это аукнется, если лезешь руками в map[string][]string: ключ "x-request-id" там не найдётся, нужен Header.Get.

Ответ

HTTP/1.1 201 Created                   <- версия, код, reason phrase (в HTTP/2 её нет вообще)
Content-Type: application/json; charset=utf-8
Location: /api/v1/orders/42
Transfer-Encoding: chunked

18
{"id":42,"status":"new"}
0
                                       <- нулевой чанк = конец тела

Где кончается тело - три варианта

  • Content-Length: N велит прочитать ровно N байт. Длину надо знать заранее, то есть сначала собрать весь ответ.
  • Transfer-Encoding: chunked режет тело на куски «длина в hex, CRLF, данные», а конец отмечает чанк нулевой длины. Нужен для стриминга, когда длина неизвестна: логи, SSE, выгрузка большой таблицы. В Go включается сам, если ты не поставил Content-Length и данных больше буфера.
  • Закрытие соединения - легаси из HTTP/1.0: тело кончается там, где кончился TCP. Несовместимо с keep-alive.
На что ловят

«А что если прислать и Content-Length, и Transfer-Encoding?» Это классика request smuggling: фронт-прокси верит одному заголовку, бэкенд другому, и в поток бэкенда «протаскивается» лишний запрос, который приклеивается к следующему пользователю. По RFC при обоих заголовках побеждает Transfer-Encoding, а Content-Length надо удалить; безопасные реализации просто отвечают 400. Go-сервер поступает по RFC: удаляет Content-Length и читает тело как chunked, а на несколько заголовков Transfer-Encoding или значение не chunked отвечает 501 Not Implemented.

Чем добить

В HTTP/2 стартовой строки нет: метод, схема, авторитет и путь стали псевдозаголовками :method, :scheme, :authority, :path, а у ответа есть :status. Reason phrase («Not Found» после 404) выбросили за ненадобностью. Имена заголовков в HTTP/2 пишутся только в нижнем регистре, прописная буква считается протокольной ошибкой.

Суть: безопасный - не меняет состояние на сервере (GET, HEAD, OPTIONS). Идемпотентный - N одинаковых запросов дают то же состояние, что и один (GET, HEAD, PUT, DELETE, OPTIONS). POST и PATCH не идемпотентны. Всё безопасное автоматически идемпотентно, обратное неверно.
МетодБезопасныйИдемпотентныйКэшируемыйТело
GETдададанет (не должно быть)
HEADдададанет
OPTIONSдаданетредко
PUTнетданетда
DELETEнетданетобычно нет
POSTнетнетпочти нетда
PATCHнетнет (зависит от формата патча)нетда

Почему PUT идемпотентен, а POST нет

PUT /orders/42 означает «пусть по этому адресу лежит вот такое представление». Повтори хоть десять раз, состояние останется тем же. POST /orders означает «обработай вот эти данные», и адрес сервер выбирает сам, поэтому десять повторов дают десять заказов. Вот почему клиенты и прокси вправе автоматически ретраить GET/PUT/DELETE при сетевой ошибке, а POST нет.

Идемпотентность говорит о состоянии, а не об ответе

Первый DELETE /orders/42 вернёт 204, второй - 404. Коды разные, но состояние после обоих одинаковое: заказа нет. Это идемпотентно. Не путай с этим POST, который «случайно» ничего не сломал: идемпотентность сервер обещает по контракту метода, и неважно, что вышло в конкретный раз.

Как сделать POST идемпотентным на практике

Через ключ идемпотентности: клиент генерирует UUID и шлёт его в заголовке Idempotency-Key. Сервер в транзакции пытается вставить этот ключ в таблицу с уникальным индексом. Если вставка прошла, он выполняет операцию и сохраняет рядом результат; если ключ уже есть, возвращает сохранённый ответ и ничего не выполняет. Так делают Stripe, YooKassa и все платёжные API. Ключ живёт с TTL (сутки-неделя). Тонкое место: состояние «ключ вставлен, но операция ещё выполняется». Второй запрос в этот момент должен получить 409 или подождать, а не начать вторую операцию.

Классический подвох

«GET безопасен» - значит, GET-ручку можно дёргать сколько угодно. А теперь вспомни ручку GET /api/logout или GET /admin/users/5/delete. Такое сносил краулер поисковика и префетчер браузера. Правило: если метод меняет состояние, это не GET.

Суть: 1xx - информационные, 2xx - успех, 3xx - «ищи в другом месте или у тебя уже есть», 4xx - виноват клиент (повтор без изменений не поможет), 5xx - виноват сервер (повтор может помочь). Граница 4xx/5xx - это граница «кого будить ночью».

2xx

  • 200 OK - успех с телом.
  • 201 Created: ресурс создан, обычно с заголовком Location на его адрес.
  • 202 Accepted: приняли в обработку, результата ещё нет. Правильный код для асинхронных операций, к нему отдаём Location на статус-ресурс.
  • 204 No Content - успех без тела (удаление, PUT без возврата). Тело писать нельзя вообще.
  • 206 Partial Content отвечает на Range: докачка и стриминг видео.

3xx

  • 301 Moved Permanently переносит навсегда, браузеры кэшируют его агрессивно и надолго. Ошибёшься - пользователи месяцами будут ходить на старый адрес, пока не почистят кэш.
  • 302 Found / 307 Temporary Redirect переносят временно. Разница в том, что при 301/302 браузеры исторически меняли POST на GET, а 307 и 308 обязаны сохранять метод и тело. Нужен предсказуемый редирект - бери 307/308.
  • Go 1.26 переехал на 307 в стандартном роутере. Раньше, когда net/http.ServeMux сам перекидывал /x на зарегистрированный /x/, он отдавал 301. Это была мина: 301 браузер кэширует практически навсегда, и после переделки роутов пользователь всё равно шёл по старому адресу, пока не почистит кэш, а POST по дороге превращался в GET. С Go 1.26 здесь уходит 307: он не кэшируется и сохраняет метод с телом. Хорошая иллюстрация того, почему «навсегда» в вебе стоит дороже всего.
  • 303 See Other говорит «сходи туда методом GET»: канонический ответ на POST по паттерну Post/Redirect/Get, чтобы F5 не отправил форму дважды.
  • 304 Not Modified - ответ на условный запрос: тела нет, бери из кэша.

4xx - где ошибаются чаще всего

  • 400 Bad Request - запрос синтаксически сломан: не распарсился JSON, не тот тип поля.
  • 401 Unauthorized на самом деле значит «не аутентифицирован»: токена нет, он протух или подпись не сошлась. Обязан идти с WWW-Authenticate. Клиенту есть смысл обновить токен и повторить.
  • 403 Forbidden: «кто ты, понятно, но тебе нельзя». Повтор с тем же токеном бессмыслен. Путаница 401/403 остаётся самым частым ляпом в API.
  • 404 Not Found: ресурса нет. Иногда его намеренно отдают вместо 403, чтобы не подтверждать чужому пользователю, что объект существует.
  • 405 Method Not Allowed - путь есть, метод не тот; обязателен Allow.
  • 409 Conflict означает конфликт с текущим состоянием: оптимистическая блокировка не сошлась, дубликат по уникальному ключу, попытка повторно оплатить оплаченный заказ.
  • 412 Precondition Failed приходит, когда не выполнилось условие If-Match: ресурс изменили между чтением и записью.
  • 422 Unprocessable Content: JSON валидный и разобрался, но бизнес-валидация не прошла («qty должен быть больше нуля»). Разница с 400: синтаксис против семантики. Это соглашение, а не жёсткое требование, но команды его любят.
  • 429 Too Many Requests - рейт-лимит. Отдавай его с Retry-After, иначе клиенты начнут долбить сильнее и устроят тебе метастабильный отказ.

5xx

  • 500 Internal Server Error: «мы сломались», в 99% случаев из-за необработанной паники или неожиданной ошибки. Деталей клиенту наружу не отдаём.
  • 502 Bad Gateway отдаёт прокси, который сходил на апстрим и получил мусор или разрыв. Смотри бэкенд.
  • 503 Service Unavailable - временно не можем: перегрузка, деплой, отключён брейкером. Вместе с ним правильно отдавать Retry-After.
  • 504 Gateway Timeout: прокси не дождался апстрима. Классика: у nginx таймаут 60 с, а запрос идёт 90 с.
Правило выбора кода

Задай два вопроса. Первый: «поможет ли клиенту повторить запрос без изменений?» Да - 5xx или 429, нет - 4xx. Второй: «кто должен чинить?» Клиент - 4xx, мы - 5xx. Отсюда и правило: не отдавай 200 с {"error": ...} в теле. Такой ответ ломает мониторинг, ретраи, брейкеры и кэш, ведь вся инфраструктура смотрит на код.

Суть: Content-Type - как читать тело; Accept - что клиент готов принять; Authorization - кто спрашивает; Cache-Control и ETag - можно ли не ходить в сеть; X-Request-Id - сквозная нить в логах; куки - состояние, которое браузер сам таскает за тобой.

Content-Type и Accept

Content-Type: application/json; charset=utf-8 описывает текущее тело, Accept: application/json говорит, что клиент хочет в ответ (content negotiation). Не умеет сервер отдать запрошенный тип, отвечает 406; не понимает присланный - 415 Unsupported Media Type. На практике Content-Type из запроса решает, парсить форму или JSON, а без него браузер может начать MIME sniffing: угадает тип по содержимому и превратит загруженную картинку в исполняемый HTML. Лечится X-Content-Type-Options: nosniff.

Authorization

Схемы: Basic base64(user:pass) (только поверх TLS, пароль летит фактически открытым), Bearer <token> (JWT или opaque-токен, стандарт для API), Digest (легаси). Инфраструктуру он тоже задевает: с Authorization в запросе общим кэшам по умолчанию запрещено кэшировать ответ. И целиком этот заголовок в логи не пишут, только префикс или хэш.

X-Request-Id и трассировка

Балансировщик или самый первый сервис генерирует идентификатор запроса, кладёт в X-Request-Id и пишет в каждую строку лога. Дальше он прокидывается во все исходящие вызовы, в сообщения в брокер, в атрибуты спанов. Так по одной строке из тикета пользователя собирают весь путь запроса через десяток сервисов. Стандартом сейчас стал traceparent из W3C Trace Context (OpenTelemetry), но X-Request-Id живёт практически везде.

// Middleware: взять входящий id или создать свой, положить в контекст
func RequestID(next http.Handler) http.Handler {
    return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
        id := r.Header.Get("X-Request-Id")
        if id == "" {
            id = uuid.New().String()   // Go 1.27: uuid уже в стандартной библиотеке
        }
        w.Header().Set("X-Request-Id", id)                 // вернём клиенту
        ctx := context.WithValue(r.Context(), ctxKeyReqID, id)
        next.ServeHTTP(w, r.WithContext(ctx))
    })
}
Go 1.27: за UUID больше не надо ходить в сторонний модуль

Годами uuid в примере означал github.com/google/uuid. С Go 1.27 пакет uuid есть в стандартной библиотеке (RFC 9562): uuid.New() даёт случайный (синоним uuid.NewV4()), uuid.NewV7() - сортируемый по времени (в первых 48 битах миллисекундная метка, поэтому такие ключи ложатся в конец B-tree-индекса, а не разбрасываются по нему, как v4), плюс uuid.Parse, uuid.MustParse, uuid.Nil(), uuid.Max(). Тип UUID [16]byte сравнивается через ==, умеет String(), Compare и MarshalText/UnmarshalText (значит, и в JSON, и в ключах map). Отдельного NewString() нет, пишут uuid.New().String().

Куки

Сервер ставит Set-Cookie: sid=abc; HttpOnly; Secure; SameSite=Lax; Path=/; Max-Age=86400, браузер потом сам шлёт Cookie: sid=abc на каждый подходящий запрос. Атрибуты:

  • HttpOnly: JS не видит куку через document.cookie, и при XSS сессию не украсть.
  • Secure - отправляется только по HTTPS.
  • SameSite бывает Strict (вообще не отправлять при переходах с чужих сайтов), Lax (отправлять при обычной навигации по ссылке, но не при POST/iframe/img; дефолт в Chrome) и None (отправлять всегда, обязателен Secure). Это главная встроенная защита от CSRF.
  • Domain / Path - область видимости; Max-Age / Expires - срок жизни, без них кука сессионная и живёт до закрытия браузера.
Куки против Bearer-токена

Куку браузер прикрепляет автоматически - отсюда CSRF и нужда в SameSite и CSRF-токенах. Заголовок Authorization ставят руками, поэтому CSRF ему не грозит, зато токен приходится где-то хранить, и localStorage для этого плохое место: любой XSS его прочитает. В SPA обычно держат refresh-токен в HttpOnly-куке, а access-токен в памяти JS.

Суть: в HTTP/1.1 одно соединение - одна очередь запросов, поэтому браузер открывает 6 соединений на домен. HTTP/2 - бинарный фрейминг: много логических потоков в одном TCP-соединении, заголовки жмутся HPACK. Но HOL-блокировка не исчезла, а переехала на уровень TCP: потеря одного сегмента тормозит все потоки сразу.

Что было в 1.1

Keep-alive (дефолт с 1.1) оставляет TCP открытым после ответа, чтобы заново не платить за рукопожатие и медленный старт. Pipelining разрешает слать несколько запросов, не дожидаясь ответов, но отвечать сервер обязан строго по порядку. Поэтому один медленный запрос запирает все следующие: это и есть HOL-блокировка прикладного уровня. Вдобавок кривые прокси ломали порядок, и браузеры конвейеризацию отключили - на практике её нет. Обходились «шардированием доменов» (img1.site.com, img2.site.com), склейкой CSS/JS и спрайтами. С HTTP/2 эти костыли стали вредны.

Что принёс HTTP/2

  • Бинарный фрейминг. Вместо текста идут фреймы фиксированной структуры: HEADERS, DATA, SETTINGS, WINDOW_UPDATE, RST_STREAM, GOAWAY, PING. Парсится однозначно и быстро.
  • Мультиплексирование. У каждого запроса свой stream id, фреймы разных потоков чередуются в одном соединении и собираются на той стороне. Сотне параллельных запросов хватает одного TCP-соединения и одного TLS-хендшейка.
  • HPACK. Сжимает заголовки статической таблицей (61 частый заголовок), динамической таблицей на соединение и кодом Хаффмана. Повторные Cookie и User-Agent схлопываются до пары байт, а в 1.1 они летели целиком в каждом запросе.
  • Приоритеты и управление потоком на уровне отдельных стримов (WINDOW_UPDATE), Server Push (оказался неудачным и де-факто мёртв).
Главная формулировка про HOL

HTTP/2 убрал HOL-блокировку на прикладном уровне, но добавил зависимость от одного TCP-соединения. TCP обязан отдавать байты приложению строго по порядку: если потерялся один сегмент, ядро держит в буфере всё, что пришло после него, и встают все стримы, даже те, чьи данные уже пришли. В HTTP/1.1 с шестью соединениями потеря била только по одному из них. Поэтому на плохой сети (мобильный интернет, потери 2–5%) HTTP/2 иногда проигрывает HTTP/1.1. Это и чинит QUIC: он уносит нумерацию и восстановление на уровень потоков.

HTTP/1.1HTTP/2HTTP/3
Форматтекстбинарные фреймыбинарные фреймы
ТранспортTCPTCPQUIC поверх UDP
Параллельностьнесколько TCP-соединенийстримы в одном соединениистримы в одном соединении
Заголовкитекст целикомHPACKQPACK
HOL прикладнойестьнетнет
HOL транспортныйесть (в рамках соединения)есть, общий на все стримынет
РукопожатиеTCP + TLS = 2–3 RTTTCP + TLS = 2–3 RTT1 RTT, 0 RTT по билету
Смена сетирвётсярвётсяпереживает (connection ID)
Глубже, чем спросят

В Go клиент включает HTTP/2 сам, если ты пользуешься дефолтным http.Transport и не трогал TLSClientConfig. При ручной настройке TLS автоапгрейд отключается, и надо либо ставить ForceAttemptHTTP2: true, либо звать http2.ConfigureTransport. О версии договариваются в ALPN внутри TLS-рукопожатия, так что лишнего раунда нет. HTTP/2 возможен и без TLS (h2c), но браузеры так не умеют: это вариант для межсервисного трафика, в том числе gRPC.

Суть: QUIC - это транспорт поверх UDP, который затащил внутрь себя надёжность, порядок по каждому потоку отдельно и TLS 1.3. Это убирает транспортную HOL-блокировку, схлопывает рукопожатие до 1 RTT (0 RTT при возобновлении) и позволяет пережить смену сети. HTTP/3 - это HTTP поверх QUIC.

Четыре вещи, которые QUIC решает

  • HOL на транспорте. Порядок и восстановление потерь у каждого стрима свои, и потерянный пакет стрима A не задерживает данные стрима B.
  • Стоимость установки. TCP+TLS 1.3 обходятся в 2 RTT (1 на SYN, 1 на TLS). QUIC совмещает транспортное и криптографическое рукопожатие в 1 RTT, а при возобновлении по билету данные летят в первом же пакете, за 0 RTT.
  • Смена сети. Соединение опознаётся не по пятёрке адрес/порт, а по connection ID. Переключился с Wi-Fi на LTE, IP сменился, а соединение живо: ни рукопожатия заново, ни обрыва загрузки.
  • Окостенение. TCP нельзя развивать: промежуточные железки режут незнакомые опции. QUIC шифрует почти весь заголовок, включая номера пакетов, поэтому middlebox видит только UDP и не мешает эволюции.

Цена

  • Процессор. Пакеты обрабатываются в userspace, а не в ядре: нет разгрузки на сетевую карту (частично её сейчас дают UDP GSO/GRO), больше системных вызовов. Раньше разрыв был в 2–3 раза, сейчас заметно меньше, но он есть.
  • UDP местами режут. Корпоративные файрволы и некоторые провайдеры блокируют UDP/443, поэтому нужен фолбэк на TCP: сервер объявляет поддержку заголовком Alt-Svc: h3=":443", а клиент пробует оба варианта.
  • Инструменты. Привычный tcpdump покажет только шифрованный UDP; отладка требует ключей и свежих версий Wireshark.
Где это уже работает

HTTP/3 стандартизован в RFC 9114 (2022), его поддерживают Chrome, Firefox, Safari, Cloudflare, Google и все крупные CDN. По разным замерам на HTTP/3 сейчас приходится порядка трети трафика в интернете. В стандартной библиотеке Go его нет: используют quic-go, а в модуле golang.org/x/net есть экспериментальный пакет quic. Ценят, если добавишь: 0-RTT уязвим к replay, поэтому туда кладут только идемпотентные запросы.

Суть: асимметрика работает ровно в рукопожатии - доказать личность сервера подписью и выработать общий секрет через ECDHE. Дальше весь трафик идёт симметричным AEAD-шифром, потому что он на порядки быстрее. TLS 1.3 укладывается в 1 RTT, TLS 1.2 - в 2.

Рукопожатие TLS 1.3 по шагам

  1. ClientHello: версии, список шифронаборов, поддерживаемые группы, SNI (какой домен нужен), ALPN (h2/http1.1) и сразу key_share, долю ключа для той группы, которую клиент угадал.
  2. ServerHello: выбранный шифронабор и свой key_share. С этого момента обе стороны уже могут вычислить общий секрет, и всё дальнейшее шифруется.
  3. Сервер шифрованно шлёт Certificate, CertificateVerify (подписывает хэш всего рукопожатия приватным ключом и так доказывает владение) и Finished.
  4. Клиент проверяет цепочку и подпись, шлёт Finished и в том же полёте может слать HTTP-запрос. Итого 1 RTT.

Если клиент не угадал группу, сервер отвечает HelloRetryRequest, и рукопожатие стоит 2 RTT. В TLS 1.2 полное рукопожатие - 2 RTT, есть RSA key transport без forward secrecy и живы CBC-режимы, на которые пришлась половина известных атак. TLS 1.3 всё это выкинул: осталось пять шифронаборов, все с AEAD, а обмен ключами только эфемерный, с forward secrecy (её нет лишь у возобновления по одному PSK и у 0-RTT).

Цепочка доверия

Сертификат X.509 связывает домен с публичным ключом и подписан удостоверяющим центром. Клиент строит цепочку (лист → промежуточный → корень из хранилища ОС/браузера) и проверяет подписи, сроки, соответствие имени полю SAN (Common Name как имя не используется с 2017 года), назначение ключа и отзыв (CRL; OCSP уходит - Let's Encrypt выключил его в 2025 году). Корневые ключи держат офлайн и подписывают ими только промежуточные, поэтому сервер обязан отдавать промежуточный.

Что реально ломается
  • x509: certificate signed by unknown authority: забыли промежуточный сертификат. Браузер прощает, curl и Go нет.
  • x509: certificate is valid for A, not B: обращались по IP или по внутреннему имени, которого нет в SAN.
  • Сертификат просрочился в 3 часа ночи, потому что автопродление молча сломалось. Лечится алертом «осталось меньше 14 дней».
  • InsecureSkipVerify: true в проде выключает всю защиту, и MITM становится тривиальным. Нужен внутренний CA? Добавь его в RootCAs, а не отключай проверку.

SNI и mTLS

SNI несёт имя домена открытым текстом в ClientHello. Без него на одном IP не удержать много сайтов с разными сертификатами: сервер не знает, какой предъявить. Побочный эффект: наблюдателю видно, куда ты идёшь. Закрывает это расширение ECH.

В mTLS (взаимный TLS) сертификат предъявляет и клиент, а сервер проверяет его по своему пулу CA. Где применяется: межсервисный трафик в service mesh (Istio, Linkerd делают это прозрачно через сайдкар), доступ к Kubernetes API, банковские и платёжные интеграции, IoT, доступ к внутренним системам вместо VPN (модель BeyondCorp). Плюс: аутентификация без паролей и токенов, личность привязана к криптографии. Минус: свой PKI, то есть выпуск, распространение, ротация (в mesh автоматом каждые 24 часа) и отзыв.

// Сервер, требующий клиентский сертификат
srv := &http.Server{
    Addr: ":8443",
    TLSConfig: &tls.Config{
        MinVersion: tls.VersionTLS12,
        ClientAuth: tls.RequireAndVerifyClientCert, // не Request и не Verify...IfGiven
        ClientCAs:  clientCAPool,
    },
}
// Клиент с внутренним CA вместо InsecureSkipVerify
cfg := &tls.Config{RootCAs: internalPool, MinVersion: tls.VersionTLS12}
Суть: два независимых механизма. Свежесть (Cache-Control: max-age) позволяет вообще не ходить в сеть. Валидация (ETag + If-None-Match) позволяет сходить дёшево: сервер отвечает 304 без тела.

Как кэш принимает решение

  1. Есть ли запись по ключу (URL + заголовки из Vary)? Нет - идём в сеть.
  2. Свежа ли она (возраст меньше max-age / до Expires)? Если да, отдаём мгновенно, в сеть не ходим вообще.
  3. Протухшую не выбрасываем, а делаем условный запрос с If-None-Match (или If-Modified-Since). На 304 продлеваем свежесть и отдаём старое тело, на 200 заменяем.

Директивы, которые путают

  • no-cache не значит «не кэшировать». Кэшировать можно, но перед каждой отдачей обязательна ревалидация.
  • А вот no-store значит «вообще не сохранять». Для персональных данных и страниц с токенами.
  • private пускает только в браузер, не в CDN. public пускает и в общие кэши (и даже перебивает запрет для ответов с Authorization, штука опасная).
  • immutable - не перепроверять никогда: для файлов с хэшем в имени.
  • stale-while-revalidate=30: отдать протухшее сразу, а обновить фоном.

ETag против Last-Modified

ETag несёт хэш или версию содержимого и точен побайтово; W/"..." помечает слабый, «то же по смыслу». Last-Modified несёт время с точностью до секунды и два изменения внутри одной секунды не различит. Если есть оба, приоритет у ETag. Бонусом тот же механизм даёт оптимистическую блокировку на запись: If-Match: "9a1f3c", и при несовпадении сервер отвечает 412, а не затирает чужие изменения.

Про Vary забывают, и это дорого

Кэш ключуется по URL. Если ответ зависит ещё от чего-то (Accept-Encoding, Accept-Language, Authorization), это надо перечислить в Vary. Иначе общий кэш отдаст одному пользователю ответ, сгенерированный для другого. Худший сценарий: персональный ответ без private оседает на CDN и раздаётся всем. Правило: всё, что зависит от пользователя, помечай private (лучше no-store) и ставь корректный Vary.

Рабочая стратегия для веб-приложения

Статике с хэшем в имени (app.7f3a1c.js) ставь max-age=31536000, immutable. HTML-точке входа - no-cache плюс ETag: проверяем всегда, но обычно получаем 304 в 150 байт. API - private, max-age=0, must-revalidate плюс ETag, чтобы мобильный клиент экономил трафик на неизменившихся списках. Публичным справочникам - public, s-maxage=300, stale-while-revalidate=60, чтобы CDN снял нагрузку.

Что стоит запомнить

  • Идемпотентность метода описывает эффект повторного запроса, а не гарантию единственного выполнения.
  • TLS защищает конфиденциальность, целостность и подлинность peer в рамках проверки сертификата; он не проверяет бизнес-смысл запроса.
  • ETag/If-None-Match позволяют условный запрос и ответ 304 без тела, а Cache-Control задаёт политику кэша.

Частые ошибки

  • Не включай InsecureSkipVerify в production: это отключает проверку сертификата и имени.
  • Не отдавай 304 без корректного валидатора/сравнения текущего ETag.
  • Не помечай чувствительный персональный ответ public и не забывай Vary для заголовков, влияющих на представление.

Самопроверка

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

Чтение кода

1. Определи условный ответ

Сервер вычислил ETag "ab12". Клиент отправил If-None-Match: "ab12". Какой статус и тело должен вернуть показанный handler?

if match := r.Header.Get("If-None-Match"); match == etag {
    w.WriteHeader(http.StatusNotModified)
    return
}
Ответ и разбор

Handler возвращает 304 Not Modified без тела. Клиент может использовать сохранённое представление. ETag сравнивается с валидатором, созданным для текущего body; если представление зависит от Authorization или Accept-Encoding, cache policy/Vary должны разделять варианты.

Код-ревью

2. Найди отключённую TLS-проверку

Ревьюят client transport ниже. Какую проверку он отключил и почему фикс через RootCAs безопаснее?

tr := &http.Transport{
    TLSClientConfig: &tls.Config{InsecureSkipVerify: true},
}
Ответ и разбор

InsecureSkipVerify отключает стандартную проверку цепочки сертификата и имени сервера, поэтому клиент шифрует соединение, но не подтверждает, к кому подключился. Для внутреннего CA добавь доверенные сертификаты в RootCAs и оставь проверку включённой; MinVersion задай согласно поддерживаемой политике TLS.

Применение

3. Выбери код результата операции

API получает PUT /users/42 с полным представлением существующего пользователя; успешное изменение сохраняется. Сравни 200, 204 и 202 и выбери код для синхронно завершённой операции, указав, когда уместен альтернативный ответ.

Ответ и разбор

Для синхронно завершённого PUT можно вернуть 200 с представлением обновлённого ресурса или 204 без тела. 202 уместен, если запрос принят к асинхронной обработке и результат ещё не завершён. Код должен описывать состояние операции, а не только факт получения HTTP запроса.

Применение

4. Настрой приватный HTTP-кэш

Ответ /me содержит данные пользователя, ETag и max-age=60. Один браузер может сменить аккаунт, а reverse proxy общий. Какие Cache-Control/Vary решения нужны?

Ответ и разбор

Используй Cache-Control: private, max-age=60, чтобы shared proxy не кэшировал персональный ответ. Если ответ зависит от Authorization или Accept-Encoding, добавь подходящий Vary либо запрети кэширование полностью при смене учётки/чувствительных данных. ETag не заменяет контроль доступа.