Анатомия HTTP-запроса — что внутри request и response
Когда вы открываете сайт или дергаете API — ваш клиент шлёт HTTP-сообщение, сервер шлёт другое HTTP-сообщение в ответ. Это всё, что есть. Никакой магии. Сообщение — это просто текст, лежащий поверх TCP-соединения. Если вы научитесь читать этот текст — вы сможете дебажить почти любую сетевую проблему уровня приложения.
В этом уроке разбираем HTTP-сообщение по байтам. Что такое request-line, какие бывают методы, как устроены заголовки, что такое body, что значат статус-коды. Все примеры — реальный curl -v вывод, который вы можете повторить на своей машине прямо сейчас.
В курсе rest-api-fundamentals HTTP разбирается с точки зрения API-методологии — как проектировать ресурсы, как делать REST правильно. Здесь же мы смотрим на HTTP как на сетевой протокол: какие байты летят по проводам, как сервер парсит входящее, какой формат на проводе. Эти знания фундаментальны для понимания TLS, HTTP/2, прокси, кэширования — всего, что строится поверх HTTP.
Структура HTTP-сообщения
Любое HTTP-сообщение (и запрос, и ответ) имеет одну и ту же общую структуру:
<start-line>
<header1>: <value1>
<header2>: <value2>
...
<headerN>: <valueN>
<optional body>
Где start-line — это либо request-line (для запросов), либо status-line (для ответов). Дальше идут заголовки, по одному на строку, в формате Имя: Значение. Потом пустая строка (CRLF — два символа \r\n). Потом опциональное тело сообщения.
Самое важное — понять, что между заголовками и телом всегда идёт пустая строка. Это маркер конца секции заголовков. Если её забыть — сервер будет ждать ещё заголовков и не начнёт парсить тело. Этот баг в самописных HTTP-клиентах — классика.
Request line: метод, путь, версия
Первая строка запроса выглядит так:
GET /api/users/torvalds HTTP/1.1
Здесь три части, разделённые пробелами:
- Метод —
GET. Какое действие хотим выполнить. - Target —
/api/users/torvalds. Путь к ресурсу на сервере. Не URL целиком — именно path + query string. - Версия протокола —
HTTP/1.1.
Заметьте: домен (api.github.com) в request-line не передаётся. Сервер узнаёт его из заголовка Host: — именно так один сервер обслуживает множество сайтов на одном IP.
HTTP-методы
В HTTP стандартизировано девять методов. На практике используются восемь:
Понятия безопасный и идемпотентный — ключевые в HTTP, потому что они определяют, что прокси и клиенты вправе делать с запросом:
- Safe методы (GET, HEAD, OPTIONS) не меняют состояние сервера. Их можно кэшировать, повторять автоматически, выполнять параллельно.
- Idempotent методы (GET, HEAD, PUT, DELETE, OPTIONS) при повторе дают тот же результат. Это значит, что клиент может безопасно ретраить запрос, если получил timeout.
POST — ни safe, ни idempotent. Если вы получили timeout на POST — вы не знаете, дошёл запрос или нет. Поэтому в проде POST часто требует idempotency key в заголовке (Idempotency-Key: <uuid>), чтобы сервер мог отличить повтор от нового запроса.
Status line: версия, код, reason
Первая строка ответа:
HTTP/1.1 200 OK
Тоже три части:
- Версия —
HTTP/1.1. - Status code —
200. Трёхзначное число. - Reason phrase —
OK. Текстовое описание для людей. Не используется машинами и в HTTP/2 не передаётся вообще.
Status codes по категориям
Первая цифра кода говорит о категории:
На практике вам нужно знать наизусть примерно двадцать кодов. Запомните разницу между 4xx и 5xx: 4xx — виноват клиент (плохой запрос, нет прав, не найдено). 5xx — виноват сервер (упал, недоступен бэкенд, таймаут). От этого зависит, кому идти жаловаться.
Особенно важные коды для дебага:
- 401 vs 403. 401 — я не знаю, кто вы (нет/плохой токен). 403 — я знаю, кто вы, но вам сюда нельзя. Если получаете 403 — проверяйте права доступа в системе, не токен.
- 404 vs 410. 404 — ресурса не существует или вы не знаете. 410 — ресурс существовал, но удалён навсегда. На практике почти все ставят 404 и для удалённых.
- 502 vs 503. 502 — nginx/балансировщик не смог достучаться до бэкенда. 503 — бэкенд жив, но сам сказал «я перегружен». В первом случае проблема в инфраструктуре, во втором — в backpressure.
- 429 Too Many Requests. Rate limiting. Ответ обычно содержит
Retry-After: <seconds>. Уважайте этот заголовок — не ретраите тут же.
Headers: метаданные сообщения
Заголовки — это пары Имя: Значение, по одному на строку. Имена case-insensitive (Content-Type == content-type), но в HTTP/1.1 принято писать в CamelCase. В HTTP/2 заголовки передаются в lowercase.
Каноническое имя заголовка может содержать буквы, цифры и дефис. Значение — любой текст без CR/LF. Если нужно перенос строки — используют , в Set-Cookie и других multi-value заголовках.
В сыром HTTP заголовок не может содержать символ перевода строки внутри значения. Если ваш самописный код пишет значение через f"X-User: {name}", и name пришёл от пользователя — атакующий может вставить \r\n и инжектировать произвольные заголовки. Это называется HTTP Response Splitting и было реальной CVE-уязвимостью у многих приложений. Используйте библиотечный API для установки заголовков, а не строковые шаблоны.
Самые важные заголовки запроса
GET /api/users HTTP/1.1
Host: api.github.com
User-Agent: curl/8.4.0
Accept: application/json
Accept-Encoding: gzip, deflate
Accept-Language: ru-RU,en-US;q=0.5
Authorization: Bearer ghp_xxxxxxxxxxxxxx
Cookie: session_id=abc123; theme=dark
Content-Type: application/json
Content-Length: 87
Connection: keep-alive
If-None-Match: "etag-value-here"
Что значит каждый:
- Host — обязательный с HTTP/1.1, иначе сервер вернёт 400. Содержит имя домена. Нужен для virtual hosting.
- User-Agent — идентификатор клиента. Часто игнорируется, но некоторые сервера им filterят.
- Accept — какие MIME-типы клиент готов принять в ответе. Без него сервер обычно отдаёт default.
- Accept-Encoding — какие компрессии клиент понимает. Сервер ответит gzip/br/zstd, если знает.
- Authorization — credentials. Bearer-токен, Basic auth (base64 user:pass), Digest и т.д.
- Cookie — значения cookie, которые сервер ранее установил через
Set-Cookie. - Content-Type — MIME-тип тела запроса.
application/json,multipart/form-data,text/plainи т.д. - Content-Length — размер тела в байтах.
- Connection — управление соединением.
keep-alive— не закрывать после ответа.close— закрыть. - If-None-Match / If-Modified-Since — conditional GET для кэширования (отдельный урок).
Самые важные заголовки ответа
HTTP/1.1 200 OK
Date: Mon, 18 May 2026 14:23:01 GMT
Server: nginx/1.25.3
Content-Type: application/json; charset=utf-8
Content-Length: 1428
Content-Encoding: gzip
Cache-Control: public, max-age=3600
ETag: "33a64df551425fcc55e4d42a148795d9f25f89d4"
Last-Modified: Sun, 12 May 2026 18:00:00 GMT
Vary: Accept-Encoding
Set-Cookie: session_id=xyz; HttpOnly; Secure; SameSite=Lax
Strict-Transport-Security: max-age=31536000
- Date — когда сервер сгенерировал ответ.
- Server — название серверного софта. Часто скрывают по соображениям безопасности.
- Content-Type — тип тела ответа.
- Content-Encoding — сжатие тела. Если
gzip, клиент должен распаковать. - Cache-Control — правила кэширования.
- ETag / Last-Modified — маркеры версии для conditional GET.
- Vary — по каким request-заголовкам меняется ответ. Важно для прокси и CDN.
- Set-Cookie — сервер просит сохранить cookie. На каждом следующем запросе клиент пришлёт его обратно.
- Strict-Transport-Security (HSTS) — говорит браузеру всегда использовать HTTPS для этого домена.
Body: тело сообщения
Тело — это произвольные байты. Никаких структурных правил у HTTP к телу нет; формат определяет Content-Type. JSON, HTML, XML, бинарный PNG, gzip-compressed stream — всё может быть телом.
Длина тела передаётся одним из двух способов:
- Content-Length: N — ровно N байт следом за заголовками. Сервер знает заранее, сколько байт читать.
- Transfer-Encoding: chunked — неизвестная заранее длина. Тело идёт чанками: размер чанка в hex + CRLF + данные + CRLF + … +
0\r\n\r\nв конце.
Chunked-encoding нужен, когда сервер не знает заранее размер ответа — например, стримит сгенерированный JSON по мере чтения из БД, или это server-sent events.
HTTP/1.1 200 OK
Transfer-Encoding: chunked
Content-Type: text/plain
7\r\n
Hello, \r\n
6\r\n
World!\r\n
0\r\n
\r\n
В коде на Python вы chunked парсите вручную крайне редко — библиотеки делают это за вас. Но знать, что бывает, обязательно.
Реальный обмен — читаем curl -v
Самое полезное упражнение — запустить curl -v и прочитать всё построчно.
curl -v https://httpbin.org/anything/hello
Что вы увидите (отбрасываю TLS-handshake, на нём отдельный модуль):
> GET /anything/hello HTTP/1.1
> Host: httpbin.org
> User-Agent: curl/8.4.0
> Accept: */*
>
< HTTP/1.1 200 OK
< Date: Mon, 18 May 2026 14:23:01 GMT
< Content-Type: application/json
< Content-Length: 318
< Connection: keep-alive
< Server: gunicorn/19.9.0
< Access-Control-Allow-Origin: *
<
{
"args": {},
"data": "",
"files": {},
"form": {},
"headers": {
"Accept": "*/*",
"Host": "httpbin.org",
"User-Agent": "curl/8.4.0"
},
"method": "GET",
"url": "https://httpbin.org/anything/hello"
}
Префикс > — то, что мы отправили на сервер. Префикс < — то, что вернулось. Пустые строки после заголовков (с одним > или <) — это CRLF, маркер конца заголовков.
httpbin.org удобен тем, что эхает обратно полный набор: что было в запросе, какие заголовки сервер увидел. Это идеальный полигон для экспериментов.
POST с телом
Простой POST с JSON:
curl -v -X POST https://httpbin.org/post \
-H "Content-Type: application/json" \
-d '{"name": "Linus", "project": "linux"}'
Что улетит:
POST /post HTTP/1.1
Host: httpbin.org
User-Agent: curl/8.4.0
Accept: */*
Content-Type: application/json
Content-Length: 36
{"name": "Linus", "project": "linux"}
Заметьте: Content-Length: 36 — curl сам посчитал длину тела. Сервер прочитает первые 36 байт после CRLF — это тело запроса.
Если вы пишете самописный HTTP-клиент (например, на raw socket), забыть Content-Length — очень распространённая ошибка. Сервер будет ждать тело и в итоге отвалится по timeout, или прочтёт мусор.
Попробуй сам
Откройте терминал и потренируйтесь видеть HTTP своими глазами:
# 1. Простейший GET -- посмотреть request и response целиком
curl -v https://httpbin.org/get 2>&1 | grep -E '^[<>]'
# 2. Разные методы -- посмотрите, как меняется request-line
curl -v -X DELETE https://httpbin.org/delete 2>&1 | grep -E '^[<>]'
curl -v -X PUT https://httpbin.org/put 2>&1 | grep -E '^[<>]'
# 3. Status codes -- запросите разные коды через httpbin
curl -v https://httpbin.org/status/200 2>&1 | grep -E '^[<>]'
curl -v https://httpbin.org/status/404 2>&1 | grep -E '^[<>]'
curl -v https://httpbin.org/status/500 2>&1 | grep -E '^[<>]'
curl -v https://httpbin.org/status/418 2>&1 | grep -E '^[<>]' # I am a teapot
# 4. POST с разными форматами
curl -v -X POST -H "Content-Type: application/json" \
-d '{"hello": "world"}' https://httpbin.org/post 2>&1 | tail -30
# 5. С кастомными заголовками
curl -v -H "X-My-Header: experiment" \
-H "X-Trace-Id: abc-123" \
https://httpbin.org/headers 2>&1 | tail -20
# 6. HEAD -- только заголовки, без тела
curl -v -I https://httpbin.org/get 2>&1 | grep -E '^[<>]'
# 7. Посмотреть, во что превратится команда -- готовый текст запроса
curl --trace-ascii /dev/stdout https://httpbin.org/get 2>&1 | head -40
Поэкспериментируйте: меняйте методы, добавляйте заголовки, смотрите, что отвечает сервер. Это лучший способ закрепить интуицию.
Что вы должны вынести из этого урока
- HTTP-сообщение — это текст: start-line, заголовки, пустая строка, опционально тело.
- Метод определяет действие; status-code — результат.
- Безопасность (safe) и идемпотентность — ключевые свойства методов, определяющие, что можно делать прокси и retry-логике.
- Заголовок
Hostобязателен в HTTP/1.1. - Длина тела задаётся через
Content-LengthилиTransfer-Encoding: chunked. curl -vпоказывает реальный обмен. Префикс>— запрос,<— ответ.
В следующем уроке разберём, как HTTP, будучи stateless, поддерживает состояние через cookies и sessions, и почему CORS вообще существует.
HTTP-методы и статус-коды с точки зрения API-дизайна HTTP как протокол — request/response lifecycle