9.3HTTP и TLS
Разбери семантику HTTP методов и статусов, затем TLS и HTTP caching. Научись выбирать ответ по результату операции, правильно проверять TLS peer и учитывать, какие запросы могут быть сохранены посредниками.
Как HTTP выглядит на проводе
HTTP/1.1 гоняет поверх TCP обычный текст. Сообщение состоит из стартовой строки, набора
заголовков вида Name: value, пустой строки и тела. Строки
всегда разделяет CRLF (\r\n), а два CRLF подряд означают «заголовки кончились».
$ 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
(длина известна заранее), 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 означает «обработай это по своим правилам», и какой идентификатор создать, обычно решает
сервер. Повтор создаёт новый ресурс - отсюда два заказа при двойном клике или ретрае после
таймаута. Это не теория: любая сетевая ошибка неоднозначна - ты не знаешь, то ли запрос не дошёл,
то ли дошёл, а потерялся ответ. Лечится ключом идемпотентности: клиент генерирует 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 403 | 401 - «я не знаю, кто ты»: токена нет, он протух или подпись невалидна. Обязателен заголовок WWW-Authenticate. Смысл: перелогинься и приходи снова. 403 - «я знаю, кто ты, и тебе нельзя»: аутентификация прошла, не хватает прав. Повтор с тем же токеном бесполезен. |
| 400 vs 422 | 400 - сообщение вообще не разобрать: битый JSON, нет обязательного поля, не тот тип. 422 Unprocessable Entity - синтаксис корректен, но семантика невалидна: дата окончания раньше даты начала, отрицательная сумма, несуществующий статус. Многие API живут только на 400 - это допустимо, но 422 точнее. |
| 404 vs 403 vs 410 | 404 - нет ресурса (или мы не хотим раскрывать, что он есть - часто отдают вместо 403 для приватных объектов). 410 Gone - был и удалён навсегда, поисковикам сигнал выкинуть из индекса. |
| 409 Conflict | Состояние ресурса не позволяет выполнить операцию: дубликат уникального поля, конфликт версий при оптимистичной блокировке (If-Match не совпал - тогда точнее 412 Precondition Failed), попытка отменить уже отгруженный заказ. |
| 429 Too Many Requests | Rate limit. Обязательно отдавай Retry-After (секунды или дата) и по возможности X-RateLimit-Remaining/Reset - иначе клиент будет долбиться в цикле. |
| 502 vs 503 vs 504 | 502 Bad Gateway - прокси сходил на апстрим и получил мусор или разрыв (апстрим упал, отдал битый ответ). 503 Service Unavailable - сервис сам говорит «я сейчас не могу»: перегрузка, деплой, circuit breaker открыт; сюда же Retry-After. 504 Gateway Timeout - апстрим не ответил за отведённое время. Разница важна при разборе инцидента: 502 - «упал бэкенд», 504 - «бэкенд тормозит». |
| 301 vs 302 vs 307/308 | 301/308 - навсегда (301 кэшируется браузером агрессивно, откатить тяжело), 302/307 - временно. Ключевое отличие: 301 и 302 исторически позволяли клиенту сменить POST на GET, а 307 и 308 обязаны сохранить метод и тело. Для API всегда бери 307/308. |
Клиентская библиотека должна ретраить только идемпотентные запросы и только на 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 |
Authorization | Bearer <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
Текстовый, в соединении один запрос за раз. Версия дала 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 и как
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
Директива 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)
}
Кэш ключуется по URL. Если ответ зависит ещё от чего-то (сжатия, языка, пользователя),
перечисли эти заголовки в Vary, иначе общий кэш отдаст одному пользователю
ответ, сгенерированный для другого. Самая опасная версия бага: персональный ответ без
Cache-Control: private оседает на CDN и раздаётся всем. Правило: любой ответ, который
зависит от Authorization или куки, помечай private (а лучше
no-store) и всегда ставь Vary.
Вопросы
8CRLF 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 | да | да | нет | редко |
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.
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))
})
}
Годами 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 - срок жизни, без них кука сессионная и живёт до закрытия браузера.
Куку браузер прикрепляет автоматически - отсюда CSRF и нужда в SameSite и
CSRF-токенах. Заголовок Authorization ставят руками, поэтому CSRF ему не
грозит, зато токен приходится где-то хранить, и localStorage для этого плохое
место: любой XSS его прочитает. В SPA обычно держат refresh-токен в HttpOnly-куке,
а access-токен в памяти JS.
Что было в 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 (оказался неудачным и де-факто мёртв).
HTTP/2 убрал HOL-блокировку на прикладном уровне, но добавил зависимость от одного TCP-соединения. TCP обязан отдавать байты приложению строго по порядку: если потерялся один сегмент, ядро держит в буфере всё, что пришло после него, и встают все стримы, даже те, чьи данные уже пришли. В HTTP/1.1 с шестью соединениями потеря била только по одному из них. Поэтому на плохой сети (мобильный интернет, потери 2–5%) HTTP/2 иногда проигрывает HTTP/1.1. Это и чинит QUIC: он уносит нумерацию и восстановление на уровень потоков.
| HTTP/1.1 | HTTP/2 | HTTP/3 | |
|---|---|---|---|
| Формат | текст | бинарные фреймы | бинарные фреймы |
| Транспорт | TCP | TCP | QUIC поверх UDP |
| Параллельность | несколько TCP-соединений | стримы в одном соединении | стримы в одном соединении |
| Заголовки | текст целиком | HPACK | QPACK |
| HOL прикладной | есть | нет | нет |
| HOL транспортный | есть (в рамках соединения) | есть, общий на все стримы | нет |
| Рукопожатие | TCP + TLS = 2–3 RTT | TCP + TLS = 2–3 RTT | 1 RTT, 0 RTT по билету |
| Смена сети | рвётся | рвётся | переживает (connection ID) |
В Go клиент включает HTTP/2 сам, если ты пользуешься дефолтным http.Transport и
не трогал TLSClientConfig. При ручной настройке TLS автоапгрейд отключается,
и надо либо ставить ForceAttemptHTTP2: true, либо звать
http2.ConfigureTransport. О версии договариваются в
ALPN внутри TLS-рукопожатия, так что лишнего раунда нет. HTTP/2 возможен и без TLS
(h2c), но браузеры так не умеют: это вариант для межсервисного трафика, в том числе gRPC.
Четыре вещи, которые 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, поэтому туда кладут только
идемпотентные запросы.
Рукопожатие TLS 1.3 по шагам
- ClientHello: версии, список шифронаборов, поддерживаемые группы, SNI (какой
домен нужен), ALPN (h2/http1.1) и сразу
key_share, долю ключа для той группы, которую клиент угадал. - ServerHello: выбранный шифронабор и свой
key_share. С этого момента обе стороны уже могут вычислить общий секрет, и всё дальнейшее шифруется. - Сервер шифрованно шлёт Certificate, CertificateVerify (подписывает хэш всего рукопожатия приватным ключом и так доказывает владение) и Finished.
- Клиент проверяет цепочку и подпись, шлёт 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 без тела.Как кэш принимает решение
- Есть ли запись по ключу (URL + заголовки из
Vary)? Нет - идём в сеть. - Свежа ли она (возраст меньше
max-age/ доExpires)? Если да, отдаём мгновенно, в сеть не ходим вообще. - Протухшую не выбрасываем, а делаем условный запрос с
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, а не затирает чужие изменения.
Кэш ключуется по 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 не заменяет контроль доступа.